Page 1 sur 1

Enregistrer les consommations Tempo dans Domoticz

Publié : 27 nov. 2025, 13:23
par Michel13
Bonjour à tous,

On m'a installé récemment un compteur linky et depuis, mon compteur classique connecté en aval pour mesurer la consommation de mon chauffage ne reçoit plus les infos Tempo. :twisted:

Je voudrais intégrer dans Domoticz la mesure de puissance en ajoutant un module type Shelly EM ou autre (je n'ai pas d'a priori). Ce type de module ne mesure qu'une seule puissance pour un circuit donné et il faudrait donc pouvoir diriger la mesure vers 6 compteurs comptabilisant les 6 périodes tarifaires du contrat Tempo.

Je sais récupérer dans Domoticz les données jour de Tempo (et celles du lendemain). Pas de problème de ce côté là.

Ma question est de savoir s'il est possible d'incrémenter des compteurs différents à partir d'une même source de mesure en fonction de la valeur Tempo active.

Module de mesure de puissance vers compteur BleuHP, ou BleuHC, ou BlancHP, ou BlancHC, ou RougeHP, ou RougeHC selon le tarif en vigueur.

Est-ce que c'est réalisable (au moyen d'un script) ? Quel module de puissance compatible Domoticz et/ou MQTT suffisamment précis pour ce type de comptage me conseillez-vous pour réaliser ce que je souhaite ? Y aurait-il un tuto sur le forum qui traite ce genre de besoin ?

D'avance merci

Michel

Re: Enregistrer les consommations Tempo dans Domoticz

Publié : 27 nov. 2025, 19:11
par Keros
C'est faisable avec un script. Tu gères le widget à remplir avec les conditions sur l'heure et la couleur du jour.

Concernant la précision, si l'idée est de pouvoir comparer ton relevé avec celui d'ENEDIS au watt prêt, tu en sauras loin.
Même moi avec une petite carte téléinfo qui récupère l'info directement sur le Linky, je sais que je ne suis pas juste. Je pense que cela permet d'avoir un ordre d'idée.

Tu n'as pas la possibilité de te brancher en direct sur ton Linky ? Il y a des modules filaires et ZigBee.

Re: Enregistrer les consommations Tempo dans Domoticz

Publié : 29 nov. 2025, 00:01
par Michel13
Merci Keros.

Non, je n'ai pas la possibilité de me connecter au linky : il est au début du chemin, à 80m de mon habitation et je ne peux pas tirer un câble sans que ça me coûte une fortune (tranchée + matériel).

Je ne devrais pas avoir de problème avec le script mais je suis un peu dubitatif devant les compteurs dans Domoticz. Ils ne fonctionnent pas de manière simple. Si tu as un exemple à me donner je suis preneur.

La précision n'est pas forcément ce que je recherche car même si la mesure est précise, les compteurs Domoticz calment vite les prétentions dans ce domaine...

Penses-tu que le Shelly EM est un bon choix ? Aurais-tu un autre modèle compatible Domoticz à me proposer ?

Re: Enregistrer les consommations Tempo dans Domoticz

Publié : 29 nov. 2025, 07:57
par Keros
40 ou 50m j'aurais dit que ça pouvait se tenter en ayant un router à l'extérieur. 80m, cela fait beaucoup, surtout si le champ n'est pas libre.

Je t'invite à regarder mon petit guide concernant le choix de matériel : la compatibilité d'un matériel ne dépend pas seulement de DomoticZ (le soft). Ça dépend de ton "DomoticZ" (ta domotique).
Deux exemples qui n'ont rien à voir avec la conso électrique :
  • On te propose un objet qui se branche et usb mais tu n'en n'as plus de dispo ou ton système tourne dans une machine virtuelle.
  • On te propose du ZigBee mais tu es équipé en Zwave. Obligé d'ajouter un coordinateur, un nouveau plugin, etc ... Tout ça pour un objet...
Je connais Shelly de nom, mais je n'en n'ai pas. Je ne peux pas te faire un retour d'expérience dessus. À toi de regarder sur le forum si on en a déjà discuté, s'il y a beaucoup de problèmes. Honnêtement, je ne vois pas beaucoup de discussions passées à ce sujet : est-ce que c'est super simple et que cela fonctionne du premier coup ? Est-ce que parce que ça ne fonctionne pas du tout ? Est-ce que c'est parce que personne ne les utilise ?
Le matos que j'utilise est dans ma présentation (lien dans ma signature). Je peux te parler de ce que j'ai.

Pour le script, je n'ai pas d'exemple. Et puis je ne vais pas te donner un exemple en DzVents si tu n'as que des scripts en LUA ou Python ou Bash. Cela dépend de toi et de ton DomoticZ. Il doit y avoir des exemples sur le forum.

Si tu es en panne avec un script, là oui, on peut t'assister. Il faut nous partager le script complet (avoir seulement la ligne qui est en erreur n'est souvent pas suffisant) et nous donner les logs de l'exécution avec des print pour connaître les valeurs des variables, etc ...

Re: Enregistrer les consommations Tempo dans Domoticz

Publié : 30 nov. 2025, 10:38
par Michel13
Merci pour cette réponse très détaillée.

C'est vrai que sans un minimum d'explications sur mon environnement, il n'est pas facile d'apporter une aide adaptée.

Mon Domoticz Version 2025.2 tourne sur un RPI4 8Go et SSD 64Go qui héberge aussi le brooker MQTT.
J'utilise plusieurs cartes E/S 4 à 16 canaux de chez Dingtian relay pour mes contrôles déportés.
Pas de zigbee mais du Z-wave qui me sert pour contrôler quelques capteurs et des prises commandées.

