External integration for Gladys Assistant that reads the daily gas consumption of a GRDF Gazpar meter and publishes it as Gladys devices.
Built on the official integration template and the JavaScript SDK.
User documentation: docs/en.md · docs/fr.md.
One Gladys device per PCE (metering point) of the GRDF account, each with four read-only sensors:
| Feature | Category | Unit |
|---|---|---|
| Consommation quotidienne | energy sensor | kWh |
| Volume quotidien | volume sensor | m³ |
| Index compteur | volume sensor | m³ |
| Température extérieure moyenne | temperature sensor | °C |
Gladys derives a fifth one itself, the cost of the daily consumption (see below), so the device carries it without the integration publishing it.
Feature names are in French on purpose: GRDF is a French-only service and this is the vocabulary its users recognize. Code and comments stay in English.
Each value is published with the timestamp of its own gas day (noon UTC of the day it belongs to), not the time it was downloaded — GRDF publishes readings one to two days late, and the charts must stay honest.
GRDF has no public API for individuals, so the client reproduces the browser login of monespace.grdf.fr:
GET https://monespace.grdf.fr/— the Okta login page, which embeds astateTokenin its HTML;POST /idp/idx/identify— the email, which returns astateHandle;POST /idp/idx/challenge/answer— the password, which returns thesuccessURL of the flow;GET <success URL>— the redirect chain ends onmonespace.grdf.frand drops theauth_tokencookie;GET /api/e-conso/…— the JSON endpoints, authenticated by that cookie.
This is scraping: GRDF can break it at any time, and an account protected by a one-time code or a captcha cannot be used. Every failure is surfaced with an explicit message in the Configuration screen rather than swallowed.
GRDF never says "your session is not valid". Being a single-page app behind a
gateway, an unauthenticated request comes back as a redirect to the login page,
or as the app's own HTML shell with a 200 — never as a clean 401. The client
therefore treats all three as the same thing: renew the session and replay
(bounded to two renewals per request, then plain backoff). Redirects are not
followed on API calls, precisely so that "go and authenticate" does not decay
into an opaque HTML page, and the HTML body is inspected to log which page
GRDF served. A whoami call after each login gives the gateway the same
bootstrap the web app performs.
Cookies are handled by a small in-repo jar (src/grdf/cookieJar.js)
because Node's fetch ignores them — the integration has no runtime dependency
beyond the Gladys SDK.
.
├─ index.js # SDK bootstrap + event wiring (no GRDF logic)
├─ src/
│ ├─ grdf/
│ │ ├─ client.js # login flow + JSON endpoints (+ retries)
│ │ ├─ cookieJar.js # minimal cookie jar for fetch
│ │ └─ readings.js # cleanup of the raw GRDF readings
│ ├─ devices/
│ │ ├─ gasMeter.js # one PCE -> one device + its features
│ │ └─ index.js # registry (devices are dynamic)
│ ├─ gazpar.js # synchronization: readings -> states + intervals
│ ├─ publisher.js # paced publishing (host API rate limit)
│ ├─ throttle.js # rate floor of the scheduled path
│ ├─ store.js # sync cursor persisted in /data
│ ├─ dates.js # gas-day helpers
│ └─ config.js # config defaults + normalization
├─ docs/{en,fr}.md # user documentation, re-hosted by Gladys
├─ tools/generate-cover.js # regenerates cover.png (no dependency)
├─ gladys-assistant-integration.json # manifest (name, config schema, image…)
└─ Dockerfile # Node 24 Alpine, read-only rootfs ready
The daily consumption goes through publishEnergyConsumption, not through the
states path. That API exists for exactly this case — a supplier whose readings
arrive after the fact — and it is what makes a cost possible: Gladys stores the
value as a dated interval, derives a <external_id>_cost feature at
discovery, attaches it to the meter and prices it against the user's energy
contract, recomputing the past when a price changes. A state carries an
instant, not an interval, and nothing can price it.
The integration therefore publishes no cost itself and no price configuration: the gas price belongs to the supplier, not to GRDF, and the user enters it in the Gladys energy screen. Until a contract is attached to this meter, Gladys files the consumption under the main electric meter — which would price gas at the electricity rate — so the user docs say to create one.
The meter index is a volume sensor, not an energy-sensor/index. Gladys
reads an energy index as a cumulative energy reading: it derives a half-hourly
consumption and a cost from its deltas and hangs them under the main electric
meter. Ours counts cubic metres of gas, so that pipeline would bill m³ at the
electricity price.
No energy_contracts template is declared: the manifest field exists, but the
tariff grammar of its rules mode is documented neither in the SDK nor in the
community catalogue, and inventing a schema is how an integration breaks
quietly. A flat French gas tariff would fit it well the day it is published.
Three constraints shape the pacing, all of them found in the Gladys core:
-
Gladys polling is not usable here.
poll_frequencyaccepts only a closed list of values (DEVICE_POLL_FREQUENCIES), in milliseconds, one minute at the slowest — a scheduler for hardware on the LAN. The devices declare none and the integration runs its own timer at the configured interval (6 h by default).onPollis still handled, in case the core ever asks on demand. -
300 states per minute, per integration. Over that the host API answers 429 and the batch is lost — the SDK does not retry.
StatePublisherkeeps a budget of 250 per minute and waits for the next window instead of losing data. A full three-year import is therefore paced over ~18 minutes.Two details make the pacing actually hold. The window is anchored on the moment the server acknowledged the first batch, never on the moment we decided to send it: the core starts counting when the request arrives, so anchoring before the round trip leaves us ahead of it and a wait of exactly one window resumes a hair too early — which is how a paced import still earns a 429. A margin is added on top, and a batch refused anyway is waited out and sent again rather than lost.
The cursor is committed every 25 days rather than at the end of the import, so a failure halfway through costs the remaining days, not the whole run.
-
GRDF is somebody else's website. A 30-minute floor per meter guards the scheduled path whatever interval ends up applied; the explicit "Refresh now" button bypasses it.
Publishing a discovered device does not create it — the user does, from the
Discovery screen. A state aimed at a device that was never created is dropped
by the core, which logs DeviceFeature not found (or not added to Gladys) on
its own side and answers success to the integration.
That silence is a trap for an integration that imports history: it would
"publish" a week of readings seconds after discovery, move its cursor past
them, and never send those days again. So every pass asks getDevices() first
and skips the meters the user has not added — without touching their cursor.
onDeviceCreated then imports the history the moment a meter is added, and
onDeviceDeleted forgets its cursor so re-adding starts fresh.
onDeviceCreated is a single event, though, and the moment it covers is the
one that matters most: a fresh install, the user adding the meter, expecting
the dashboard to fill. Miss it and they wait hours. So while any meter is
offered but not added, a catch-up pass runs every minute (for an hour) and
imports as soon as one appears. It is free while it waits: a meter that has
not been added triggers no GRDF request at all.
As a safety net, a cursor claiming days were published for a device that has never held a single value is treated as wrong: it is rewound and the history re-imported.
GRDF answers with a whole period at once, so the integration keeps a cursor —
the last gas day published per PCE — in /data/gazpar-state.json, the only
writable location of the sandboxed container. On the first run it imports the
configured history (7 days by default, up to 3 years); afterwards it only asks
for what is new. If /data is not writable the integration keeps working, it
simply re-imports the history window after a restart.
npm install
GLADYS_HOST_API_URL="http://localhost:1443" \
GLADYS_INTEGRATION_TOKEN="<token>" \
GLADYS_INTEGRATION_SELECTOR="grdf-gazpar" \
GAZPAR_DATA_DIR="./data" \
LOG_LEVEL=debug \
npm startThe three GLADYS_* variables are injected by the Gladys supervisor when the
integration runs inside its sandboxed container; GAZPAR_DATA_DIR only exists to
keep the sync cursor out of /data while developing.
npm run format:check # Prettier
npm run lint # ESLint
npm test # unit tests, via the built-in `node --test` runnerThe same three checks run on every push and pull request
(.github/workflows/ci.yml). The tests cover the
login flow (with fetch stubbed), the cookie jar, the cleanup of the GRDF
payloads, the day arithmetic, the device mapping and the synchronization cursor
— no network, no GRDF account needed.
Before tagging a release, the store validation can be run locally:
npx github:GladysAssistant/integration-store .- Push this repository to GitHub, public, with the topic
gladys-assistant-integration. The manifest declares theenergycategory — the shelf the integration sits on in the catalog sidebar (Gladys 4.86+, 1 to 3 keys amongclimate,lighting,energy,security,multimedia,appliances,environment,protocols,network,notifications,assistants,services). Declaring the field requires agladys_versionminimum of 4.86.0 or later: older cores reject unknown manifest fields, and the store validator enforces the coupling. - Actions → Release → Run workflow, pick
patch,minorormajor: the workflow bumps the version everywhere (package.json+ manifestversion/docker_image), pushes thevX.Y.Ztag and builds thelinux/amd64+linux/arm64image toghcr.io. - The decentralized indexer picks up the new manifest version and Gladys offers a one-click install.
Apache-2.0