Skip to content
csieb2001Public

About

GroLo web: password-protected live mirror of a Growatt NEXA 2000 on Vercel

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

GroLo web

Live mirror of a Growatt NEXA 2000 balcony battery and a Wolf CHA heat pump, self-hosted next to the GroLo stack it belongs to. The stack pushes cleaned measurements and weather data every 30 s; this app stores them in Postgres and renders a live power-flow schema (solar → NEXA/battery → home, grid ↔ home with a Shelly meter, animated like the energy-flow screens of the Anker SOLIX or Growatt apps), tiles, charts, daily energy figures and a Costs and savings section (saved today/month/year/total, grid cost, payback), a Year calendar (one tile per day coloured by PV yield, click opens the day; highlights: strongest/weakest day, highest peak, best self-sufficiency, best month, year so far) a Top panels ranking and a Heat pump section (live tiles, heat output and COP, temperatures, heat and electricity per day with the performance factor, where the heat pump's electricity came from — straight from solar, from the battery or from the grid — plus heat cost, cost per kWh of heat and CO₂ against a gas boiler), in English and German.

web-push sidecar ──POST /api/ingest (Bearer token)──▶ grolo-web ──▶ grolo-db (Postgres)
   (same Docker network, the push never leaves the house)     ▲
                                                              │
  browser ──▶ Cloudflare Access ──▶ Cloudflare Tunnel ──▶ page and GET /api/data

Both containers are part of the stack's docker-compose.yml under the profile web, neither publishes a port to the network: the only way in from outside is the Cloudflare Tunnel, and an Access policy sits in front of it. Because the push service talks to the container directly, /api/ingest does not need to be reachable from the internet at all.

Environment variables

Variable Purpose
DATABASE_URL Postgres connection string, e.g. postgres://grolo:<password>@grolo-db:5432/grolo
INGEST_TOKEN Bearer token the push service sends to /api/ingest (openssl rand -hex 24)
LINK_SETTINGS, LINK_GRAFANA, LINK_HEATPUMP Optional addresses of the stack's other pages, shown in the header. Left empty they are derived from the page's own host name (pv.example.org → settings.example.org, grafana.example.org), which is why nothing needs configuring when the services follow that pattern. Without a subdomain — on localhost, say — the links are left out rather than guessed.
SITE_PASSWORD Optional password for the page. Set = login with a cookie holding an HMAC of it, never the password. Empty = no login at all, for when access is already handled in front of the app (Cloudflare Access). Leave it empty only if nothing else can reach the container.

The push service in the stack needs WEB_URL=http://grolo-web:3000 and WEB_TOKEN=<INGEST_TOKEN> in its .env.

API

POST /api/ingest with Authorization: Bearer <INGEST_TOKEN>:

{
  "samples": [{ "ts": "2026-09-06T10:00:00Z", "device": "<serial>", "pv_w": 45.2, "out_w": 85.0, "bat_w": -39.8, "soc": 48,
                "soc1": 51, "soc2": 46, "temp_sys": 29.5, "temp_bat1": 21.0, "temp_bat2": 18.0,
                "pv_v": [33.1, 7.2, 7.2, 7.2], "pv_a": [1.44, 0.1, 0, 0.04], "packs": 2, "status": "Idle", "mode": "Load First",
                "grid_w": 212.5, "house_w": 297.5 }],
  "shelly": { "grid_w": 212.5, "household_w": 297.5, "out_w": 85.0, "target_w": 300, "setpoint_w": 20, "ok": true, "enabled": true, "host": "192.168.1.158",
              "limited": false, "reason": "ok", "soc": 48, "soc_limit": 8 },
  "tariff": { "price_ct_kwh": 32.5, "feedin_ct_kwh": 0, "system_cost_eur": 1500, "currency": "EUR" },
  "heat": {
    "ts": "2026-09-06T10:00:00Z",
    "samples": [{ "ts": "2026-09-06T10:00:00Z", "hp_w": 1000, "heat_w": 5300, "flow_c": 52.5, "return_c": 51.2, "dhw_c": 49.6,
                  "outside_c": 19.1, "spread": 1.3, "freq": 25, "flow_lpm": 26.7, "compressor": 1, "mode": 8 }],
    "days": [{ "day": "2026-09-06", "heat_kwh": 24, "el_kwh": 7 }, { "day": "2026-09-05", "heat_kwh": 55, "el_kwh": 14, "spf": 3.9 }],
    "state": { "mode_text": "Warmwasser", "compressor_text": "Betrieb", "cop": 5.3, "spf_year": 3.3, "heat_today": 24, "el_today": 7, "…": "…" }
  },
  "info": { "device": "<serial>", "dongle_model": "GTSW0000", "dongle_sw": "4.0.2.6", "dongle_hw": "V1.0", "wifi_dbm": "-68" },
  "weather": {
    "ts": "2026-09-06T10:00:00Z",
    "current": { "temperature": 9.1, "cloud_cover": 29, "shortwave_radiation": 412.0, "direct_radiation": 300.0, "diffuse_radiation": 112.0,
                 "wind_speed": 3.2, "weather_code": 1, "is_day": 1, "condition_en": "Mainly clear", "condition_de": "Überwiegend klar",
                 "sunrise": "2026-09-06T06:39:00+02:00", "sunset": "2026-09-06T20:02:00+02:00", "sunshine_duration_today": 25400, "radiation_sum_today": 3.9 },
    "forecast": [{ "t": "2026-09-06T11:00:00Z", "shortwave_radiation": 300, "cloud_cover": 40, "temperature": 12.3, "weather_code": 3 }],
    "site": { "name": "25421 Pinneberg", "lat": 53.64, "lon": 9.81, "strings": { "1": { "tilt": 30, "azimuth": 180, "wp": 440 } } },
    "model": [{ "t": "2026-09-06T10:30:00Z", "string": 1, "gti": 612.0, "expected_w": 229.0 }]
  }
}

heat comes from the heat pump bridge in the stack: samples carry the same timestamps as the NEXA samples, which is what lets the site work out how much of the heat pump's draw the NEXA covered at that moment; days are the heat pump's own daily counters (today and the previous day, so a gap over midnight closes itself) and are upserted as the highest value seen per day; state is the current picture for the tiles, stored as one row.

weather.current may also carry sun_azimuth / sun_elevation. weather.site is the location and panel orientation from the GroLo settings page (names = optional per-string labels, shown everywhere instead of “String n”), weather.model the expected power per string and hour (transposed Open-Meteo irradiance).

grid_w/house_w per sample are the Shelly grid import and household load averaged over the push interval (only while the zero feed-in control runs); shelly is the controller state, tariff the electricity price from the stack's settings page (one row, upserted). All fields are optional except ts and device per sample; info, weather, shelly and tariff may be missing. Samples are upserted on (device, ts), weather.current is kept as a single row plus a history row per timestamp, weather.forecast is upserted per hour so newer forecasts overwrite older ones. Tables are created on first use.

GET /api/data?range=24h|7d|30d&day=YYYY-MM-DD&year=YYYY returns latest, series (bucketed), daily (kWh per day from minute averages, including grid_kwh/feedin_kwh/house_kwh from the Shelly), today, totals, periods (output to house, grid import and export since the start of the month and year and in total, with days and since for the payback estimate), tariff (price, feed-in rate, system price; the page assumes 30 ct/kWh when missing), shelly (controller state incl. reason), calendar (daily PV/house/grid/AC-charging kWh, minutes with data, PV peak and its time for every day of the selected year, plus the years with data), string_rank (kWh per PV input over 24 h, 7 and 30 days), info, weather { current, forecast (next 48 h), history }, heatpump (state, series bucketed like series, and days with the heat pump's own heat_kwh/el_kwh/spf plus hp_kwh, solar_kwh and direct_kwh — the part the NEXA covered and the part of that which came straight from the panels rather than the battery) and for the strings section site, day (bounds of the selected day, default today), string_peaks (daily peak per string, 30 days), strings_day (5-minute power per string of the selected day), heat (hourly mean per string and day, 30 days), model_day (expected power per string of the selected day) and alltime (per string and total: highest 30-second sample with its time and the mean power during daylight over all minutes with data; daylight = sun elevation > 0 from an approximation in SQL using site.lat/lon, central Germany if no location is set). The sun path on the page is computed in the browser (lib/solar.ts, NOAA algorithm) from site.lat/lon.

Auth: with SITE_PASSWORD set, POST /api/login (form field password) sets the grolo_auth cookie for 30 days and /api/logout clears it; everything except /login, /api/login, /api/ingest and static assets then requires the cookie (proxy.ts). With SITE_PASSWORD empty there is no login and no cookie — see the table above for when that is safe.

Develop

npm install
docker run -d --name grolo-dev-db -e POSTGRES_PASSWORD=grolo -e POSTGRES_USER=grolo -e POSTGRES_DB=grolo -p 5432:5432 postgres:18-alpine
echo 'DATABASE_URL=postgres://grolo:grolo@127.0.0.1:5432/grolo' > .env.local   # plus INGEST_TOKEN, optional SITE_PASSWORD
npm run dev

The tables are created on first use, so an empty database is enough to start; feed it through /api/ingest.

Deploy

Clone this repository next to the stack (the compose file expects ../grolo-web) and let the stack build it:

docker compose build grolo-web
docker compose up -d grolo-web

next.config.ts uses output: "standalone", so the image carries only the server bundle. Publishing it to the internet is the tunnel's job and is described in the stack's README.

License: MIT.

About

GroLo web: password-protected live mirror of a Growatt NEXA 2000 on Vercel

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages