Metadata-Version: 2.4
Name: enedis-client
Version: 1.0.2
Summary: MyElectricalData client for fetching the Enedis (Linky) load curve
Author-email: Jordan Roimarmier <jordan.roimarmier@outlook.com>
License-Expression: Apache-2.0
Keywords: enedis,linky,myelectricaldata,energy,home-assistant
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Natural Language :: English
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Home Automation
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.27
Requires-Dist: python-dotenv>=1.0
Requires-Dist: tzdata; sys_platform == "win32"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
Requires-Dist: respx>=0.21; extra == "dev"
Provides-Extra: ha
Requires-Dist: pytest-homeassistant-custom-component==0.13.364; extra == "ha"
Provides-Extra: lint
Requires-Dist: ruff>=0.6; extra == "lint"
Dynamic: license-file

# enedis-client

Fetches the **load curve** of a French Linky meter through the
[MyElectricalData](https://www.myelectricaldata.fr) 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

```bash
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:

```bash
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

```python
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](src/enedis_client/models.py) and covered by tests.

- **`date` is the end of the interval**, not its start. `IntervalReading`
  exposes both (`start` and `end`).
- **`value` is an average power in watts**, not an energy. `energy_wh` does the
  conversion using the interval's real duration.
- **The measurement step is not always 30 minutes**: Enedis delivers `PT10M`,
  `PT30M` or `PT60M` depending 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`/`daily` split 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. `--yes` overrides.
- **Two quotas, not one.** `enedis check` shows 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, and `enedis fetch` relays it verbatim. That
  refusal is never retried: access does not reopen for hours.
- The gateway cache is used by default; `--no-cache` forces 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/](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

```bash
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:

```bash
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.

```bash
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](src/enedis_client/config.py) | Reading and validating `.env`, masking the token |
| [client.py](src/enedis_client/client.py) | HTTP calls, retries, error translation |
| [async_client.py](src/enedis_client/async_client.py) | The same surface, asynchronous |
| [http.py](src/enedis_client/http.py) | Shared mapping from HTTP status to exception |
| [models.py](src/enedis_client/models.py) | Parsing, time zone, energy; `LoadCurve` and `DailySeries` |
| [hourly.py](src/enedis_client/hourly.py) | Hourly aggregation and plausibility filter |
| [windows.py](src/enedis_client/windows.py) | Window splitting (7 days hourly, 1095 daily) |
| [loadcurve.py](src/enedis_client/loadcurve.py) | Chaining windows and stitching them back |
| [export.py](src/enedis_client/export.py) | JSON and CSV writing |
| [errors.py](src/enedis_client/errors.py) | One exception per actionable cause |
| [cli.py](src/enedis_client/cli.py) | The `check`, `daily`, `fetch`, `demo`, `cache-clear` commands |
| [demo.py](src/enedis_client/demo.py) | Fake gateway backing demo mode |
