Metadata-Version: 2.4
Name: enedis-client
Version: 1.0.0
Summary: Client MyElectricalData pour recuperer la courbe de charge Enedis (Linky)
Author-email: Jordan Roimarmier <jordan.roimarmier@outlook.com>
License-Expression: Apache-2.0
Keywords: enedis,linky,myelectricaldata,energie,home-assistant
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Natural Language :: French
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

Récupère la **courbe de charge** de ton compteur Linky via la passerelle
[MyElectricalData](https://www.myelectricaldata.fr), et l'exporte en JSON et en CSV.

Première brique d'une future intégration Home Assistant : toute la logique
d'accès aux données vit ici, pour être réutilisée telle quelle ensuite.

## Démarrage

```bash
make setup      # venv + dépendances
make demo       # essaie tout, sans token ni réseau
```

`make demo` fait tourner la chaîne complète — découpage en fenêtres, parsing,
agrégation, export — sur des données simulées. C'est le moyen le plus rapide de
voir ce que produit l'outil sans toucher à ton quota Enedis.

Pour de vraies données :

```bash
cp .env.example .env    # puis renseigne ENEDIS_PDL et ENEDIS_TOKEN
make check              # vérifie le consentement et le quota restant
make daily              # consommation quotidienne en kWh -> dumps/
make fetch              # courbe de charge (nécessite la collecte horaire)
```

**Commence par `make daily`.** La consommation quotidienne ne dépend que du
consentement et fonctionne immédiatement. La courbe de charge, elle, exige en
plus que la **collecte horaire** soit activée chez Enedis — voir *En cas de
problème*.

## Commandes

| Commande | Effet |
|---|---|
| `enedis check` | Valide le `.env`, l'état du consentement Enedis et le quota d'appels |
| `enedis daily --days 30` | Consommation **quotidienne** en kWh — marche sans la collecte horaire |
| `enedis fetch --days 30` | **Courbe de charge** au pas du compteur |
| `enedis fetch --start 2026-01-01 --end 2026-02-01` | Une période précise (`--end` est **exclue**) |
| `enedis demo --days 20` | La courbe de charge sur des données simulées |
| `enedis cache-clear` | Efface les données que la passerelle a mises en cache |

Options communes : `--format json|csv|both`, `--out <dossier>`, `--no-cache`,
`-y/--yes`, `-v` pour le détail, `--env-file` pour un `.env` alternatif.

`--end` est **exclue** : `--end 2026-02-01` s'arrête au 31 janvier inclus. Les
périodes impossibles (inversées, futures, au-delà de 24 mois) sont refusées avec
un message et le code de sortie `2`, avant le moindre appel réseau.

Le binaire s'appelle `enedis` une fois le projet installé ; `python -m enedis_client`
fonctionne aussi.

## Le cache stocke tes données chez un tiers

Les endpoints en `/cache` font stocker tes données **chiffrées, pendant 30 jours,
sur les serveurs de MyElectricalData**. C'est un compromis : le cache épargne le
quota Enedis, qui est serré.

Le cache est **actif par défaut**, et chaque commande le rappelle à l'écran.
`--no-cache` va chercher la donnée directement chez Enedis sans rien y laisser,
au prix d'un appel de quota. `enedis cache-clear` efface ce qui y est déjà.

## Ce que couvre le client

| Endpoint | Méthode | Donnée |
|---|---|---|
| `consumption_load_curve` | `consumption_load_curve()` | Puissance moyenne en W, au pas du compteur |
| `production_load_curve` | `production_load_curve()` | Idem, en production (photovoltaïque) |
| `daily_consumption` | `daily_consumption()` | Énergie en Wh, par journée |
| `daily_production` | `daily_production()` | Idem, en production |
| `contracts` | `contracts()` | Puissance souscrite, option tarifaire |
| `valid_access` | `valid_access()` | Consentement, quota, réinitialisation |
| `*/cache` (DELETE) | `clear_cache()` | Purge du cache de la passerelle |

`identity`, `contact` et `addresses` existent aussi côté passerelle mais ne sont
pas exposés : ce sont des données personnelles sans rapport avec la consommation.

## En bibliothèque

```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:
    # Quotidien : une énergie en Wh par journée.
    series = fetch_daily(client, today - timedelta(days=30), today)
    print(f"{series.total_energy_kwh:.1f} kWh sur {len(series)} jours")

    # Courbe de charge : une puissance moyenne en W par intervalle.
    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")
```

`DailySeries` et `LoadCurve` sont **deux types distincts, volontairement** : le
quotidien porte des Wh d'énergie, la courbe de charge des W de puissance. Passer
l'un au parseur de l'autre lève une `ParseError` explicite plutôt que de produire
des chiffres faux d'un facteur deux.

## Ce que le format Enedis a de piégeux

Trois détails cassent silencieusement les calculs si on les rate. Ils sont
traités dans [models.py](src/enedis_client/models.py) et couverts par des tests.

- **`date` est la fin de l'intervalle**, pas son début. `IntervalReading` expose
  les deux (`start` et `end`).
- **`value` est une puissance moyenne en watts**, pas une énergie. `energy_wh`
  fait la conversion avec la vraie durée de l'intervalle.
- **Le pas de mesure n'est pas toujours 30 minutes** : Enedis livre du `PT10M`,
  `PT30M` ou `PT60M` selon le compteur. Il est lu dans chaque mesure.

À quoi s'ajoute le **changement d'heure** : en octobre, l'heure 02:00–03:00 est
vécue deux fois et Enedis renvoie deux fois les mêmes horodatages. Ils sont
désambiguïsés par l'ordre d'arrivée. Pour comparer ou soustraire deux mesures,
utilise `end_utc` — entre deux `datetime` qui partagent le même fuseau, Python
soustrait les valeurs naïves et ignore le décalage.

## Limites de l'API

- **Courbe de charge : 7 jours maximum par appel, 24 mois d'historique.**
- **Quotidien : 1095 jours par appel, 36 mois d'historique.** Enedis le dit
  lui-même — *« Il est possible de recevoir au maximun 1095j de données
  consécutives »*.
- Rien pour la journée en cours : les bornes sont ramenées automatiquement dans
  la fenêtre servable, et `fetch`/`daily` découpent et recollent les périodes
  plus longues.
- **Une plage longue est annoncée avant d'être lancée.** Le nombre d'appels
  s'affiche en tête, et au-delà de 20 la commande demande confirmation — deux ans
  d'historique, c'est 105 appels pour un quota de l'ordre de 50 par jour. `--yes`
  passe outre.
- **Deux quotas, pas un.** `enedis check` affiche le compteur de la passerelle
  (`appels` / `quota`), mais Enedis applique en plus son propre *throttling*, qui
  peut refuser un appel alors que ce compteur est à zéro. Dans ce cas la réponse
  indique l'heure de réouverture, et `enedis fetch` la relaie telle quelle. Ce
  refus n'est jamais réessayé : l'accès ne rouvre pas avant plusieurs heures.
- Le cache de la passerelle est utilisé par défaut ; `--no-cache` force un vrai
  appel chez Enedis.
- La collecte de la courbe de charge doit être **activée chez Enedis**, et se
  réactive chaque année à la date anniversaire. Sans ça, les réponses sont vides.

## En cas de problème

| Message | Cause | Quoi faire |
|---|---|---|
| `aucune mesure entre … la collecte horaire doit etre activee` | La passerelle répond `404 no measure found`. Le consentement peut être valide sans que la **collecte horaire** le soit : c'est une option distincte, à activer sur le compte Enedis (*Je souhaite accéder à mes données horaires*). | L'activer, puis attendre : elle passe par *En cours d'activation* et le premier relevé arrive sous quelques jours. Rien n'est rétroactif. En attendant, `enedis daily` fonctionne. |
| `quota d'appels Enedis atteint … Acces rouvert le …` | Throttling Enedis, indépendant du compteur affiché par `enedis check`. | Attendre l'heure indiquée. Le cache de la passerelle évite d'y revenir. |
| `token refuse par la passerelle` (403 *Le point de livraison ne correspond au token fourni*) | `ENEDIS_TOKEN` invalide, ou lié à un autre PDL. Refaire un consentement **invalide le token précédent**. | Reprendre le token affiché sur myelectricaldata.fr et le remettre dans `.env`. |
| `consentement Enedis absent ou expire` | Le consentement a une date de fin. | Le renouveler sur myelectricaldata.fr. |

`enedis check` répond aux trois derniers cas sans consommer de quota Enedis.

## Intégration Home Assistant

Le dossier [custom_components/enedis_med/](custom_components/enedis_med/) contient
une intégration installable via HACS en **dépôt personnalisé** (catégorie
*Integration*), puis ajoutée depuis *Paramètres → Appareils et services*.

La configuration se fait entièrement par l'interface : point de livraison et
token. Le couple est validé auprès de la passerelle **avant** que l'entrée soit
créée, donc une erreur de saisie se voit tout de suite.

**La consommation apparaît dans le dashboard Energy** sous *Consommation du
réseau*, au nom `Enedis <pdl> consommation`. Ce n'est pas une entité mais une
**statistique externe** : c'est la seule façon d'injecter de l'historique passé,
ce qu'un capteur classique ne permet pas.

### Ce qu'il faut savoir

- **Le token est stocké en clair** dans `.storage/core.config_entries`. C'est le
  fonctionnement normal de Home Assistant pour toutes les intégrations.
- **Refaire un consentement sur myelectricaldata.fr invalide le token
  précédent.** Home Assistant le détecte et ouvre un formulaire de
  ré-authentification qui ne redemande que le token.
- **Le rattrapage est étalé.** Un appel couvre 7 jours et le quota est de 50
  appels par jour : après une longue coupure, le dashboard se remplit sur
  plusieurs cycles plutôt que d'un bloc. Le cycle est de 12 heures.
- **Les mesures impossibles sont écartées.** Tout intervalle dépassant la
  puissance souscrite lue dans ton contrat est ignoré et journalisé. Sur des
  données réelles, un point à 20 822 W pour 9 kVA souscrits ramenait l'erreur
  totale de 5,75 % à 1,31 % face aux index quotidiens.

## Développement

```bash
make test    # aucun appel réseau : respx simule la passerelle
make lint    # style ruff
```

L'environnement exige **Python 3.14**, imposé par
`pytest-homeassistant-custom-component`. `make setup` l'installe via `uv`.

Pour figer le format exact renvoyé par ton propre compteur :

```bash
python scripts/capture_fixture.py --day 2026-09-01
```

Un seul appel, sur une seule journée ; le PDL est remplacé et les puissances
bruitées, donc la fixture produite est commitable.

## Structure

| Fichier | Rôle |
|---|---|
| [config.py](src/enedis_client/config.py) | Lecture et validation du `.env`, masquage du token |
| [client.py](src/enedis_client/client.py) | Appels HTTP, retries, traduction des erreurs |
| [models.py](src/enedis_client/models.py) | Parsing, fuseau horaire, énergie ; `LoadCurve` et `DailySeries` |
| [windows.py](src/enedis_client/windows.py) | Découpage en fenêtres (7 jours horaire, 1095 quotidien) |
| [loadcurve.py](src/enedis_client/loadcurve.py) | Enchaînement des fenêtres et recollement |
| [export.py](src/enedis_client/export.py) | Écriture JSON et CSV |
| [demo.py](src/enedis_client/demo.py) | Fausse passerelle pour le mode démo |
| [errors.py](src/enedis_client/errors.py) | Une exception par cause traitable |
| [cli.py](src/enedis_client/cli.py) | Commandes `check`, `fetch`, `demo` |
| [demo.py](src/enedis_client/demo.py) | Fausse passerelle pour le mode démo |
