enedis-client
Fetches the load curve of a French Linky meter through the MyElectricalData gateway, and exports it as JSON and CSV.
It is also the foundation of a Home Assistant integration: all the data-access logic lives here, so it can be reused as-is.
Getting started
make setup # venv and dependencies
make demo # try everything, with no token and no network
make demo runs the whole chain — window splitting, parsing, aggregation,
export — on simulated data. It is the fastest way to see what the tool produces
without spending any of your Enedis quota.
For real data:
cp .env.example .env # then fill in ENEDIS_PDL and ENEDIS_TOKEN
make check # verify the consent and the remaining quota
make daily # daily consumption in kWh -> dumps/
make fetch # load curve (needs hourly collection enabled)
Start with make daily. Daily consumption depends only on the consent and
works immediately. The load curve additionally requires hourly collection
to be enabled on the Enedis side — see Troubleshooting.
Commands
| Command | Effect |
|---|---|
enedis check |
Validates .env, the Enedis consent state and the call quota |
enedis daily --days 30 |
Daily consumption in kWh — works without hourly collection |
enedis fetch --days 30 |
Load curve at the meter's own step |
enedis fetch --start 2026-01-01 --end 2026-02-01 |
A precise period (--end is exclusive) |
enedis demo --days 20 |
The load curve on simulated data |
enedis cache-clear |
Erases the data the gateway has cached |
Shared options: --format json|csv|both, --out <directory>, --no-cache,
-y/--yes, -v for detail, --env-file for an alternative .env.
--end is exclusive: --end 2026-02-01 stops at 31 January inclusive.
Impossible periods (reversed, in the future, beyond 24 months) are rejected
with a message and exit code 2, before any network call.
The binary is called enedis once the project is installed;
python -m enedis_client works too.
The cache stores your data with a third party
The /cache endpoints have your data stored encrypted, for 30 days, on
MyElectricalData's servers. It is a trade-off: the cache spares the Enedis
quota, which is tight.
The cache is on by default, and every command says so on screen.
--no-cache goes straight to Enedis without leaving anything behind, at the
cost of one quota call. enedis cache-clear erases what is already there.
What the client covers
| Endpoint | Method | Data |
|---|---|---|
consumption_load_curve |
consumption_load_curve() |
Average power in W, at the meter's step |
production_load_curve |
production_load_curve() |
The same, for solar production |
daily_consumption |
daily_consumption() |
Energy in Wh, per day |
daily_production |
daily_production() |
The same, for production |
contracts |
contracts() |
Subscribed power, tariff option |
valid_access |
valid_access() |
Consent, quota, reset time |
*/cache (DELETE) |
clear_cache() |
Purge of the gateway cache |
identity, contact and addresses also exist on the gateway but are not
exposed: they are personal details unrelated to consumption.
As a library
from datetime import date, timedelta
from enedis_client import MyElectricalDataClient, Settings, fetch_daily, fetch_load_curve
today = date.today()
with MyElectricalDataClient(Settings.from_env()) as client:
# Daily: one energy in Wh per day.
series = fetch_daily(client, today - timedelta(days=30), today)
print(f"{series.total_energy_kwh:.1f} kWh over {len(series)} days")
# Load curve: one average power in W per interval.
curve = fetch_load_curve(client, today - timedelta(days=7), today)
for reading in curve.readings[:3]:
print(reading.start, "->", reading.end, reading.power_w, "W", reading.energy_wh, "Wh")
An AsyncMyElectricalDataClient mirrors the same surface on httpx.AsyncClient,
for callers that must not block an event loop — Home Assistant, for instance.
DailySeries and LoadCurve are deliberately distinct types: the daily
series carries Wh of energy, the load curve W of power. Feeding one to the
other's parser raises an explicit ParseError rather than producing numbers
that are wrong by a factor of two.
What is tricky about the Enedis format
Three details silently break the arithmetic if you miss them. They are handled in models.py and covered by tests.
dateis the end of the interval, not its start.IntervalReadingexposes both (startandend).valueis an average power in watts, not an energy.energy_whdoes the conversion using the interval's real duration.- The measurement step is not always 30 minutes: Enedis delivers
PT10M,PT30MorPT60Mdepending on the meter — and it has been observed changing in the middle of a day. It is read from every single measurement.
On top of that, the clock change: in October the hour 02:00–03:00 happens
twice and Enedis returns the same timestamps twice. They are disambiguated by
arrival order. To compare or subtract two readings, use end_utc — between two
datetime objects sharing a time zone, Python subtracts the naive values and
ignores the offset.
API limits
- Load curve: 7 days maximum per call, 24 months of history.
- Daily: 1095 days per call, 36 months of history. Enedis says so itself — "Il est possible de recevoir au maximun 1095j de données consécutives".
- Nothing for the current day: bounds are clamped automatically into the
servable window, and
fetch/dailysplit and stitch longer periods. - A long range is announced before it is run. The number of calls is printed
up front, and beyond 20 the command asks for confirmation — two years of
history is 105 calls against a quota of roughly 50 per day.
--yesoverrides. - Two quotas, not one.
enedis checkshows the gateway counter (calls/quota), but Enedis additionally applies its own throttling, which can refuse a call while that counter still reads zero. The response then carries the reopening time, andenedis fetchrelays it verbatim. That refusal is never retried: access does not reopen for hours. - The gateway cache is used by default;
--no-cacheforces a real Enedis call.
Troubleshooting
| Message | Cause | What to do |
|---|---|---|
no measurement between … hourly collection must be enabled |
The gateway answers 404 no measure found. The consent can be valid without hourly collection being enabled: it is a separate option, switched on in the Enedis customer account (Je souhaite accéder à mes données horaires). |
Enable it, then wait: it goes through En cours d'activation and the first readings arrive within a few days. In the meantime, enedis daily works. |
Enedis call quota reached … Access reopens at … |
Enedis throttling, independent of the counter shown by enedis check. |
Wait for the stated time. The gateway cache avoids coming back. |
token rejected by the gateway (403 Le point de livraison ne correspond au token fourni) |
ENEDIS_TOKEN invalid, or tied to another delivery point. Renewing the consent invalidates the previous token. |
Take the token shown on myelectricaldata.fr and put it back into .env. |
Enedis consent missing or expired |
The consent has an end date. | Renew it on myelectricaldata.fr. |
enedis check answers the last three cases without spending any Enedis quota.
Home Assistant integration
The custom_components/enedis_med/ directory holds an integration installable through HACS as a custom repository (category Integration), then added from Settings → Devices and services.
Configuration is entirely through the UI: delivery point and token. The pair is validated against the gateway before the entry is created, so a typo shows up immediately.
Consumption appears in the Energy dashboard under Grid consumption, named
Enedis <pdl> consommation. It is not an entity but an external statistic:
that is the only way to inject past history, which a plain sensor cannot do.
What you should know
- The token is stored in clear text in
.storage/core.config_entries. That is how Home Assistant works for every integration. - Renewing a consent on myelectricaldata.fr invalidates the previous token. Home Assistant detects this and opens a re-authentication form that asks only for the token.
- Catch-up is spread out. One call covers 7 days and the quota is around 50 per day: after a long outage the dashboard fills over several cycles rather than in one go. The cycle is hourly, and it makes no API call at all while yesterday is already complete.
- Impossible measurements are discarded. Any interval above the subscribed power read from your contract is skipped and logged. On real data, a point at 20,822 W against a 9 kVA subscription took the total error from 5.75 % down to 1.31 % against the daily index readings.
Development
make test # no network calls: respx simulates the gateway
make lint # ruff style check
The environment requires Python 3.14, imposed by
pytest-homeassistant-custom-component. make setup installs it through uv.
To freeze the exact format your own meter returns:
python scripts/capture_fixture.py --day 2026-09-01
One call, on one day; the delivery point is replaced and the power values are jittered, so the resulting fixture is safe to commit.
Publishing
The GitLab CI runs on the self-hosted COMPX runner (tag roims-tech).
| Trigger | Version produced | Destination |
|---|---|---|
Push to develop |
<next patch>.dev<pipeline> |
The project's GitLab package registry |
Tag 1.2.3 or v1.2.3 |
1.2.3 |
Public PyPI |
Development builds are pre-releases under PEP 440: pip ignores them unless
--pre is given, so they cannot supersede the stable release. A naive
<version>-<build> would normalise to <version>.post<build>, which would
instead be installed by default.
Publishing a stable release takes two steps: align version in
pyproject.toml, then push the matching tag. If the two disagree, the build
job fails before anything is published and the pypi job is skipped.
pip install --index-url https://gitlab.com/api/v4/projects/86382035/packages/pypi/simple \
"enedis-client==1.0.1.dev2"
Deploying the Home Assistant integration
HACS installs from GitHub only, so this GitLab repository cannot be added as
a HACS custom repository. The integration is deployed by copying
custom_components/enedis_med/ into Home Assistant's /config/custom_components/.
The pipeline carries a manual home-assistant job, available on tags only.
It runs on the COMPX runner, which sits on the same network as the Home
Assistant VM, so it reaches it directly. It needs two CI variables:
| Variable | Content |
|---|---|
HA_SSH_KEY_B64 |
SSH private key, base64-encoded on a single line, masked and protected |
HA_HOST |
Instance address (optional, defaults to the value in .gitlab-ci.yml) |
The key is base64-encoded because GitLab cannot mask a multi-line value. The variable must be protected so that only protected tags and branches can read it — otherwise any pipeline, including one from a merge request, could use a key that grants root access to Home Assistant.
Deployment is deliberately manual: it touches a live system and must never fire
on its own. It is offered on tags and on main — the latter is handy
for shipping an integration fix without cutting a release.
Before copying anything, the job checks that the version pinned by
manifest.json is actually published on PyPI. Home Assistant installs a
custom integration's requirements from PyPI when it loads it, so deploying a
manifest that pins an unpublished version yields a broken integration and an
obscure log entry. The check retries, because the PyPI index lags a few seconds
behind an upload.
Disk footprint
The pipeline is deliberately frugal: python:3.14-alpine image (82 MB on disk
against 179 for slim), shallow clone at 20 commits, uv cache keyed on the
hash of pyproject.toml, artifacts limited to dist/ (77 kB) and expiring in a
day, superseded pipelines cancelled automatically.
Above all: the jobs that gate publication install only the 34 MB of library
dependencies, not the 737 MB the Home Assistant test harness requires — it
is the library that gets published, not the integration. The tests under
tests/ha/ still run locally through make test.
Structure
| File | Role |
|---|---|
| config.py | Reading and validating .env, masking the token |
| client.py | HTTP calls, retries, error translation |
| async_client.py | The same surface, asynchronous |
| http.py | Shared mapping from HTTP status to exception |
| models.py | Parsing, time zone, energy; LoadCurve and DailySeries |
| hourly.py | Hourly aggregation and plausibility filter |
| windows.py | Window splitting (7 days hourly, 1095 daily) |
| loadcurve.py | Chaining windows and stitching them back |
| export.py | JSON and CSV writing |
| errors.py | One exception per actionable cause |
| cli.py | The check, daily, fetch, demo, cache-clear commands |
| demo.py | Fake gateway backing demo mode |
Metadata
Release files for enedis-client 1.0.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| enedis_client-1.0.2.tar.gz | 47.0 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| enedis_client-1.0.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 82.7 kB
Release files / enedis_client-1.0.2.tar.gz
| Download URL | enedis_client-1.0.2.tar.gz |
|---|---|
| Size | 47.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
300cf27f046ad97ccc9f57b2c5a6178d0da761ed9a88d213c7f3a02e1c9fc98f
|
|
BLAKE2b-256 checksum How to use checksums |
505bac0831c9f737c53d5d9c856358aed9e3067b1d1924470d22024628c49412
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Release files / enedis_client-1.0.2-py3-none-any.whl
| Download URL | enedis_client-1.0.2-py3-none-any.whl |
|---|---|
| Size | 35.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0a015c345a60cff4637ed0b0338ebdaf90a63f75042610c3b2ea99f9c49f7e89
|
|
BLAKE2b-256 checksum How to use checksums |
efe28aefef48250a02e3b0c5ade334e1cbeef04a22376d9acd902d5d9f2b26b5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Security for enedis-client 1.0.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.