Concernant mon besoin spécifique, l'emplacement du capteur de puissance n'est pas dans le périmètre Z-wave. Connexion possible uniquement en Ethernet ou wifi (j'ai une borne à proximité). D'où le Shelly qui fonctionne en wifi. J'ai trouvé quelques sujets sur le forum anglais qui parlent des capteurs Shelly. Les avis sont assez partagés mais il semble qu'ils soient utilisables dans Domoticz. https://github.com/mario-peters/ShellyC ... /README.md

Côté scripts, je n'ai pas de préférence particulière. Je choisi celui qui est le mieux adapté à ce que je recherche, ou je conserve le langage de celui que j'ai trouvé sur la toile pour un besoin particulier.
J'en ai une dizaine en LUA, DzVents, python, et même en blocky pour des fonctions simples.

J'ai vu qu'il y avait pas mal de sujets sur le forum qui traitent des enregistrements heures pleines, heures creuses. Je vais m'en inspirer pour mon projet.

Je reviendrai ici lorsque j'aurai un peu plus avancé dans mes recherches.

Re: Enregistrer les consommations Tempo dans Domoticz

Publié : 16 déc. 2025, 11:52
par Michel13
Bonjour,

Ayant pu profiter d'un prix canon sur le shelly em directement sur le site du constructeur pendant le black friday (moins de 35€ avec deux pinces 50A, fdp inclus), je me suis lancé dans mon projet.

Pour commencer, je ne suis pas du tout un expert en programmation et encore moins en python.

Après m'être fait baladé par chatgpt pendant des jours, je me suis tourné vers claude.ai et c'est un autre monde. Beaucoup plus professionnel et pertinent que chatgpt qui perdait la boule au fur et à mesure que j'avançais dans le projet. J'ai découvert claude.ai sur ce projet et c'est franchement au top.

La connexion du shelly n'est pas directe car il faut d'abord passer par l'application Shelly Smart Control pour paramétrer le wifi.
Une fois le shelly connecté au wifi, on peut s'y connecter via le navigateur (http:\\<IP du shelly>).
Là on met à jour le firmware si nécessaire, on modifie le type de pince (50 ou 120A) et on associe le shelly avec le brooker mqtt (Internet & security, Advanced - Developer settings). Le mien est installé sur le RPI sur lequel tourne domoticz.

Voici ce que l'on trouve dans mqtt Explorer :
shellyem-<MAC adress du shelly>
relay
emeter
0 (numéro du canal)
power = puissance instantanée
reactive_power = puissance réactive
pf = power factor
voltage = tension
total = total en Wh direct
total_returned = total en Wh retour
energy = Watt par minute
returned_energy = Watt par minute retour
1
power = puissance instantanée
reactive_power = puissance réactive
pf = power factor
voltage = tension
total = total en Wh direct
total_returned = total en Wh retour
energy = Watt par minute
returned_energy = Watt par minute retour

Les returned sont les puissances négatives, utiles lorsqu'on a de l'auto-production. Ce n'est pas mon cas.

Le script ci-dessous utilise 3 données du shelly : power, total et energy. Cette dernière valeur est mise à jour toutes les minutes par le shelly lui même. Elle est utilisée dans le script pour alimenter les compteurs Tempo. Elle est plus ou moins précise à cause des arrondis inévitables, aussi le script réalise des corrections en fin de chaque cycle tempo pour réajuster la valeur du compteur à la consommation supposée réelle calculée à partir de la dernière valeur "total" enregistrée en mémoire au dernier changement de "energy", avec un fallback si la valeur enregistrée n'est pas dispo.

Note à propos de la donnée energy : Ce sont des Watt par minute. Cette valeur est mise à jour 60 fois par heure. Il faut donc la diviser par 60 pour avoir la donnée correcte à envoyer vers le compteur incrémental à chaque mise à jour dans mqtt.

Le script final gère les deux canaux du shelly.

Les données Tempo de référence (HC/HP et couleur) sont issues d'un fichier .json qui est créé et mis à jour à partir d'un script qui n'est pas traité ici. Pour ma part c'est le script qui gère mon chauffage et mon chauffe-eau qui génère le json, les données Tempo étant quand à elles récupérées sur le site RTE via une API. Son format est expliqué dans le fichier d'installation.

A savoir : si la couleur Tempo n'est pas dispo au moment du basculement HC/HP, les valeurs energy/60 sont cumulées dans le json en attente de recevoir la donnée jour. Le script incrémente alors le compteur actif et reprend son déroulement normal.

Installation complète et détaillée (donnée par claude.ai à ma demande) :
# Guide d'installation - Shelly EM1 (Script unifié 2 canaux)

**Version :** 2.0
**Date :** 2025-12-24
**Auteur :** Projet Shelly EM Tempo

---

## 📋 Table des matières

1. [Présentation](#présentation)
2. [Prérequis](#prérequis)
3. [Architecture du système](#architecture-du-système)
4. [Configuration Domoticz](#configuration-domoticz)
5. [Installation](#installation)
6. [Fonctionnement détaillé](#fonctionnement-détaillé)
7. [Logs et dépannage](#logs-et-dépannage)
8. [Changelog](#changelog)

---

## Présentation

Ce script unifié gère **les 2 canaux d'un Shelly EM** avec compteurs Tempo (Bleu/Blanc/Rouge HP/HC).

**Nouveautés v2.0 :**
- ✅ **Script unique** pour les 2 canaux (vs 2 scripts séparés)
- ✅ **1 seul JSON state** pour tout
- ✅ **1 seul service systemd**
- ✅ **Logs préfixés** `[Canal X]` pour clarté
- ✅ **Statistiques globales**
- ✅ Organisation améliorée (dossiers `/json/` et `/logs/`)

**Fonctionnalités :**
- Correction automatique des dérives
- Synchronisation `total_sync` avec `energy`
- Accumulation si "Pas d'info"
- Vérification fraîcheur JSON (HC: 8h15, HP: 16h15)
- Reset CounterToday à 00h01
- Protection contre double comptage
- Retry lecture JSON (fichier en cours d'écriture)

---

## Prérequis

### Matériel
- **Shelly EM** configuré en MQTT
- **Raspberry Pi** avec Domoticz
- **Broker MQTT** (Mosquitto)

### Logiciels
```bash
# Mise à jour du système
sudo apt-get update

# Bibliothèques système pour systemd
sudo apt-get install libsystemd-dev pkg-config

# Python 3 + modules
sudo apt-get install python3 python3-pip

# Modules Python requis
pip3 install paho-mqtt --break-system-packages
pip3 install requests --break-system-packages
pip3 install systemd-python --break-system-packages

# Vérification
python3 -c "import paho.mqtt.client as mqtt; print('✓ paho-mqtt OK')"
python3 -c "import requests; print('✓ requests OK')"
python3 -c "from systemd.daemon import notify; print('✓ systemd-python OK')"
```

**Note :** `requests` est généralement déjà installé sur Raspberry Pi OS.

### Réseau
- Shelly EM accessible via MQTT
- Domoticz accessible en HTTP

---

## Architecture du système

### Structure des fichiers

```
/home/pi/domoticz/scripts/
├── python/
│ └── shelly_em1.py # Script unifié
├── json/
│ ├── tempo_actif.json # Partagé (lecture seule, écrit par autre script)
│ └── shelly_em1_state.json # État persistant du script
└── logs/
└── shelly_em1.log # Logs rotatifs (30 jours)

/etc/systemd/system/
└── shelly-em1.service # Service systemd
```

### Flux de données

```
Shelly EM (MQTT)
├─ Canal 0 → emeter/0/power|total|energy
└─ Canal 1 → emeter/1/power|total|energy

shelly_em1.py (script unifié)

┌────────────────────────┬────────────────────────┐
│ Canal 0 │ Canal 1 │
├────────────────────────┼────────────────────────┤
│ IDX_CANAL0_TOTAL │ IDX_CANAL1_TOTAL │
│ IDX_CANAL0_INSTANT │ IDX_CANAL1_INSTANT │
│ IDX_C0_xxx_HP/HC │ IDX_C1_xxx_HP/HC │
│ (Tempo HP/HC) │ (Tempo HP/HC) │
└────────────────────────┴────────────────────────┘

Domoticz
```

---

## Configuration Domoticz

### Compteurs Canal 0

| Type | Nom | IDX | Usage |
|------|-----|-----|-------|
| Counter Incremental | Shelly EM Canal 0 - Total | IDX_CANAL0_TOTAL | Total cumulé (kWh) |
| Electric (Instant+Counter) | Shelly EM Canal 0 - Instantané | IDX_CANAL0_INSTANT | Puissance (W) |
| Counter Incremental | Tempo Bleu HP Canal 0 | IDX_C0_BLEU_HP | Heures Pleines Bleu |
| Counter Incremental | Tempo Bleu HC Canal 0 | IDX_C0_BLEU_HC | Heures Creuses Bleu |
| Counter Incremental | Tempo Blanc HP Canal 0 | IDX_C0_BLANC_HP | Heures Pleines Blanc |
| Counter Incremental | Tempo Blanc HC Canal 0 | IDX_C0_BLANC_HC | Heures Creuses Blanc |
| Counter Incremental | Tempo Rouge HP Canal 0 | IDX_C0_ROUGE_HP | Heures Pleines Rouge |
| Counter Incremental | Tempo Rouge HC Canal 0 | IDX_C0_ROUGE_HC | Heures Creuses Rouge |

### Compteurs Canal 1

| Type | Nom | IDX | Usage |
|------|-----|-----|-------|
| Counter Incremental | Shelly EM Canal 1 - Total | IDX_CANAL1_TOTAL | Total cumulé (kWh) |
| Electric (Instant+Counter) | Shelly EM Canal 1 - Instantané | IDX_CANAL1_INSTANT | Puissance (W) |
| Counter Incremental | Tempo Bleu HP Canal 1 | IDX_C1_BLEU_HP | Heures Pleines Bleu |
| Counter Incremental | Tempo Bleu HC Canal 1 | IDX_C1_BLEU_HC | Heures Creuses Bleu |
| Counter Incremental | Tempo Blanc HP Canal 1 | IDX_C1_BLANC_HP | Heures Pleines Blanc |
| Counter Incremental | Tempo Blanc HC Canal 1 | IDX_C1_BLANC_HC | Heures Creuses Blanc |
| Counter Incremental | Tempo Rouge HP Canal 1 | IDX_C1_ROUGE_HP | Heures Pleines Rouge |
| Counter Incremental | Tempo Rouge HC Canal 1 | IDX_C1_ROUGE_HC | Heures Creuses Rouge |

**IMPORTANT :**
- Tous les compteurs **Tempo** doivent être de type **Counter Incremental** avec **Energy Read** = `From Device` et **Meter Offset** = `0`.
- Les compteurs **Instantanés** (IDX_CANAL0_INSTANT et IDX_CANAL1_INSTANT) doivent être de type **Electric (Instant+Counter)** avec **Computed** activé pour recevoir la puissance instantanée.

---

## Installation

### 1. Créer l'arborescence

```bash
# Créer les dossiers
sudo mkdir -p /home/pi/domoticz/scripts/python
sudo mkdir -p /home/pi/domoticz/scripts/json
sudo mkdir -p /home/pi/domoticz/scripts/logs
sudo chown -R pi:pi /home/pi/domoticz/scripts
```

### 2. Copier le script

```bash
# Copier le script (adapter selon votre source)
sudo cp shelly_em1.py /home/pi/domoticz/scripts/python/
sudo chmod +x /home/pi/domoticz/scripts/python/shelly_em1.py
sudo chown pi:pi /home/pi/domoticz/scripts/python/shelly_em1.py
```

### 3. Configurer le script

Éditer `/home/pi/domoticz/scripts/python/shelly_em1.py` :

```python
# Broker MQTT
MQTT_BROKER = "MQTT_BROKER_IP" # Votre broker
MQTT_PORT = 1883
SHELLY_ID = "SHELLY_ID" # Votre Shelly ID

# Domoticz
DOMOTICZ_URL = "http://DOMOTICZ_IP:8080"
DOMOTICZ_USER = "DOMOTICZ_USER"
DOMOTICZ_PASS = "DOMOTICZ_PASS"

# IDX des compteurs (selon votre config)
CANAUX = {
0: {
"IDX_SIMPLE": IDX_CANAL0_TOTAL,
"IDX_COMPUTED": IDX_CANAL0_INSTANT,
"IDX_TEMPO": {
"Bleu_HP": IDX_C0_BLEU_HP,
"Bleu_HC": IDX_C0_BLEU_HC,
# ... etc
}
},
1: {
"IDX_SIMPLE": IDX_CANAL1_TOTAL,
"IDX_COMPUTED": IDX_CANAL1_INSTANT,
"IDX_TEMPO": {
"Bleu_HP": IDX_C1_BLEU_HP,
"Bleu_HC": IDX_C1_BLEU_HC,
# ... etc
}
}
}
```

### 4. Vérifier tempo_actif.json

```bash
# Le fichier doit être dans /json/
ls -l /home/pi/domoticz/scripts/json/tempo_actif.json

# Format attendu
cat /home/pi/domoticz/scripts/json/tempo_actif.json
```

```json
{
"HC": "On",
"CouleurJour": "Bleu",
"Timestamp": "2025-12-24 08:00:00"
}
```

### 5. Installer le service systemd

```bash
# Copier le fichier service
sudo cp shelly-em1.service /etc/systemd/system/

# Recharger systemd
sudo systemctl daemon-reload

# Activer au démarrage
sudo systemctl enable shelly-em1.service

# Démarrer
sudo systemctl start shelly-em1.service
```

### 6. Vérifier le fonctionnement

```bash
# Statut
sudo systemctl status shelly-em1.service

# Logs en temps réel
sudo journalctl -u shelly-em1.service -f

# Fichier log
tail -f /home/pi/domoticz/scripts/logs/shelly_em1.log
```

---

## Fonctionnement détaillé

### Unités

**Shelly EM :**
- `power` : W (instantané)
- `energy` : W (moyenne sur 1 minute, envoyé toutes les 60s)
- `total` : Wh (cumulatif depuis la mise en service)

**Calculs internes :**
- Tout en **Wh** (pas de conversion kWh ↔ Wh)
- `energy_wh = energy / 60` (W → Wh)
- `total_sync = total` (synchronisé avec `energy`)

**Envoi Domoticz :**
- Counter Incremental attend des **Wh**
- Domoticz convertit automatiquement Wh → kWh pour l'affichage

### Synchronisation total_sync

À chaque réception d'`energy` (60s) :

```python
total_sync = total # Garantit cohérence temporelle
```

**Pourquoi ?** `total` arrive en continu, `energy` toutes les 60s. `total_sync` capture la valeur de `total` **au moment exact** où `energy` arrive.

### Correction automatique

**Déclenchement :** Changement HC ↔ HP

**Calcul (par canal) :**
```python
delta_total = total_fin - total_debut # Consommation réelle Shelly
delta_tempo = tempo_fin - tempo_debut # Somme des incréments Domoticz
correction = delta_total - delta_tempo # Écart à corriger

if abs(correction) > 1.0: # Seuil 1 Wh
send_domoticz(old_idx, 0, correction)
```

**Protection :**
- Durée max HC : 8h15
- Durée max HP : 16h15
- HP : vérification même jour
- Seuil minimal : 1 Wh

**Logs :**
```
[Canal 0] 🔄 Changement détecté: On → Off
[Canal 0] 🔧 CORRECTION IDX_C0_BLEU_HC: Δtotal=157.8 Wh, Δtempo=132.0 Wh, correction=+25.8 Wh
[Canal 0] ✓ Correction appliquée
```

### Gestion "Pas d'info"

Si `CouleurJour = "Pas d'info"` ou JSON trop ancien (HC > 8h15, HP > 16h15) :

**Accumulation automatique :**
```python
pending_energy["enabled"] = True
pending_energy["accumulated"] += energy_wh
pending_energy["count"] += 1
```

**Envoi dès rétablissement :**
```
[Canal 0] ✅ Couleur rétablie! Envoi valeurs accumulées: 58.7667 Wh (2 mesures)
[Canal 0] ✓ Valeurs accumulées envoyées IDX_C1_BLANC_HP
```

### Reset CounterToday (00h01)

**Problème :** Compteurs inactifs affichent `-0.001 kWh` (dérive cosmétique)

**Solution :** Compensation à 00h01
```python
if counter_today_kwh != 0:
compensation_wh = -counter_today_kwh * 1000 # Inverser
send_domoticz(idx, 0, compensation_wh)
```

**Logs :**
```
[Canal 0] 🔄 00h01 - Début reset CounterToday
[Canal 0] └─ Reset Rouge HP (IDX=IDX_C0_ROUGE_HP): -0.001 kWh → +1.0 Wh
```

### Protection double comptage

**Au cycle de changement HC/HP :**
- Correction appliquée à l'ancien compteur
- `energy_wh` **non envoyé** au nouveau compteur (déjà inclus dans `delta_total`)

```python
if correction_applied:
logger.debug("Pas d'envoi energy (inclus correction)")
```

### Retry lecture JSON

Protection si `tempo_actif.json` en cours d'écriture :

```python
for attempt in range(3):
try:
data = json.load(f)
break # Succès
except:
sleep(0.1) # Retry
```

---

## Logs et dépannage

### Exemples de logs

**Démarrage :**
```
2025-12-24 10:00:00 - INFO - ==================================================
2025-12-24 10:00:00 - INFO - Démarrage du script Shelly EM - 2 canaux
2025-12-24 10:00:00 - INFO - Broker MQTT: MQTT_BROKER_IP:1883
2025-12-24 10:00:00 - INFO - Shelly ID: SHELLY_ID
2025-12-24 10:00:00 - INFO - Canaux configurés: [0, 1]
2025-12-24 10:00:00 - INFO - ✓ Connecté au broker MQTT
2025-12-24 10:00:00 - INFO - ✓ Souscription canal 0: shellies/SHELLY_ID/emeter/0/#
2025-12-24 10:00:00 - INFO - ✓ Souscription canal 1: shellies/SHELLY_ID/emeter/1/#
```

**Fonctionnement normal :**
```
[Canal 0] 📍 Nouveau cycle: IDX_C0_BLEU_HC, Total_sync=88626.8 Wh
[Canal 1] 📍 Nouveau cycle: IDX_C1_BLEU_HC, Total_sync=125430.2 Wh
```

**Changement HC → HP :**
```
[Canal 0] 🔄 Changement détecté: On → Off
[Canal 0] 📊 Stats période: Uptime=29543s, MQTT=15462, MAJ=6185, Erreurs=0
[Canal 0] 🔧 CORRECTION IDX_C0_BLEU_HC: Δtotal=157.8 Wh, Δtempo=132.0 Wh, correction=+25.8 Wh
[Canal 0] ✓ Correction appliquée IDX_C0_BLEU_HC
[Canal 0] 📍 Nouveau cycle: IDX_C0_BLEU_HP, Total_sync=88784.6 Wh
[Canal 0] ✓ Protection correction: reprise normale
```

**Statistiques :**

Les statistiques sont affichées **au changement de période HC/HP** (pas toutes les heures) :
```
[Canal 0] 📊 Stats période: Uptime=29543s, MQTT=15462, MAJ=6185, Erreurs=0
```

### Commandes utiles

```bash
# Statut service
sudo systemctl status shelly-em1.service

# Redémarrer
sudo systemctl restart shelly-em1.service

# Logs temps réel
sudo journalctl -u shelly-em1.service -f

# Logs fichier
tail -f /home/pi/domoticz/scripts/logs/shelly_em1.log

# Voir état JSON
cat /home/pi/domoticz/scripts/json/shelly_em1_state.json | python3 -m json.tool

# Tester MQTT
mosquitto_sub -h MQTT_IP -t "shellies/SHELLY_ID/#" -v
```

### Problèmes courants

**Le service ne démarre pas :**
```bash
sudo journalctl -u shelly-em1.service -n 50
# Vérifier les erreurs Python
```

**Pas de logs :**
```bash
ls -la /home/pi/domoticz/scripts/logs/
sudo chown pi:pi /home/pi/domoticz/scripts/logs/
```

**JSON introuvable :**
```bash
ls -la /home/pi/domoticz/scripts/json/
# Vérifier que tempo_actif.json existe
```

**MQTT ne se connecte pas :**
```bash
# Tester le broker
mosquitto_sub -h MQTT_IP -t "shellies/#" -v
# Vérifier MQTT_BROKER et MQTT_PORT dans le script
```

**Compteurs ne se mettent pas à jour :**
```bash
# Vérifier les IDX dans Domoticz
# Vérifier les IDX dans le script
# Vérifier les logs pour les erreurs Domoticz
```
### v2.0 (2025-12-24)

**Support :** `sudo journalctl -u shelly-em1.service -f`

**Documentation complète :** Ce guide
Le script shelly_em01_canal0.py (bon courage c'est long.....) :

Code : Tout sélectionner

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Script unifié Shelly EM - Gestion des 2 canaux avec compteurs Tempo
Version: 2.0
Date: 2025-12-23
"""

import json
import os
import time
import requests
import paho.mqtt.client as mqtt
from datetime import datetime
import logging
from typing import Optional, Dict
from logging.handlers import TimedRotatingFileHandler

# Watchdog systemd
try:
    from systemd.daemon import notify
    SYSTEMD = True
except ImportError:
    SYSTEMD = False

# ---------------- CONFIG ----------------
MQTT_BROKER = "MQTT_BROKER_IP"
MQTT_PORT = MQTT_PORT
SHELLY_ID = "SHELLY_ID"

DOMOTICZ_URL = "http://MQTT_BROKER_IP:8080"
DOMOTICZ_USER = "DOMOTICZ_USER"
DOMOTICZ_PASS = "DOMOTICZ_PASS"

# Configuration des 2 canaux
CANAUX = {
    0: {
        "topic": f"shellies/{SHELLY_ID}/emeter/0",
        "IDX_SIMPLE": IDX_CANAL0_TOTAL,      # Compteur total
        "IDX_COMPUTED": IDX_CANAL0_INSTANT,    # Compteur instantané
        "IDX_TEMPO": {
            "Bleu_HP": IDX_C0_BLEU_HP,
            "Bleu_HC": IDX_C0_BLEU_HC,
            "Blanc_HP": IDX_C0_BLANC_HP,
            "Blanc_HC": IDX_C0_BLANC_HC,
            "Rouge_HP": IDX_C0_ROUGE_HP,
            "Rouge_HC": IDX_C0_ROUGE_HC
        }
    },
    1: {
        "topic": f"shellies/{SHELLY_ID}/emeter/1",
        "IDX_SIMPLE": IDX_CANAL1_TOTAL,      # Compteur total
        "IDX_COMPUTED": IDX_CANAL1_INSTANT,    # Compteur instantané
        "IDX_TEMPO": {
            "Bleu_HP": IDX_C1_BLEU_HP,
            "Bleu_HC": IDX_C1_BLEU_HC,
            "Blanc_HP": IDX_C1_BLANC_HP,
            "Blanc_HC": IDX_C1_BLANC_HC,
            "Rouge_HP": IDX_C1_ROUGE_HP,
            "Rouge_HC": IDX_C1_ROUGE_HC
        }
    }
}

JSON_FILE = "SCRIPTS_PATH/json/shelly_em1_state.json"
TEMPO_ACTIF_FILE = "SCRIPTS_PATH/json/tempo_actif.json"

UPDATE_INTERVAL = 5    # secondes pour power/total
TEMPO_FILE_CACHE_DURATION = 30  # secondes de cache pour tempo_actif.json

# Configuration du logging
LOG_FILE = "SCRIPTS_PATH/logs/shelly_em1.log"
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)

file_handler = TimedRotatingFileHandler(
    LOG_FILE,
    when="midnight",
    interval=30,  # 30 jours
    backupCount=3,
    encoding='utf-8'
)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))

console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
logger.addHandler(file_handler)
logger.addHandler(console_handler)

# ---------------- JSON State ----------------
def load_json() -> Dict:
    """Charge l'état persistant depuis le fichier JSON"""
    default_state = {
        "canaux": {}
    }
    
    # Initialiser chaque canal
    for canal_id in CANAUX.keys():
        default_state["canaux"][canal_id] = {
            "correction": {
                "last_hc_status": None,
                "last_color": None,
                "last_active_idx": None,
                "total_at_activation": 0,
                "tempo_at_activation": 0,
                "activation_timestamp": None
            },
            "pending_energy": {
                "enabled": False,
                "accumulated": 0.0,
                "count": 0,
                "start_timestamp": None
            }
        }
    
    if not os.path.exists(JSON_FILE):
        logger.info(f"Création du fichier d'état: {JSON_FILE}")
        save_json(default_state)
        return default_state
    
    try:
        with open(JSON_FILE, "r") as f:
            state = json.load(f)
            
        # Fusionner avec defaults si structure incomplète
        for canal_id in CANAUX.keys():
            if str(canal_id) not in state.get("canaux", {}):
                state.setdefault("canaux", {})[str(canal_id)] = default_state["canaux"][canal_id]
        
        logger.info(f"✓ Fichier d'état chargé: {JSON_FILE}")
        return state
        
    except Exception as e:
        logger.error(f"Erreur lors de la lecture de {JSON_FILE}: {e}")
        return default_state

def save_json(data: Dict) -> None:
    """Sauvegarde l'état dans le fichier JSON"""
    try:
        with open(JSON_FILE, "w") as f:
            json.dump(data, f, indent=2)
    except Exception as e:
        logger.error(f"Erreur lors de l'écriture de {JSON_FILE}: {e}")

# Cache pour tempo_actif.json
tempo_actif_cache = {
    "data": None,
    "timestamp": 0
}

def load_tempo_actif() -> Optional[Dict]:
    """
    Charge les données depuis tempo_actif.json avec système de cache
    """
    global tempo_actif_cache
    
    now = time.time()
    
    # Vérifier si le cache est encore valide
    if (tempo_actif_cache["data"] is not None and 
        now - tempo_actif_cache["timestamp"] < TEMPO_FILE_CACHE_DURATION):
        logger.debug("Utilisation du cache pour tempo_actif.json")
        return tempo_actif_cache["data"]
    
    # Charger depuis le fichier
    if not os.path.exists(TEMPO_ACTIF_FILE):
        logger.error(f"Fichier {TEMPO_ACTIF_FILE} introuvable")
        return None
    
    # Retry jusqu'à 3 fois (fichier peut être en cours d'écriture)
    max_retries = 3
    for attempt in range(max_retries):
        try:
            with open(TEMPO_ACTIF_FILE, "r") as f:
                data = json.load(f)
            break
        except json.JSONDecodeError as e:
            if attempt < max_retries - 1:
                logger.debug(f"Erreur JSON (tentative {attempt+1}/{max_retries}), retry dans 0.1s...")
                time.sleep(0.1)
            else:
                logger.error(f"Erreur parsing JSON après {max_retries} tentatives: {e}")
                return None
        except Exception as e:
            if attempt < max_retries - 1:
                logger.debug(f"Erreur lecture (tentative {attempt+1}/{max_retries}), retry dans 0.1s...")
                time.sleep(0.1)
            else:
                logger.error(f"Erreur lecture après {max_retries} tentatives: {e}")
                return None
    
    try:
        # Validation des données
        if not isinstance(data, dict):
            logger.error(f"Format invalide: doit être un objet JSON")
            return None
            
        if "HC" not in data or "CouleurJour" not in data:
            logger.error(f"Données manquantes: HC ou CouleurJour absent")
            return None
        
        # Vérifier que la couleur est valide
        valid_colors = ["Bleu", "Blanc", "Rouge", "Pas d'info"]
        if data["CouleurJour"] not in valid_colors:
            logger.error(f"Couleur invalide: {data['CouleurJour']}")
            return None
        
        if data["CouleurJour"] == "Pas d'info":
            logger.warning(f"⚠️  Couleur indisponible, accumulation en attente")
        
        # Vérifier que HC est valide
        valid_hc = ["On", "Off"]
        if data["HC"] not in valid_hc:
            logger.error(f"Statut HC invalide: {data['HC']}")
            return None
        
        # Vérifier la fraîcheur du Timestamp selon HC/HP
        if "Timestamp" in data and data["Timestamp"]:
            try:
                timestamp_str = data["Timestamp"]
                timestamp = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
                now_dt = datetime.now()
                age_hours = (now_dt - timestamp).total_seconds() / 3600
                
                # Durées max identiques à apply_correction
                max_hc_hours = 8.25
                max_hp_hours = 16.25
                
                # Déterminer la durée max selon HC/HP
                hc_status = data.get("HC")
                if hc_status == "On":
                    max_hours = max_hc_hours
                    period_name = "HC"
                else:
                    max_hours = max_hp_hours
                    period_name = "HP"
                
                if age_hours > max_hours:
                    logger.warning(f"⚠️  JSON trop ancien: {age_hours:.1f}h (max {period_name}: {max_hours}h)")
                    logger.warning(f"⚠️  Timestamp: {timestamp_str}, maintenant: {now_dt.strftime('%Y-%m-%d %H:%M:%S')}")
                    data["_stale"] = True
                else:
                    logger.debug(f"Timestamp OK: {age_hours:.1f}h < {max_hours}h ({period_name})")
                    data["_stale"] = False
            except ValueError:
                logger.warning(f"⚠️  Format Timestamp invalide")
                data["_stale"] = True
        else:
            logger.warning(f"⚠️  Timestamp absent")
            data["_stale"] = True
        
        # Mettre à jour le cache
        tempo_actif_cache["data"] = data
        tempo_actif_cache["timestamp"] = now
        
        logger.debug(f"tempo_actif.json: HC={data['HC']}, Couleur={data['CouleurJour']}")
        return data
        
    except Exception as e:
        logger.error(f"Erreur validation: {e}")
        return None

state = load_json()

# ---------------- Récupération Counter Domoticz ----------------
def get_counter_from_domoticz(idx: int) -> float:
    """Récupère Counter (total) depuis Domoticz"""
    try:
        url = f"{DOMOTICZ_URL}/json.htm?type=command&param=getdevices&used=true&displayhidden=1"
        response = requests.get(url, auth=(DOMOTICZ_USER, DOMOTICZ_PASS), timeout=5)
        
        if response.status_code != 200:
            logger.error(f"Erreur HTTP {response.status_code} pour IDX {idx}")
            return 0
        
        data = response.json()
        for device in data.get("result", []):
            if str(device.get("idx")) == str(idx):
                counter_str = device.get("Counter", "0 kWh")
                counter_value = float(counter_str.split()[0])
                logger.debug(f"Counter IDX {idx}: {counter_value} kWh")
                return counter_value
        
        logger.warning(f"IDX {idx} non trouvé")
        return 0
        
    except Exception as e:
        logger.error(f"Erreur récupération Counter IDX {idx}: {e}")
        return 0

def get_counter_today_from_domoticz(idx: int) -> float:
    """Récupère CounterToday depuis Domoticz"""
    try:
        url = f"{DOMOTICZ_URL}/json.htm?type=command&param=getdevices&used=true&displayhidden=1"
        response = requests.get(url, auth=(DOMOTICZ_USER, DOMOTICZ_PASS), timeout=5)
        
        if response.status_code != 200:
            logger.error(f"Erreur HTTP {response.status_code} pour IDX {idx}")
            return 0
        
        data = response.json()
        for device in data.get("result", []):
            if str(device.get("idx")) == str(idx):
                counter_today_str = device.get("CounterToday", "0 kWh")
                counter_today_value = float(counter_today_str.split()[0])
                logger.debug(f"CounterToday IDX {idx}: {counter_today_value} kWh")
                return counter_today_value
        
        logger.warning(f"IDX {idx} non trouvé")
        return 0
        
    except Exception as e:
        logger.error(f"Erreur récupération CounterToday IDX {idx}: {e}")
        return 0

# ---------------- Correction automatique ----------------
def apply_correction(canal_id: int, old_idx: int, total_debut: float, total_fin: float, current_hc_status: str) -> None:
    """
    Applique correction sur le compteur Tempo qui vient de se terminer
    Protection : vérifie durée (HC: 8h15, HP: 16h15) et même jour pour HP
    Valeurs en Wh
    """
    canal_state = state["canaux"][str(canal_id)]
    tempo_debut = canal_state["correction"]["tempo_at_activation"]
    tempo_fin_kwh = get_counter_from_domoticz(old_idx)
    tempo_fin = tempo_fin_kwh * 1000  # kWh → Wh
    
    if tempo_fin_kwh == 0:
        logger.warning(f"[Canal {canal_id}] Impossible récupérer Counter IDX {old_idx}")
        return
    
    if tempo_debut == 0:
        logger.info(f"[Canal {canal_id}] Premier cycle après démarrage IDX {old_idx}")
    
    # Vérifier cohérence temporelle
    activation_timestamp = canal_state["correction"].get("activation_timestamp")
    last_hc_status = canal_state["correction"].get("last_hc_status")
    
    if activation_timestamp:
        try:
            activation_time = datetime.fromisoformat(activation_timestamp)
            current_time = datetime.now()
            elapsed_hours = (current_time - activation_time).total_seconds() / 3600
            
            max_hc_hours = 8.25
            max_hp_hours = 16.25
            
            if last_hc_status == "On":
                max_hours = max_hc_hours
                period_name = "HC"
            else:
                max_hours = max_hp_hours
                period_name = "HP"
                # HP : vérifier même jour
                if activation_time.date() != current_time.date():
                    logger.warning(f"[Canal {canal_id}] Période HP sur jours différents")
                    logger.info(f"[Canal {canal_id}] Cycle ignoré: {elapsed_hours:.1f}h")
                    return
            
            if elapsed_hours <= max_hours:
                logger.debug(f"[Canal {canal_id}] Validation OK: {period_name} {elapsed_hours:.1f}h")
            else:
                logger.warning(f"[Canal {canal_id}] Période {period_name} trop longue: {elapsed_hours:.1f}h")
                logger.info(f"[Canal {canal_id}] Cycle ignoré")
                return
                
        except Exception as e:
            logger.error(f"[Canal {canal_id}] Erreur validation: {e}")
    
    # Calculer correction
    delta_total = total_fin - total_debut
    delta_tempo = tempo_fin - tempo_debut
    correction = delta_total - delta_tempo
    
    # Seuil minimal : 1 Wh
    if abs(correction) > 1.0:
        logger.info(f"[Canal {canal_id}] 🔧 CORRECTION IDX {old_idx}: Δtotal={delta_total:.1f} Wh, Δtempo={delta_tempo:.1f} Wh, correction={correction:+.1f} Wh")
        if send_domoticz(old_idx, 0, f"{correction:.1f}"):
            logger.info(f"[Canal {canal_id}] ✓ Correction appliquée IDX {old_idx}")
            stats["domoticz_updates"] += 1
        else:
            logger.error(f"[Canal {canal_id}] ✗ Échec correction IDX {old_idx}")
            stats["errors"] += 1
    else:
        logger.debug(f"[Canal {canal_id}] Correction négligeable: {correction:+.1f} Wh")

# ---------------- Envoi Domoticz ----------------
def send_domoticz(idx: int, nvalue: int, svalue: str, retry: int = 3) -> bool:
    """Envoie une mise à jour à Domoticz avec retry"""
    url = f"{DOMOTICZ_URL}/json.htm?type=command&param=udevice&idx={idx}&nvalue={nvalue}&svalue={svalue}"
    
    for attempt in range(retry):
        try:
            response = requests.get(url, auth=(DOMOTICZ_USER, DOMOTICZ_PASS), timeout=5)
            if response.status_code == 200:
                logger.debug(f"Domoticz: IDX={idx} svalue={svalue}")
                return True
            else:
                logger.warning(f"Domoticz HTTP {response.status_code} IDX={idx}")
        except requests.exceptions.Timeout:
            logger.warning(f"Timeout ({attempt+1}/{retry}) IDX={idx}")
        except requests.exceptions.ConnectionError:
            logger.warning(f"Connexion error ({attempt+1}/{retry}) IDX={idx}")
        except Exception as e:
            logger.error(f"Erreur Domoticz IDX={idx}: {e}")
        
        if attempt < retry - 1:
            time.sleep(0.5)
    
    return False

# ---------------- Calcul IDX actif ----------------
def get_active_idx(canal_id: int) -> Optional[int]:
    """Calcule l'IDX actif Tempo pour un canal"""
    tempo_data = load_tempo_actif()
    
    if tempo_data is None:
        return None
    
    # Vérifier si données périmées
    if tempo_data.get("_stale", False):
        logger.debug(f"[Canal {canal_id}] Données trop anciennes")
        return None
    
    tempo_color = tempo_data.get("CouleurJour")
    hc_status = tempo_data.get("HC")
    
    if tempo_color == "Pas d'info":
        logger.debug(f"[Canal {canal_id}] Couleur = Pas d'info")
        return None
    
    if tempo_color is None or hc_status is None:
        return None
    
    # Mapping couleur+HP/HC → IDX
    is_hc = str(hc_status).lower() == "on"
    
    mapping = {
        "Bleu": CANAUX[canal_id]["IDX_TEMPO"]["Bleu_HC"] if is_hc else CANAUX[canal_id]["IDX_TEMPO"]["Bleu_HP"],
        "Blanc": CANAUX[canal_id]["IDX_TEMPO"]["Blanc_HC"] if is_hc else CANAUX[canal_id]["IDX_TEMPO"]["Blanc_HP"],
        "Rouge": CANAUX[canal_id]["IDX_TEMPO"]["Rouge_HC"] if is_hc else CANAUX[canal_id]["IDX_TEMPO"]["Rouge_HP"]
    }
    
    active_idx = mapping.get(tempo_color)
    
    if active_idx is None:
        logger.warning(f"[Canal {canal_id}] Couleur inconnue: {tempo_color}")
    
    return active_idx

# Variables globales par canal
canal_data = {}
for canal_id in CANAUX.keys():
    canal_data[canal_id] = {
        "power": None,
        "total": None,
        "energy": None,
        "total_sync": None,
        "correction_applied": False,
        "last_daily_reset_date": None,
        "last_update": 0
    }

last_watchdog = 0

# Statistiques globales
stats = {
    "mqtt_messages": 0,
    "domoticz_updates": 0,
    "errors": 0,
    "start_time": time.time()
}

# ---------------- MQTT Callbacks ----------------
def on_message(client, userdata, msg):
    """Callback appelé lors de la réception d'un message MQTT"""
    global last_watchdog, stats
    
    topic = msg.topic
    payload_raw = msg.payload.decode()
    
    try:
        payload = float(payload_raw)
    except ValueError:
        logger.debug(f"Payload non numérique ignoré: {topic} = {payload_raw}")
        return
    
    stats["mqtt_messages"] += 1
    logger.debug(f"MQTT: {topic} = {payload}")
    
    # Déterminer le canal depuis le topic
    canal_id = None
    for cid, config in CANAUX.items():
        if config["topic"] in topic:
            canal_id = cid
            break
    
    if canal_id is None:
        logger.warning(f"Topic inconnu: {topic}")
        return
    
    cd = canal_data[canal_id]
    
    # Extraire le type de message
    if topic.endswith("/power"):
        cd["power"] = payload
    elif topic.endswith("/total"):
        cd["total"] = payload
    elif topic.endswith("/energy"):
        cd["energy"] = payload
    
    now = time.time()
    
    # ----- Compteurs simples -----
    if cd["power"] is not None and cd["total"] is not None and now - cd["last_update"] >= UPDATE_INTERVAL:
        if send_domoticz(CANAUX[canal_id]["IDX_SIMPLE"], 0, f"{cd['total']}"):
            stats["domoticz_updates"] += 1
        else:
            logger.error(f"[Canal {canal_id}] ✗ Échec envoi total IDX={CANAUX[canal_id]['IDX_SIMPLE']}")
            stats["errors"] += 1
            
        if send_domoticz(CANAUX[canal_id]["IDX_COMPUTED"], 0, f"{cd['power']};0"):
            stats["domoticz_updates"] += 1
        else:
            logger.error(f"[Canal {canal_id}] ✗ Échec envoi power IDX={CANAUX[canal_id]['IDX_COMPUTED']}")
            stats["errors"] += 1
            
        cd["last_update"] = now
    
    # ----- Compteurs Tempo -----
    if cd["energy"] is not None and topic.endswith("/energy"):
        # Synchroniser total avec energy
        if cd["total"] is not None:
            cd["total_sync"] = cd["total"]
            logger.debug(f"[Canal {canal_id}] Sync: total_sync={cd['total_sync']} Wh")
        
        # Charger infos tempo
        tempo_data = load_tempo_actif()
        current_hc_status = None
        current_color = None
        
        canal_state = state["canaux"][str(canal_id)]
        
        if tempo_data is not None:
            current_hc_status = tempo_data.get("HC")
            current_color = tempo_data.get("CouleurJour")
            
            # Détecter changement HC/HP
            if canal_state["correction"]["last_hc_status"] is not None and \
               canal_state["correction"]["last_hc_status"] != current_hc_status:
                
                logger.info(f"[Canal {canal_id}] 🔄 Changement: {canal_state['correction']['last_hc_status']} → {current_hc_status}")
                
                # Appliquer correction
                old_idx = canal_state["correction"]["last_active_idx"]
                
                if old_idx is not None:
                    total_debut = canal_state["correction"]["total_at_activation"]
                    total_fin = cd["total_sync"] if cd["total_sync"] is not None else cd["total"]
                    
                    if total_fin is not None:
                        apply_correction(canal_id, old_idx, total_debut, total_fin, current_hc_status)
                        cd["correction_applied"] = True
                    else:
                        logger.error(f"[Canal {canal_id}] Impossible correction: pas de total")
        
        active_idx = get_active_idx(canal_id)
        
        if active_idx is not None:
            # Vérifier valeurs en attente
            if canal_state["pending_energy"]["enabled"]:
                accumulated = canal_state["pending_energy"]["accumulated"]
                count = canal_state["pending_energy"]["count"]
                logger.info(f"[Canal {canal_id}] ✅ Envoi valeurs accumulées: {accumulated:.4f} Wh ({count} mesures)")
                
                if send_domoticz(active_idx, 0, f"{accumulated:.4f}"):
                    logger.info(f"[Canal {canal_id}] ✓ Valeurs accumulées envoyées IDX {active_idx}")
                    stats["domoticz_updates"] += 1
                    
                    # Réinitialiser
                    canal_state["pending_energy"]["enabled"] = False
                    canal_state["pending_energy"]["accumulated"] = 0.0
                    canal_state["pending_energy"]["count"] = 0
                    canal_state["pending_energy"]["start_timestamp"] = None
                else:
                    logger.error(f"[Canal {canal_id}] ✗ Échec envoi valeurs accumulées")
                    stats["errors"] += 1
            
            # Enregistrer nouvelles valeurs si changement
            if current_hc_status is not None and (
                canal_state["correction"]["last_hc_status"] != current_hc_status or
                canal_state["correction"]["last_active_idx"] != active_idx
            ):
                # Récupérer Counter actuel
                counter_value = get_counter_from_domoticz(active_idx)
                
                canal_state["correction"]["last_hc_status"] = current_hc_status
                canal_state["correction"]["last_color"] = current_color
                canal_state["correction"]["last_active_idx"] = active_idx
                total_value = cd["total_sync"] if cd["total_sync"] is not None else cd["total"]
                canal_state["correction"]["total_at_activation"] = total_value if total_value is not None else 0
                canal_state["correction"]["tempo_at_activation"] = counter_value * 1000  # kWh → Wh
                canal_state["correction"]["activation_timestamp"] = datetime.now().isoformat()
                
                if cd["total_sync"] is not None:
                    logger.info(f"[Canal {canal_id}] 📍 Nouveau cycle: IDX {active_idx}, Total_sync={cd['total_sync']:.1f} Wh")
                else:
                    logger.warning(f"[Canal {canal_id}] 📍 Nouveau cycle: IDX {active_idx}, Total={cd['total']:.1f} Wh")
            
            # Conversion energy en Wh
            energy_wh = cd["energy"] / 60
            
            # Reset 00h01
            current_time = datetime.now()
            current_date = current_time.strftime("%Y-%m-%d")
            is_reset_time = (current_time.hour == 0 and current_time.minute == 1 and 
                           cd["last_daily_reset_date"] != current_date)
            
            if is_reset_time:
                cd["last_daily_reset_date"] = current_date
                logger.info(f"[Canal {canal_id}] 🔄 00h01 - Début reset CounterToday")
            
            for name, idx in CANAUX[canal_id]["IDX_TEMPO"].items():
                if idx == active_idx:
                    # Ne pas envoyer energy_wh si correction appliquée (déjà inclus)
                    if cd["correction_applied"]:
                        logger.debug(f"[Canal {canal_id}] Pas d'envoi energy (inclus correction)")
                    else:
                        if send_domoticz(idx, 0, f"{energy_wh:.4f}"):
                            stats["domoticz_updates"] += 1
                        else:
                            stats["errors"] += 1
                else:
                    # Ne pas envoyer 0 si correction appliquée
                    if cd["correction_applied"]:
                        logger.debug(f"[Canal {canal_id}] Pas de 0 (protection correction)")
                        break
                    # Reset 00h01
                    elif is_reset_time:
                        counter_today_kwh = get_counter_today_from_domoticz(idx)
                        if counter_today_kwh != 0:
                            compensation_wh = -counter_today_kwh * 1000
                            logger.info(f"[Canal {canal_id}]    └─ Reset {name} (IDX={idx}): {counter_today_kwh:.3f} kWh → {compensation_wh:.1f} Wh")
                            if send_domoticz(idx, 0, f"{compensation_wh:.1f}"):
                                stats["domoticz_updates"] += 1
                            else:
                                stats["errors"] += 1
                    else:
                        if send_domoticz(idx, 0, "0"):
                            stats["domoticz_updates"] += 1
                        else:
                            stats["errors"] += 1
            
            # Réinitialiser flag correction
            if cd["correction_applied"]:
                logger.info(f"[Canal {canal_id}] ✓ Protection correction: reprise normale")
                cd["correction_applied"] = False
            
            save_json(state)
        else:
            # Accumulation si couleur indisponible
            energy_wh = cd["energy"] / 60
            
            if not canal_state["pending_energy"]["enabled"]:
                logger.warning(f"[Canal {canal_id}] ⏳ Début accumulation: {energy_wh:.4f} Wh")
                canal_state["pending_energy"]["enabled"] = True
                canal_state["pending_energy"]["accumulated"] = energy_wh
                canal_state["pending_energy"]["count"] = 1
                canal_state["pending_energy"]["start_timestamp"] = datetime.now().isoformat()
            else:
                canal_state["pending_energy"]["accumulated"] += energy_wh
                canal_state["pending_energy"]["count"] += 1
                logger.info(f"[Canal {canal_id}] ⏳ Accumulation: +{energy_wh:.4f} Wh → Total: {canal_state['pending_energy']['accumulated']:.4f} Wh ({canal_state['pending_energy']['count']} mesures)")
            
            save_json(state)
        
        # Réinitialiser energy
        cd["energy"] = None
    
    # Watchdog systemd
    if SYSTEMD and now - last_watchdog >= 2:
        notify("WATCHDOG=1")
        last_watchdog = now

def on_connect(client, userdata, flags, rc):
    """Callback lors de la connexion MQTT"""
    if rc == 0:
        logger.info("✓ Connecté au broker MQTT")
        for canal_id, config in CANAUX.items():
            topic = f"{config['topic']}/#"
            client.subscribe(topic)
            logger.info(f"✓ Souscription canal {canal_id}: {topic}")
    else:
        logger.error(f"✗ Échec connexion MQTT, code: {rc}")

def on_disconnect(client, userdata, rc):
    """Callback lors de la déconnexion MQTT"""
    logger.warning(f"⚠️  Déconnexion MQTT, code: {rc}")

def on_subscribe(client, userdata, mid, granted_qos):
    """Callback confirmation souscription"""
    logger.info(f"Souscription confirmée, QoS: {granted_qos}")

def print_stats():
    """Affiche les statistiques"""
    uptime = int(time.time() - stats["start_time"])
    logger.info("=" * 50)
    logger.info(f"STATISTIQUES - Uptime: {uptime}s")
    logger.info(f"Messages MQTT reçus: {stats['mqtt_messages']}")
    logger.info(f"Mises à jour Domoticz: {stats['domoticz_updates']}")
    logger.info(f"Erreurs: {stats['errors']}")
    logger.info("=" * 50)

# ---------------- Main ----------------
def main():
    """Fonction principale"""
    logger.info("=" * 50)
    logger.info("Démarrage du script Shelly EM - 2 canaux")
    logger.info("=" * 50)
    logger.info(f"Broker MQTT: {MQTT_BROKER}:{MQTT_PORT}")
    logger.info(f"Shelly ID: {SHELLY_ID}")
    logger.info(f"URL Domoticz: {DOMOTICZ_URL}")
    logger.info(f"Canaux configurés: {list(CANAUX.keys())}")
    logger.info(f"Fichier tempo: {TEMPO_ACTIF_FILE}")
    logger.info("=" * 50)
    
    # Client MQTT
    client = mqtt.Client()
    client.on_connect = on_connect
    client.on_message = on_message
    client.on_disconnect = on_disconnect
    client.on_subscribe = on_subscribe
    
    # Stats périodiques (toutes les heures)
    last_stats = time.time()
    
    try:
        logger.info("Connexion au broker MQTT...")
        client.connect(MQTT_BROKER, MQTT_PORT, 60)
        
        if SYSTEMD:
            notify("READY=1")
            logger.info("✓ Signal READY=1 envoyé à systemd")
        
        logger.info("✓ Démarrage de la boucle principale")
        
        # Boucle principale non-bloquante
        client.loop_start()
        
        while True:
            time.sleep(60)
            
            # Afficher stats toutes les heures
            if time.time() - last_stats >= 3600:
                print_stats()
                last_stats = time.time()
        
    except KeyboardInterrupt:
        logger.info("Arrêt demandé par l'utilisateur")
    except Exception as e:
        logger.error(f"Erreur fatale: {e}")
    finally:
        client.loop_stop()
        client.disconnect()
        logger.info("Script arrêté")

if __name__ == "__main__":
    main()

Ce script tourne sur mon domoticz de travail et rempli parfaitement son rôle.

Comme je l'ai dit en préambule, je ne suis pas un expert python aussi je ne pourrai pas répondre à des questions technique sur tel ou tel point du script.

Ce message pourrait éventuellement faire l'objet d'un tuto.

Dernière mise à jour : 25/12/2026 Mise à jour finale.