Metadata-Version: 2.4
Name: enedis-client
Version: 1.0.1
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"
```

### 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 |
