HA-Gestion Complète d’une Piscine avec ESP32 et ESPHome

Contents [hide]

Intro

Dans un article précédent, je vous présentais un module de gestion de la filtration et traitement d’une piscine développé sous APP DAEMON. J’ai depuis, pour simplifier la mise en oeuvre, migré vers ESP Home. Je vous invite quand même à le parcourir, tout n’est pas à jeter.

Dans ce nouveau projet, j’ai développé une solution complète pour automatiser la gestion de ma piscine à l’aide d’un ESP32 programmé avec ESPHome. Ce système contrôle la filtration, la régulation du pH et du chlore, le niveau d’eau, le volet roulant, et même une protection hors gel, le tout intégré à Home Assistant. Voici les détails de cette réalisation, du matériel au code, pour ceux qui voudraient s’en inspirer !

Update:

  • 09/06/2025:
    • Suppression des ‘$device_name} dans les définitions d’entités (simplification des noms)
    • Séparation du module « mesure_pression » du module principal
    • Ajout compteur de litres dans ph et chlore
    • Mise à jour du schéma (Brochage ADS1115)
  • 21/05/2025:
    • refonte du programme: Séparation des fichiers ph, chlore, Appoint_eau, gestion volet. Cela permet de les installer uniquement si nécessaire.
  • 18/05/2025:
    • Intégration d’une fonction de conversion d’un int en HH:MM:SS
    • Refonte de la régulation pH
    • Refonte de l’injection Chlore
    • Ajout d’un script de pilotage de la couverture flottante

Objectif du Projet

L’idée était de centraliser et d’automatiser toutes les fonctions essentielles de ma piscine (volume : 50 m³) :

  • Filtration : adapter la durée en fonction de la température de l’eau, avec plusieurs modes (Palier, Classique, Abaque, Horaire).
  • Régulation chimique : maintenir le pH et le chlore à des niveaux cibles via des pompes doseuses.
  • Niveau d’eau : gérer l’appoint automatiquement avec une électrovanne.
  • Volet roulant : ouverture/fermeture via relais.
  • Sécurité : surveiller la pression du filtre, le temps d’usage des galets de chlore, et activer un mode hors gel en hiver.
  • Monitoring : notifications Telegram et affichage local sur un écran LCD.

Le tout repose sur une carte ESP32 et un circuit imprimé maison pour simplifier les raccordements.

Matériel Utilisé

  • Carte ESP32 : J’ai choisi une carte avec 8 relais intégrés, disponible sur AliExpress. Elle offre assez de sorties pour piloter la pompe de filtration, les pompes doseuses, l’électrovanne, et le volet.
  • Circuit Imprimé Personnalisé : Conçu pour connecter facilement les capteurs (température, pH, pression) et les actionneurs via des borniers.
  • Capteurs :
    • Sonde Dallas DS18B20 (température eau).
    • Un module mesure de ph EZO
    • Une sonde pH EZO. J’utilise celle ci mais il existe des modèles compatibles industriels beaucoup plus chers et des modèles chinois beaucoup moins chers, c’est selon l’importance et le budget que vous accorderez à la fiabilité et la pérennité de la mesure.
    • Un module d’isolation galvanique EZO.
    • Sonde ORP EZO ou Ali Express.
    • Capteur de pression ADS1115 (filtre).
    • un module PZEM-004T 100A permettant de mesurer des courants de 0-100A sous une tension alternative de 80-260V.
    • Détecteurs de niveau (LSH, LSL via SX1509). Voir article sur la mesure de niveau
  • Actionneurs : Relais pour pompe filtration (12 m³/h), pompes doseuses (pH-, chlore), électrovanne eau, et volet roulant.
  • Afficheur LCD 16×2 I2C : Pour un suivi local (pH, pression, température).

Conception du Circuit Imprimé

Pour éviter un câblage anarchique, j’ai conçu un PCB qui regroupe toutes les connexions nécessaires. Voici une vue du PCB final, conçu avec EasyEda et fabriqué par JLCPCB :

Le PCB inclut :

  • Connecteurs pour capteurs : Borniers pour la sonde Dallas (température eau), les sondes EZO (pH et ORP), l’ADS1115 (pression), et les détecteurs de niveau (LSH, LSL).
  • Interfaces de communication : I2C pour l’afficheur et les EZO, UART pour le PZEM-004T.
  • Extension SX1509 : Pour gérer plus d’entrées/sorties (niveaux, LEDs).
  • Alimentation : Régulateurs 12V/5V/3.3V pour alimenter l’ESP32 et les capteurs.
  • Sorties vers les relais : Connecteurs pour piloter la pompe de filtration, les pompes doseuses, l’électrovanne, et le volet roulant.
  • Connecteurs supplémentaires : Pour des extensions futures (ex. : capteur ORP).

Le PCB est étiqueté clairement (ex. : « PZEM-004T », « ADS1115 », « PH-EZO ») pour faciliter les raccordements. Les borniers à vis permettent de brancher/débrancher facilement les capteurs et actionneurs sans soudure. La conception a été finalisée le 25/07/2024, comme indiqué sur le PCB.

La liste du materiel est disponible ici: https://github.com/remycrochon/domo.rem81/blob/main/BOM_Extension-Platine-Relais-ESP32-Veth_2025-05-01.csv

Le Gerber est disponible ici:

Schéma de Câblage

Pour mieux comprendre les connexions, voici le schéma de câblage réalisé sous Easyeda :

Au format easyEDA:

Au format PNG:

Le schéma montre :

  • ESP32 : Cœur du système, connecté via GPIO aux capteurs et actionneurs.
  • Capteurs :
    • Sonde Dallas (GPIO16) pour la température de l’eau.
    • EZO pH/ORP via I2C (SDA GPIO15, SCL GPIO22).
    • ADS1115 (pression filtre) sur I2C.
    • PZEM-004T (puissance) via UART (RX GPIO3, TX GPIO1).
    • Détecteurs de niveau (LSH, LSL) via SX1509.
  • Actionneurs :
    • Relais pour la pompe de filtration (GPIO32), pompes doseuses (GPIO33, GPIO25), électrovanne (GPIO12), et volet roulant (GPIO27, GPIO14).
  • Extensions :
    • SX1509 pour gérer les entrées/sorties supplémentaires (niveaux, LEDs).
    • Afficheur LCD 16×2 sur I2C.
  • Alimentation : Régulateurs 12V/5V/3.3V pour l’ESP32 et les capteurs.

Ce schéma est essentiel pour comprendre comment tout est interconnecté et pour reproduire le projet.

Fonctionnement et Code ESPHome

Le programme ESPHome (ESP178) est structuré autour de plusieurs scripts et capteurs.

Le corps principal du programme gère les différents modes de filtration. Vous pouvez insérer ou pas les sous programmes suivants en commentant la ligne correspondante sous l’instruction « Includes »:

  • packages:
    • ph: !include pack_esp178/ph.yaml
    • chlore: !include pack_esp178/chlore.yaml
    • volet: !include pack_esp178/couverture_flottante.yaml
    • appoint_eau: !include pack_esp178/appoint_eau.yaml
    • hors_gel: !include pack_esp178/hors_gel.yaml
    • mesure_elec: !include pack_esp178/mesure_elec.yaml
    • mesure_pression: !include pack_esp178/mesure_pression.yaml

Les fichiers .yaml doivent stockés dans un sous_dossier de ESPhome, « pack_esp178 » dans mon cas.

Filtration :

Quatre modes de fonctionnement:

Palier : Durée fixée par paliers.

  • la durée est calculée manuellement par rapport au volume de la piscine: Vol=9*4*1.4=50.4 m3
  • et le débit de la pompe: Qppe Théorique=12m3/h -> retenu:12m3/h
  • donc 1 cycle= 50.4/12=4.2h
  • il faut filtrer au minimum:
  • T°eau<10° = 0 cycle
  • 10°<T°eau<15° = 1 cycle = 4.2h => 4h
  • 15°<T°eau<20° = 2 cycles = 8.4h => 8h
  • 20°<T°eau<25° = 3 cycles = 12.6h => 12h
  • 25°<T°eau = 3 ou 4 cycles = 12.6h => 12h
  • T°eau>25° => 14h

Classique : Durée = température / 2 (min 5h, max 23h).

Solution largement. Exemple : À 20°C, durée = 10h.

Abaque : Durée calculée par une formule polynomiale cubique :


Horaire : Plage horaire fixe (ex. : hiver).

Régulation pH :

Dans ce chapitre, je vous explique comment j’ai mis en place un système intelligent pour ajuster automatiquement le pH en injectant un produit acidifiant, avec des notifications et une gestion fine des durées. L’objectif est de maintenir un pH optimal (généralement entre 7.2 et 7.6), essentiel pour le confort des baigneurs et l’efficacité du chlore.

Contexte et objectif

Ma piscine est équipée d’une pompe doseuse pour injecter un produit pH- (acidifiant) lorsque le pH mesuré dépasse la cible définie. Je récupère la valeur mesurée du pH via id(g_memoire_ph) et la cible via id(_ph_cible). L’idée est de calculer une durée d’injection proportionnelle à l’écart entre la mesure et la cible, en utilisant les caractéristiques de ma piscine (43,2 m³) et du produit pH Moins Ultra (0,2 l pour 0,1 unité de pH pour 10 m³). Le système s’active uniquement en mode automatique et lorsque la filtration est en marche.

La configuration ESPHome

Voici le script que j’ai intégré dans mon fichier ESPHome pour réguler le pH de manière précise et automatisée.

Vous trouverez le code complet fin d’article.

Ce script est appelé deux fois par jour, à 10:30 et 15:30.

Fonctionnement détaillé

Ce script, exécuté en mode single, régule le pH de manière intelligente. Voici comment il opère étape par étape :

Condition d’activation

Le script se déclenche uniquement en mode Auto. Il vérifie si le pH mesuré (g_memoire_ph) dépasse la cible (_ph_cible) de plus de 0,1 unité (définie dans _ph_hysteresis) et si la filtration est active.

Calcul de la durée d’injection

L’écart entre la mesure et la cible est calculé, et la durée d’injection est déterminée directement en fonction de cet écart, du volume de la piscine, et du débit de la pompe :

  • Quantité nécessaire : On calcule la quantité de produit à injecter pour corriger l’écart total, en se basant sur les données du produit (0,2 l pour 0,1 unité de pH pour 10 m³). Pour ma piscine de 43,2 m³, cela représente 0,864 l pour 0,1 unité de pH.
  • Durée théorique : On détermine le temps nécessaire pour injecter cette quantité, en fonction du débit de la pompe (_debit_ppe_moins, par exemple 4,272 l/h, soit environ 1,187 ml/s).
  • Facteur de correction : Pour éviter une sur-correction, seule une fraction de l’écart est corrigée par cycle. Ce facteur est ajustable via un paramètre (facteur_correction_ph), que j’ai initialement fixé à 10 %.
  • Limites : La durée est bornée entre 1 et 60 secondes pour éviter une surdose.

Conversion et affichage

Le temps calculé est converti en heures, minutes et secondes, puis affiché via un composant datetime (duree_injection_ph) pour une lecture facile dans Home Assistant.

Gestion de la pompe

Si une durée positive est calculée, la pompe (cde_ppe_ph_moins) s’active pour cette durée. Des notifications Telegram sont envoyées au début et à la fin de l’injection, avec les détails du pH mesuré, de la cible et de la durée. Si aucune action n’est requise, la pompe reste éteinte.

Logs et suivi

Des messages de log permettent de suivre les valeurs mesurées, les quantités calculées, le débit, le facteur de correction et la durée finale, facilitant le débogage.

Exemple pratique

Supposons que ma cible soit 7.4 et que le pH mesuré soit 8.0 (écart de 0.6) :

  • Quantité nécessaire : (0,6 / 0,1) x 0,2 x (43,2 / 10) = 5,184 l (soit 5184 ml).
  • Débit de la pompe : 4,272 l/h = 1,187 ml/s.
  • Durée totale : 5184 / 1,187 = 4368 secondes (environ 73 minutes).
  • Avec un facteur de correction de 10 % : 4368 x 0,1 = 436,8 secondes, mais limité à 60 secondes (maximum par cycle).
  • Résultat : La pompe s’active pendant 60 secondes, injectant environ 71 ml, et le processus se répète au prochain cycle jusqu’à atteindre la cible.

Conseils pour adapter cette solution

  • Ajustez le facteur de correction : Modifiez facteur_correction_ph (par exemple, passez de 0,1 à 0,2 pour une correction plus rapide) selon la réactivité de votre produit pH-.
  • Vérifiez le débit : Assurez-vous que le débit de la pompe (_debit_ppe_moins) est correct et ajustez-le si nécessaire.
  • Validez les mesures : Utilisez un kit d’analyse pour confirmer les ajustements et affinez si besoin.

Un dispositif similaire a été décrit dans ces articles :

Injection Chlore :

L’objectif est de calculer précisément le temps d’injection de chlore liquide (javel à 9.6%) en fonction de la concentration cible souhaitée, tout en utilisant une pompe doseuse connectée. Voici comment j’ai procédé.

Contexte et objectif

Ma piscine est équipée d’une pompe doseuse connectée via ESPHome, qui injecte de la javel à 9.6% de chlore actif (96 g/L). Je voulais un système capable de calculer automatiquement le temps d’injection en fonction de la concentration cible de chlore (en ppm), du volume de la piscine, et du débit de la pompe. De plus, j’ai ajouté des fonctionnalités pour afficher le temps sous forme lisible (heures, minutes, secondes) et envoyer des notifications via Telegram.

La configuration ESPHome

Voici le script que j’ai intégré dans mon fichier ESPHome pour gérer l’injection de chlore. Ce script fonctionne dans différents modes (automatique, manuel forcé, ou arrêté forcé) et s’active uniquement lorsque la filtration est en marche.

Vous trouverez le code complet fin d’article.

Ce script est appelé deux fois par jour à 10:00 et à 15:00.

Fonctionnement détaillé

Le script s’exécute dans un mode single, ce qui garantit qu’il ne se répète pas indéfiniment. Voici comment il fonctionne étape par étape :

Calcul du dosage :

Conversion du temps :

  • Le temps calculé est converti en heures, minutes, et secondes, puis affiché via un composant datetime (duree_injection_chlore). Cela me permet de visualiser facilement la durée dans Home Assistant.

Gestion de la Pompe:

  • En mode Auto, si un temps positif est calculé et que la filtration est en marche, la pompe (cde_ppe_chlore) s’active pour la durée calculée, et une notification Telegram est envoyée avec le temps d’injection.
  • Si aucun temps n’est requis ou si la filtration est arrêtée, la pompe s’arrête.
  • En mode Ma_f (manuel forcé), la pompe s’active tant que la filtration est en marche, quelle que soit la concentration.
  • En mode At_f (arrêt forcé) ou si la filtration est désactivée, la pompe s’arrête automatiquement.

Logs et débogage

  • Des messages de log sont générés à chaque étape (début du script, temps calculé, activation/arrêt de la pompe), avec des détails comme la quantité injectée et le temps. Cela me permet de suivre l’opération en temps réel.

Mesure de Pression:

Appoint d’Eau :

L’objectif ici est d’assurer automatiquement le maintien du niveau d’eau dans la piscine grâce à une électrovanne commandée par un ESP32 sous ESPHome, tout en prenant en compte les différents niveaux d’eau détectés par des sondes de niveau. Le tout fonctionne de manière autonome ou manuelle, avec un suivi des durées d’ouverture et une protection contre les défauts.

Le dispositif mis en oeuvre a été décrit dans cet article: https://domo.rem81.com/index.php/2021/04/02/home-assistant-gestion-piscine-1_mise-a-niveau-automatique/

Contexte et objectif

Ma piscine est équipée de deux sondes de niveau connectées à un module d’extension SX1509 (LSH = haut, LSL = bas), permettant de détecter quatre états : niveau haut, intermédiaire, bas, ou défaut (cas incohérent). Une électrovanne pilotée par un relais ESP32 se charge d’ajouter de l’eau quand nécessaire. L’objectif est de gérer l’appoint d’eau de manière fiable, avec une sécurité anti-débordement et une logique de fonctionnement automatique ou forcée.

Trois modes sont proposés via une entité select dans Home Assistant :

  • Auto : Appoint automatique uniquement si la couverture est ouverte, sans défaut, et en fonction du niveau mesuré.
  • Ma_f (manuel forcé) : Ouverture manuelle de l’électrovanne tant que ce mode est actif.
  • At_f (arrêt forcé) : Blocage total de l’appoint d’eau, utile en hiver ou lors de maintenance.

La configuration ESPHome

Un script principal _regul_eau pilote l’ouverture/fermeture de l’électrovanne selon les niveaux d’eau détectés, les conditions de sécurité, et le mode choisi. Ce script peut être déclenché automatiquement ou via un bouton virtuel dans Home Assistant.

Un second script _calcul_niveau_eau, exécuté toutes les 5 secondes, analyse l’état des deux sondes pour déterminer précisément le niveau global de la piscine (haut, intermédiaire, bas ou défaut).

Un compteur duty_time mesure quotidiennement le temps d’ouverture de l’électrovanne, remis à zéro chaque nuit à minuit grâce à une synchronisation SNTP.

Vous trouverez le code complet fin d’article.

Fonctionnement détaillé

Analyse des niveaux d’eau

Les deux sondes permettent d’interpréter 4 états logiques :

  • Niveau haut : LSH = ON, LSL = ON
  • Niveau intermédiaire : LSH = OFF, LSL = ON
  • Niveau bas : LSH = OFF, LSL = OFF
  • Défaut : LSH = ON, LSL = OFF (état incohérent)

Chaque état est publié via un binary_sensor pour un affichage clair dans Home Assistant.

Régulation de l’eau

  • En mode Auto :
    • Si le niveau est bas ou intermédiaire, et que le volet est ouvert, l’électrovanne s’ouvre automatiquement pour 15 minutes (timeout de sécurité).
    • Si le niveau est haut ou en défaut, la vanne est immédiatement fermée.
    • Le tout est logué en détail (mode actif, état de la vanne, etc.).
  • En mode Ma_f (manuel forcé) :
    • L’électrovanne s’ouvre dès que ce mode est activé, quel que soit le niveau, tant que la filtration est en fonctionnement.
  • En mode At_f (arrêt forcé) :
    • La vanne reste fermée en toute circonstance.

Logs et surveillance

Chaque activation de la vanne est horodatée et consignée dans les logs. Le capteur duty_time permet de visualiser le temps d’ouverture cumulé sur la journée, un bon indicateur de fuite éventuelle ou de sur-remplissage.

Enfin, l’ensemble de cette logique est complètement intégrée dans Home Assistant avec affichage des niveaux, du mode actif, du bouton d’appoint manuel, et des durées de fonctionnement.

Fonctionnement Hors Gel : Protection Automatisée de la Piscine en Hiver

Afin de protéger le local technique et le circuit hydraulique de la piscine contre le gel, j’ai mis en place une fonction de hors gel intelligente dans ESPHome. Cette logique permet d’enclencher automatiquement la pompe de filtration en cas de température extérieure négative, selon deux seuils personnalisables.

Objectif

Lors de températures proches ou en dessous de zéro, le risque de gel dans les canalisations est réel, en particulier si l’eau est stagnante. La circulation de l’eau suffit généralement à empêcher ce phénomène. Plutôt que de laisser tourner la pompe en continu, j’ai opté pour un fonctionnement ponctuel conditionné par la température extérieure, avec des alertes Telegram à chaque déclenchement.

Principe de fonctionnement

Le fonctionnement repose sur deux seuils configurables depuis Home Assistant :

  • Seuil 1 : en dessous duquel la pompe tourne pendant 15 minutes,
  • Seuil 2 : en dessous duquel elle tourne pendant 30 minutes.

La logique est testée toutes les 15 minutes. Un flag interne empêche que plusieurs cycles ne soient lancés simultanément.

Intégration dans ESPHome

Vous trouverez le code complet fin d’article.

Notifications et suivi

À chaque déclenchement ou arrêt d’un cycle hors gel, une notification Telegram est envoyée. Cela me permet de suivre à distance l’activité hivernale de la piscine.

Automatisation de la couverture flottante

Dans ce chapitre je vous explique comment j’ai automatisé la gestion de la couverture flottante de ma piscine grâce à ESPHome. Cette couverture protège l’eau des impuretés, réduit l’évaporation et conserve la chaleur, mais l’ouvrir et la fermer manuellement peut être fastidieux. J’ai donc créé un système qui pilote automatiquement l’ouverture et la fermeture en fonction de l’heure et du mode de fonctionnement.

Pour information ma couverture flottante est un :https://www.abriblue.com/solutions/immax/

J’ai raccordé un contact NO des relais de commande d’ouverture et de fermeture en parallèle sur le boitier à clef fourni avec le volet.

Attention:

Ce n’est pas conforme de fermer un volet sans visuel, mais je le fais en connaissance de cause et je vous conseille de modifier les scripts de fermeture: Vous pouvez automatiser l’ouverture et déclencher la fermeture manuellement avec un visuel sur le volet

Contexte et objectif

Ma piscine est équipée d’une couverture flottante motorisée, contrôlée par deux interrupteurs : cde_volet_ouverture pour ouvrir et cde_volet_fermeture pour fermer. Je voulais automatiser ce processus selon deux modes :

  • Mode « Horaire » : L’ouverture et la fermeture se font à des heures fixes, définies par h_ouv_volet et h_ferm_volet.
  • Mode « Auto » : L’ouverture est synchronisée avec le démarrage de la filtration (selon ses modes : « Horaire », « Palier », « Classique », ou « Abaque »), et la fermeture se fait à l’heure définie par h_ferm_volet.

De plus, j’ai ajouté un éclairage (cde_eclairage) qui s’allume pendant la fermeture pour des raisons de sécurité, et un script pour arrêter manuellement le mouvement si nécessaire.

La configuration ESPHome

Vous trouverez le code complet fin d’article.

Fonctionnement détaillé

Ce script pilote la couverture flottante de manière intelligente, en fonction de l’heure et du mode choisi. Voici les étapes principales :

Vérification de l’heure :

  • Le script utilise sntp_time pour récupérer l’heure actuelle et s’assure qu’elle est valide. Des logs permettent de comparer l’heure actuelle avec les heures programmées pour l’ouverture (h_ouv_volet) et la fermeture (h_ferm_volet).

Mode « Horaire » :

  • Si le mode est défini sur « Horaire », le script vérifie si l’heure actuelle correspond à l’heure d’ouverture ou de fermeture définie. Si c’est le cas, il exécute respectivement script_ouv_volet ou script_ferm_volet.

Mode « Auto » :

  • En mode « Auto », l’ouverture est déclenchée à l’heure de démarrage de la filtration (h_debut), mais uniquement si la filtration est configurée dans un mode compatible (« Horaire », « Palier », « Classique », ou « Abaque »).
  • La fermeture est déclenchée à l’heure définie par h_ferm_volet, comme en mode « Horaire ».

Scripts d’ouverture et de fermeture :

  • Ouverture (script_ouv_volet) : Éteint la commande de fermeture, attend 2 secondes, active la commande d’ouverture pendant 5 secondes, puis l’éteint.
  • Fermeture (script_ferm_volet) : Éteint la commande d’ouverture, attend 2 secondes, active la commande de fermeture et l’éclairage pendant 90 secondes (le temps que la couverture se ferme complètement), puis éteint les deux.
  • Arrêt (script_stop_volet) : Arrête les deux moteurs, effectue une petite impulsion de fermeture pour sécuriser, et éteint l’éclairage.

Exemple pratique

Imaginons que je configure mon système ainsi :

  • Mode « Horaire ».
  • Heure d’ouverture : 8h00 (h_ouv_volet).
  • Heure de fermeture : 20h00 (h_ferm_volet).

À 8h00 précises, le script script_ouv_volet s’exécute : la couverture s’ouvre, laissant la piscine accessible. À 20h00, script_ferm_volet se déclenche : la couverture se ferme en 90 secondes, et l’éclairage s’allume pendant ce temps pour des raisons de sécurité. Si besoin, je peux arrêter manuellement avec script_stop_volet.

Conseils pour adapter cette solution

  • Ajustez les durées : Modifiez les délais (5 secondes pour l’ouverture, 90 secondes pour la fermeture) selon la vitesse de votre moteur.
  • Ajoutez des notifications : Comme pour mes autres scripts, vous pouvez intégrer des messages Telegram pour être informé de chaque ouverture ou fermeture.
  • Testez les modes : Essayez le mode « Auto » pour voir s’il convient à votre routine de filtration, et ajustez les heures si nécessaire.
  • Ne pas reproduire la fermeture automatique

Monitoring :

  • Rapport journalier Telegram à 23h59 (temps filtration, conso, etc.).
  • Alerte si pression filtre > 1.5 bar ou galets chlore épuisés

Mesures Électriques : Surveillance de la Consommation de la Piscine

Afin de surveiller avec précision la consommation électrique de l’ensemble du local technique piscine (pompe de filtration, électrolyseur, régulation, etc.), j’ai intégré un module PZEM-004T (version TTL 100A) à mon ESP32. Ce module permet de mesurer en temps réel la tension, le courant, la puissance active, et l’énergie consommée.

Objectif et contexte

Mon objectif était de disposer d’un suivi précis de la consommation électrique de la piscine pour :

  • Détecter un fonctionnement anormal (pompe bloquée, consommation excessive…),
  • Calculer le coût réel de fonctionnement,
  • Optimiser la planification de certaines tâches en fonction du tarif horaire de l’électricité.

Le PZEM est connecté au circuit principal d’alimentation de la piscine, et communique avec l’ESP32 via l’interface UART (RX/TX).

La configuration ESPHome

Vous trouverez le code complet fin d’article.

Fonctionnement et visualisation

Les valeurs mesurées sont mises à jour toutes les 30 secondes et affichées dans Home Assistant via ESPHome. Cela me permet de :

  • Visualiser la puissance instantanée consommée,
  • Accéder à l’historique de l’énergie consommée chaque jour.

Avantages

Cette mesure pourrait me permet, par exemple, de détecter rapidement un dysfonctionnement de la pompe de filtration, qui tourne à vide suite à un désamorçage. Le courant absorbé étant anormalement bas, une alerte pourrait être générée, évitant un fonctionnement prolongé inutile.

Ce n’est qu’une suggestion car inutile dans mon cas.

Code ESPHOME

Programme Principal:

La dernière version du programme principal est disponible ici:

https://github.com/remycrochon/home-assistant/blob/master/esphome/esp178-esp32-piscine.yaml

Et les sous programmes ici:

https://github.com/remycrochon/home-assistant/tree/master/esphome/pack_esp178

# Mode simulation
# Sensor simulé:
  # temperature eau
  # ph EZO
  # Seuil binary sensor "Marche ppe"
  # test si ph_EZO est valide
  # Cron time pour lancer script regule pH

substitutions:
  device_name: "esp178_piscine"
  friendly_name: esp178
  adress_ip: "192.168.0.178"
  time_timezone: "Europe/Paris"
  # Definition des seuils admissibles
  pu_fonctionnement: "200"
  pression_max: "15"

packages:
  ph: !include pack_esp178/ph.yaml
  chlore: !include pack_esp178/chlore.yaml    
  volet: !include pack_esp178/couverture_flottante.yaml
  appoint_eau: !include pack_esp178/appoint_eau.yaml
  hors_gel: !include pack_esp178/hors_gel.yaml
  mesure_elec: !include pack_esp178/mesure_elec.yaml
  mesure_pression: !include pack_esp178/mesure_pression.yaml

esphome:
  name: ${device_name}
  project:
    name: "rem81.esp178-esp32-piscine"
    version: "1.0.0"

  on_boot:
    priority: 200
    then:
      - lambda: |-
          #define CONVERT_SECONDS(total_seconds, hh, mm, ss) \
          do { \
            int total = static_cast<int>(total_seconds); \
            hh = total / 3600; \
            int r = total - hh * 3600; \
            mm = r / 60; \
            ss = r - mm * 60; \
            hh = std::min(hh, 23); \
            mm = std::min(mm, 59); \
            ss = std::min(ss, 59); \
          } while (0)
      # Initialisation des templates Ph
      # A supprimer si pH non utilisé
      - sensor.template.publish:
          id: _tps_injection_ph_moins
          state: 0.0
      - sensor.template.publish:
          id: _vol_injection_ph_moins
          state: 0.0
      # Initialisation du template Chlore
      # A supprimer si Chlore non utilisé
      - sensor.template.publish:
          id: _tps_injection_chlore
          state: 0.0
                    
      # Messages de Boot
      - delay: 20s
      - lambda: |-
          std::string mess = "Boot ESP178";
          id(telegram_msg_buffer) = mess;
      - homeassistant.service:
          service: notify.telegram
          data:
            message: !lambda 'return id(telegram_msg_buffer).c_str();'
        # Message Telegram
      - lambda: |-                
          std::string mess= "ESP178 Boot";
          id(_message_telegram)->execute(mess.c_str());                   

esp32:
  board: esp32dev

wifi:
  networks:
   - ssid: !secret wifi_esp
     password: !secret mdpwifi_esp
  reboot_timeout: 5min

#ethernet:
#  type: W5500
#  clk_pin: GPIO17
#  mosi_pin: GPIO19
#  miso_pin: GPIO18
#  cs_pin: GPIO21 
#  interrupt_pin: GPIO4
#  reset_pin: GPIO5
#  clock_speed: 15Mhz
  manual_ip:
    static_ip: ${adress_ip}
    gateway: 192.168.0.254
    subnet: 255.255.255.0
    dns1: 192.168.0.254

# Utilisez la LED de l'appareil comme LED d'état, qui clignotera s'il y a des avertissements (lent) ou des erreurs (rapide)
status_led:
  pin:
    number: GPIO23
    inverted: true

# Enable logging
logger:
  level: INFO
  baud_rate: 0
  
# Enable Home Assistant API
api:


ota:
  platform: esphome

web_server:
  port: 80
  version: 3 # sinon 2

time:
  - platform: sntp
    id: sntp_time
    timezone: Europe/Paris
    servers:
      - 0.pool.ntp.org
      - 1.pool.ntp.org
      - 2.pool.ntp.org
    on_time:
      # reset le compteur de temps à minuit
      - seconds: 0
        minutes: 0
        hours: 0
        then:
          - sensor.duty_time.reset: _temps_fonctionnement_ppe_piscine_jour

      # Notification du rapport journalier sur Telegram
      - seconds: 00
        minutes: 59
        hours: 23
        then:
          - lambda: |-
              std::string mess = "ESP178 Temps Fonctionnement filtration";
              id(_message_telegram_v2)->execute(mess,id(_temps_fonctionnement_ppe_piscine_jour).state);
          - lambda: |-          
              std::string mess = "ESP178 Tps Fonct ppe pH";
              id(_message_telegram_v2)->execute(mess,id(_temps_fonctionnement_ppe_ph).state);
          - lambda: |-          
              std::string mess = "ESP178 Tps Fonct ppe Chl";
              id(_message_telegram_v2)->execute(mess,id(_temps_fonctionnement_ppe_chlore).state);

# Connection Bus i2c (Afficheur, EZO,...)
i2c:
  sda: 15
  scl: 22
  scan: false
  id: bus_a
  frequency: 300kHz

# Connection sonde(s) de température DS18b20
one_wire:
  - platform: gpio
    pin: GPIO16

# Extension E/S
sx1509:
  - id: sx1509_hub1
    address: 0x3E

# déclaration des variables "globals"
globals:
    # température de fonctionnement en début de pompage avant prise en compte de la mesure de température
    # en secondes
    - id: g_memoire_temp_eau
      type: float
      restore_value: yes
      initial_value: '25'

    - id: flag_tempo_ppe_filtre
      type: bool
      restore_value: no
      initial_value: 'false'

    # mémorise la durée de filtation dans les différents modes de fonctionnement
    - id: g_tps_filtration
      type: float
      restore_value: no

    # Limite haute du temps de filtration "en heure"
    - id: g_temps_max_filtration
      type: float
      initial_value: '23'
    # Limite basse du temps de filtration (en heure)
    - id: g_temps_min_filtration
      type: float
      initial_value: '5'
    # Paliers temperature/Temps filtration avec le mode "Palier"
    # Seuils Température en °C
    # la durée est calculée manuellement par rapport au volume de la piscine: Vol=9*4*1.4=50.4 m3
    # et le débit de la pompe: Qppe Théorique=12m3/h -> retenu:12m3/h
    # donc 1 cycle= 50.4/12=4.2h
    # il faut filtrer au minimum:
    # T°eau<10° = 0 cycle
    # 10°<T°eau<15° = 1 cycle = 4.2h => 4h
    # 15°<T°eau<20° = 2 cycles = 8.4h => 8h
    # 20°<T°eau<25° = 3 cycles = 12.6h => 12h
    # 25°<T°eau = 3 ou 4 cycles = 12.6h => 12h
    # T°Eau>25° => 14h
    # 
    # si beaucoup de baigneurs de jour alors augmenter le coeff
    # Mode "Palier":
      # Si T°eau< Seuil Temp1 alors Durée = Tps paliers 1
      # Sinon
      # Si T°eau>= Seuil Temp1 et T°eau< Seuil Temp2 alors Durée = Tps paliers 2
      # Sinon
      # Si T°eau>= Seuil Temp2 et T°eau< Seuil Temp3 alors Durée = Tps paliers 3
      # Sinon
      # Si T°eau>= Seuil Temp3 et T°eau< Seuil Temp4 alors Durée = Tps paliers 4
      # Sinon
      # Durée = Tps paliers 5
    - id: g_temp_palier1
      type: float
      initial_value: '10'
    - id: g_temp_palier2
      type: float
      initial_value: '15'
    - id: g_temp_palier3
      type: float
      initial_value: '20'
    - id: g_temp_palier4
      type: float
      initial_value: '25' 
  # nb d'heures de filtration (en h)
    - id: g_tps_palier1
      type: float
      initial_value: '1'      
    - id: g_tps_palier2
      type: float
      initial_value: '4'
    - id: g_tps_palier3
      type: float
      initial_value: '8'
    - id: g_tps_palier4
      type: float
      initial_value: '12'
    - id: g_tps_palier5
      type: float
      initial_value: '14'

    # Constantes utilisées dans le mode "Abaque"  
    - id: g_abaque_a
      type: float
      initial_value: '0.00335'
    - id: g_abaque_b
      type: float
      initial_value: '-0.14953'
    - id: g_abaque_c
      type: float
      initial_value: '2.43489'
    - id: g_abaque_d
      type: float
      initial_value: '-10.72859'

    # Variables intermediaires utilisées dans le calcul "heure debut et fin"
    - id: g_hh
      type: int
    - id: g_mm
      type: int
    - id: g_ss
      type: int
    
    # stocke temporairement le message à envoyer à telegram
    - id: telegram_msg_buffer
      type: std::string
      restore_value: no
      initial_value: '""'

    - id: convert_seconds
      type: std::function<void(int, int&, int&, int&)>

# déclaration des modes de fonctionnement dans des "input select"
select:
  - platform: template
    name: "Mode_Fonctionnement_filtration"
    optimistic: true
    restore_value: true
    options:
      - Palier
      - Classique
      - Abaque
      - Horaire
      - Ma_f
      - At_f
    id: _Mode_Fonctionnement_filtration
    on_value: 
      then:
        - script.execute: _fonctionnement_filtration        
        - logger.log:
            format: "Mode Fonct Filtration --> %s"
            args: [ 'id(_Mode_Fonctionnement_filtration).state.c_str()' ]
            level: INFO
        - lambda: |-
            char buf[64];
            snprintf(buf, sizeof(buf), "Mode Fonct Filtration --> %s", id(_Mode_Fonctionnement_filtration).state.c_str());
            id(_log_message)->execute(std::string(buf));


button:
  # Ce bouton stoppe la filtration pour la journée (cas de mauvais temps par ex)
  # Durée "Arret Jour" en heure on multiplie par 3600 pour avoir des secondes 
  # puis par 1000 pour des millisecondes: unité du delay lambda
  - platform: template
    name: "BP_arret_jour"
    id: _arret_jour
    on_press: 
      then:
      - switch.template.publish:
          id: ent_at_force
          state: ON
      - logger.log:
          format: "Début arret jour pour : %.0f h"
          args: [ 'id(duree_at_jour).state' ]
          level: INFO         
      - delay: !lambda "return id(duree_at_jour).state*3600*1000;"
      - switch.template.publish:
          id: ent_at_force
          state: OFF
      - logger.log:
          format: "Fin arret jour de : %.0f h"
          args: [ 'id(duree_at_jour).state' ]
          level: info 
  
  # Lance un test
  - platform: template
    name: "BP_Pour_Test"
    on_press:
      - script.execute: _test

binary_sensor:
  #Etat de la connection
  - platform: status
    name: "Status"

  # Pompe en fonctionnement
  # Remplacer le seuil (threshold) par du négatif pour simuler
  - platform: analog_threshold
    name: "Ppe_en_fonctionnement"
    id: ppe_filt_en_fonctionnement
    sensor_id: puissance
    threshold: ${pu_fonctionnement} # Défini dans Substitution en Watt
    on_press:
      - lambda: |-                
          std::string mess= "ESP178 Debut Filtration";
          id(_message_telegram)->execute(mess.c_str());           
    on_release: 
      - lambda: |-                
          std::string mess= "ESP178 Fin Filtration";
          id(_message_telegram)->execute(mess.c_str());    

  # Entrée logique permettant de lire le BP I00 de la carte
  - platform: gpio
    pin:
      number: GPIO00
      inverted: True
      mode:
        input: true
        pullup: true
    name: "bp1"

  # GPIO sur module extension SX1509
  - platform: gpio
    name: "E4"
    pin:
      sx1509: sx1509_hub1
      number: 3
      mode:
        input: true
        pullup: false
      inverted: false   
  - platform: gpio
    name: "E5"
    pin:
      sx1509: sx1509_hub1
      number: 4
      mode:
        input: true
        pullup: false
      inverted: false   
  - platform: gpio
    name: "E6"
    pin:
      sx1509: sx1509_hub1
      number: 5
      mode:
        input: true
        pullup: false
      inverted: false   

# Définiton des "Time"
datetime:
  - platform: template
    id: heure_pivot
    type: time
    name: "heure_pivot"
    optimistic: yes
    initial_value: "13:30:00"
    restore_value: true

  - platform: template
    id: h_debut
    type: time
    name: "h_debut"
    optimistic: yes
    initial_value: "00:00:00"
    restore_value: false
    
  - platform: template
    id: h_fin
    type: time
    name: "h_fin"
    optimistic: yes
    initial_value: "00:00:00"
    restore_value: false

  - platform: template
    id: duree_filtration
    type: time
    name: "duree_filtration"
    optimistic: yes
    initial_value: "00:00:00"
    restore_value: false

  - platform: template
    id: debut_mode_horaire
    type: time
    name: "debut_mode_horaire"
    optimistic: yes
    restore_value: true

  - platform: template
    id: duree_mode_horaire
    type: time
    name: "duree_mode_horaire"
    optimistic: yes
    restore_value: true

# Input Number
number:
  # Simulation Temp eau 
  - platform: template
    name: "simule_Temp"
    id: simul_temp_eau
    optimistic: true
    restore_value: true
    mode: box
    min_value: -10
    max_value: 50
    device_class: temperature
    step: 0.01 
    on_value: 
      then:
        - lambda: |-
            id(g_memoire_temp_eau)=id(temp_eau).state;     

  # Temps de recirculation avant prise en compte mesure de température
  - platform: template
    name: "tempo_recirculation"
    id: tempo_mesure_temp
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 900
    unit_of_measurement: "s"
    step: 1
    icon: mdi:clock

  # Coefficient de filtration
  - platform: template
    name: "coeff_Filtration"
    id: coeff_filtration
    optimistic: true
    restore_value: true
    mode: box
    min_value: 50
    max_value: 150
    unit_of_measurement: "%"
    step: 1
    icon: mdi:percent

  # Durée de l'arret sur la journée en lien avec "arret_jour"
  - platform: template
    name: "Durée-Arret_jour"
    id: duree_at_jour
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 24
    unit_of_measurement: "h"
    step: 0.01

sensor:
  - platform: homeassistant
    name: "temperature_exterieure"
    entity_id: "sensor.vp2_temp_out"
    id: temp_ext
  #    entity_id: "input_number.simule_temp_exterieur" sert à la simulation

  - platform: dallas_temp
    #address: 0x060321117ae89b28
    name: "temperature_eau"
    id: temp_eau
    device_class: temperature
    state_class: "measurement"     
    filters:
      - filter_out: 0.0
        # Etalonné le 26 Aout 2024  avec une PT100       
      #- calibrate_linear:
      #  - 0 -> 0
      #  - 26.6 -> 27.7

  # Calcul du temps de fonctionnement
  # Pompe piscine
  - platform: duty_time
    id: _temps_fonctionnement_ppe_piscine_jour
    name: 'temps_ma_ppe_piscine_jour'
    sensor: ppe_filt_en_fonctionnement
    restore: true
    filters: 
      - round: 0

# déclaration des "text_sensors"
text_sensor:
  # Affichage des heures de filtration dans Home Assistant
  - platform: template
    id: aff_heure_filtration
    name: "affich_heure_filtration"
    icon: mdi:timer

# Déclaration des switches: cde des relais
switch:
  - platform: gpio
    name: "cde_pompe_filtration"
    pin: GPIO32
    id: cde_ppe_filtration
    on_turn_on:
      then:
        - switch.turn_on: led14        
        - delay: !lambda "return id(tempo_mesure_temp).state*1000;" # Durée de fonctionnement de la pompe avant prise en compte de la température eau
        - logger.log:
            format: "Set tempo cde ppe"
            level: DEBUG
        - lambda: |-
            id(flag_tempo_ppe_filtre) = true;
    on_turn_off: 
      then:
        - logger.log:
            format: "Reset tempo cde ppe"
            level: DEBUG
        - lambda: |-
            id(flag_tempo_ppe_filtre) = false;
        - script.stop: _regul_ph
        - switch.turn_off: cde_ppe_ph_moins
        - script.stop: _regul_chlore
        - switch.turn_off: cde_ppe_chlore
        - switch.turn_off: led14     

  - platform: gpio
    name: "cde_eclairage"
    pin: GPIO26
    id: cde_eclairage

  - platform: gpio
    name: "relais8"
    pin: GPIO13
    id: relais8

  - platform: gpio
    name: "led14"
    id: led14
    pin:
      sx1509: sx1509_hub1
      number: 14
      mode:
        output: true
      inverted: false

  - platform: gpio
    name: "led15"
    id: led15
    pin:
      sx1509: sx1509_hub1
      number: 15
      mode:
        output: true
      inverted: false

  - platform: restart
    name: "Restart"

  # Switch Forçage Arret pompe filtration en mode Auto
  #
  - platform: template
    name: "ent_arret_forcé"
    id: ent_at_force
    optimistic: True
    lambda: |-
      if (id(ent_at_force).state) {
        return true;
      } else {
        return false;
      }
    on_turn_on: 
      then:
        - script.execute: _fonctionnement_filtration        
        - switch.turn_off: cde_ppe_filtration
        - logger.log: 
            format: "entrée_arret_forcé_ppe_filtration"
            level: DEBUG
        # RAZ de la durée de filtration
        - datetime.time.set:
            id: duree_filtration
            time: !lambda |-
              return {second: 0, minute: 0, hour: 0};                
        - lambda: |-
            std::string mess= "Ent At Forcé";
            id(aff_heure_filtration).publish_state(mess.c_str());
        # Message Telegram
        - lambda: |-                
            std::string mess= "ESP178 Debut Arret Forcé Filtration";
            id(_message_telegram)->execute(mess.c_str());                 

    on_turn_off: 
      then:
        # Message Telegram
        - lambda: |-                
            std::string mess= "ESP178 Fin arret Forcé Filtration";
            id(_message_telegram)->execute(mess.c_str());              
        - script.execute: _fonctionnement_filtration

# Gestion de l'afficheur 
display:
  - platform: lcd_pcf8574
    dimensions: 16x2
    address: 0x27
    update_interval: 10s
    lambda: |-
      it.printf(0,0,"Ph=%.2f",id(ph_ezo).state);
      it.printf(0,1,"ORP=%.2f",id(orp_ezo).state);
      it.printf(8,0,"P=%.3f",id(pression_filtre).state);
      it.printf(8,1,"T=%.1f",id(g_memoire_temp_eau));

#it.printf(15,1,"T=%.1s",id(mode_f).state);

# Déclenchement des scripts à intervalles réguliers
interval:
  - interval: 5s
    then:      
      - script.execute: _fonctionnement_filtration

  - interval: 5s
    then: 
      - script.execute: _memorisation_temperature_eau

  #- interval: 20s # Test
  #  then: 
  #    - script.execute: _test

# Déclaration des "Scripts"
script:
  # Script utilisé pour tester", peut etre supprimer si inutilise
  - id: _test
    then:
      - lambda: |-
          std::string mess = "ESP178 Temps Fonctionnement filtration";
          id(_message_telegram_v2)->execute(mess,id(_temps_fonctionnement_ppe_piscine_jour).state);
      - lambda: |-          
          std::string mess = "ESP178 Tps Fonct ppe pH";
          id(_message_telegram_v2)->execute(mess,id(_temps_fonctionnement_ppe_ph).state);
      - lambda: |-          
          std::string mess = "ESP178 Tps Fonct ppe Chl";
          id(_message_telegram_v2)->execute(mess,id(_temps_fonctionnement_ppe_chlore).state);

  - id: _testold
    then:
      - lambda: |-
          std::string mess = "ESP178 Rapport Journalier\n";

          CONVERT_SECONDS(id(_temps_fonctionnement_ppe_piscine_jour).state, id(g_hh), id(g_mm), id(g_ss));
          char buf1[32];
          snprintf(buf1, sizeof(buf1), "Tps Filtration: %02d:%02d:%02d\n", id(g_hh), id(g_mm), id(g_ss));
          mess += buf1;

          CONVERT_SECONDS(id(_temps_fonctionnement_ppe_ph).state, id(g_hh), id(g_mm), id(g_ss));
          snprintf(buf1, sizeof(buf1), "Tps Ppe pH: %02d:%02d:%02d\n", id(g_hh), id(g_mm), id(g_ss));
          mess += buf1;

          CONVERT_SECONDS(id(_temps_fonctionnement_ppe_chlore).state, id(g_hh), id(g_mm), id(g_ss));
          snprintf(buf1, sizeof(buf1), "Tps Ppe Chlore: %02d:%02d:%02d\n", id(g_hh), id(g_mm), id(g_ss));
          mess += buf1;

          id(_message_telegram)->execute(mess);
  # Si la pompe tourne depuis au moins "tempo_recirculation" on raffraichit la memoire de la temperature eau qui est
  # prise en compte dans les scripts.
  # sinon on travaille avec la témpérature mémorisée avant l'arret précédent
  - id: _memorisation_temperature_eau
    then:
      - if:
          condition:
            lambda: 'return id(flag_tempo_ppe_filtre) == true;'
          then:
            - lambda: |-
                id(g_memoire_temp_eau)=id(temp_eau).state;            
          else:
            - lambda: |-
                id(g_memoire_temp_eau)=id(g_memoire_temp_eau);
      - logger.log:
          format: "Flag Temp: %i / Temp Dallas: %.2f / Mem Temp: %.2f"
          args: [ 'id(flag_tempo_ppe_filtre)','id(temp_eau).state','id(g_memoire_temp_eau)' ]
          level: DEBUG

   # Calcul de la durée de la filtration en fonction du mode de fonctionnement selectionné
  - id: _fonctionnement_filtration
    then:
      - logger.log:
          format: "Switch at force 2: %i "
          args: [ 'id(ent_at_force).state' ]
          level: DEBUG

      # Entrée Arret Forcé par Binary_sensor "Arret force"
      - if:
          condition:
            - lambda: 'return id(ent_at_force).state == true;'
          then:
            - switch.turn_off: cde_ppe_filtration

      # Entrée Marche Forcée HG
      - if:
          condition:
            - lambda: 'return id(g_flag_hg) == true;'
          then:
            - switch.turn_on: cde_ppe_filtration
            - logger.log: 
                format: "Marche HG Ppe filtration"
                level: INFO
            - logger.log:
                format: "Flag HG: %i / Temp Ext: %.2f / S1: %.2f / S2: %.2f"
                args: [ 'id(g_flag_hg)','id(temp_ext).state','id(s1_temp_hg).state','id(s2_temp_hg).state' ]
                level: DEBUG                
            - lambda: |-
                std::string mess= "Ma HG";
                id(aff_heure_filtration).publish_state(mess.c_str());

            # RAZ de la durée de filtration
            - datetime.time.set:
                id: duree_filtration
                time: !lambda |-
                  return {second: 0, minute: 0, hour: 0};

      # Mode Arret forcé Input Select
      - if:
          condition:
              - lambda: 'return id(_Mode_Fonctionnement_filtration).state == "At_f";'
              - lambda: 'return id(ent_at_force).state == false;'
              - lambda: 'return id(g_flag_hg) == false;'
          then:
            - switch.turn_off: cde_ppe_filtration
            - logger.log: 
                format: "arret_forcé_ppe_filtration"
                level: DEBUG
            - lambda: |-
                std::string mess="At_force";
                id(aff_heure_filtration).publish_state(mess.c_str());

      # Mode Marche forcée Input Select
      - if:
          condition:
            - lambda: 'return id(_Mode_Fonctionnement_filtration).state == "Ma_f";'
            - lambda: 'return id(ent_at_force).state == false;'
            - lambda: 'return id(g_flag_hg) == false;'
          then:
            - switch.turn_on: cde_ppe_filtration
            - logger.log: 
                format: "Marche_forcée_ppe_filtration"
                level: DEBUG
            - lambda: |-
                std::string mess="Ma_force";
                id(aff_heure_filtration).publish_state(mess.c_str());                

      # Mode "Palier":
      # Si T°eau< Seuil Temp1 alors Durée = Tps paliers 1
      # Sinon
      # Si T°eau>= Seuil Temp1 et T°eau< Seuil Temp2 alors Durée = Tps paliers 2
      # Sinon
      # Si T°eau>= Seuil Temp2 et T°eau< Seuil Temp3 alors Durée = Tps paliers 3
      # Sinon
      # Si T°eau>= Seuil Temp3 et T°eau< Seuil Temp4 alors Durée = Tps paliers 4
      # Sinon
      # Durée = Tps paliers 5
      - if:
          condition:
            - lambda: 'return id(_Mode_Fonctionnement_filtration).state == "Palier";'
            - lambda: 'return id(ent_at_force).state == false;'
            - lambda: 'return id(g_flag_hg) == false;'
          then:
            - lambda: |-
                if (id(g_memoire_temp_eau)<id(g_temp_palier1)){
                  id(g_tps_filtration)=id(g_tps_palier1);
                } else {
                  if (id(g_memoire_temp_eau)>=id(g_temp_palier1) && (id(g_memoire_temp_eau)<id(g_temp_palier2))){
                    id(g_tps_filtration)=id(g_tps_palier2);
                  } else {
                    if (id(g_memoire_temp_eau)>=id(g_temp_palier2) && (id(g_memoire_temp_eau)<id(g_temp_palier3))){
                    id(g_tps_filtration)=id(g_tps_palier3);
                    } else {
                      if (id(g_memoire_temp_eau)>=id(g_temp_palier3) && (id(g_memoire_temp_eau)<id(g_temp_palier4))){
                      id(g_tps_filtration)=id(g_tps_palier4);
                      } else {
                        id(g_tps_filtration)=id(g_tps_palier5);
                      }
                    }
                  }
                }
                
                id(g_tps_filtration)=id(g_tps_filtration)*id(coeff_filtration).state/100;

            - logger.log:
                format: "Mode: Palier / Valeur Mem Temp: %.2f / Tps Filtrat: %2f"
                args: [ 'id(g_memoire_temp_eau)','id(g_tps_filtration)' ]
                level: DEBUG
            - script.execute: _calcul_hdebut_hfin
      
      # Mode "Classique":
      # La durée de filtration en h est égale à la température de l'eau divisée par 2
      - if:
          condition:
            - lambda: 'return id(_Mode_Fonctionnement_filtration).state == "Classique";'
            - lambda: 'return id(ent_at_force).state == false;'     
            - lambda: 'return id(g_flag_hg) == false;'       
          then:
            - lambda: |-
                id(g_tps_filtration)=id(g_memoire_temp_eau)/2;
                id(g_tps_filtration)=min(id(g_temps_max_filtration),id(g_tps_filtration));
                id(g_tps_filtration)=max(id(g_temps_min_filtration),id(g_tps_filtration));
                id(g_tps_filtration)=id(g_tps_filtration)*id(coeff_filtration).state/100;
            - logger.log:
                format: "Mode Classique / Valeur Mem Temp: %.2f / Tps Filtrat: %2f"
                args: [ 'id(g_memoire_temp_eau)','id(g_tps_filtration)' ]
                level: DEBUG
            - script.execute: _calcul_hdebut_hfin
      
      # Mode "Abacus"
      #
      - if:
          condition:
            - lambda: 'return id(_Mode_Fonctionnement_filtration).state == "Abaque";'
            - lambda: 'return id(ent_at_force).state == false;'    
            - lambda: 'return id(g_flag_hg) == false;'        
          then:
            - lambda: |-
                  id(g_tps_filtration)=id(g_abaque_a)*pow(id(g_memoire_temp_eau),3)+id(g_abaque_b)*pow(id(g_memoire_temp_eau),2)+id(g_abaque_c)*id(g_memoire_temp_eau)+id(g_abaque_d);
                  id(g_tps_filtration)=id(g_tps_filtration)*id(coeff_filtration).state/100;
                  id(g_tps_filtration)=min(id(g_temps_max_filtration),id(g_tps_filtration));
                  id(g_tps_filtration)=max(id(g_temps_min_filtration),id(g_tps_filtration));
            - logger.log:
                format: "Mode Abaque / Valeur Mem Temp: %.2f / Tps Filtrat: %2f"
                args: [ 'id(g_memoire_temp_eau)','id(g_tps_filtration)' ]
                level: DEBUG
            - script.execute: _calcul_hdebut_hfin

      # Mode "Horaire"
      # Débute à l'heure programmée pour une durée programmée
      # je m'en sers surtout l'hiver
      - if:
          condition:
            - lambda: 'return id(_Mode_Fonctionnement_filtration).state == "Horaire";'
            - lambda: 'return id(ent_at_force).state == false;'
            - lambda: 'return id(g_flag_hg) == false;'
          then:
            - logger.log:
                format: "Mode Abaque / Valeur Mem Temp: %.2f"
                args: [ 'id(g_memoire_temp_eau)' ]
                level: DEBUG

            - datetime.time.set:
                id: h_debut
                time: !lambda |-
                  return {second: 0, minute: id(debut_mode_horaire).minute, hour: id(debut_mode_horaire).hour};

            - datetime.time.set:
                id: h_fin
                time: !lambda |-
                  return {second: 0, minute: static_cast<uint8_t>(id(h_debut).minute+id(duree_mode_horaire).minute), hour: static_cast<uint8_t>(id(h_debut).hour+id(duree_mode_horaire).hour)};
                  // return {second: 0, minute: id(h_debut).minute+id(duree_mode_horaire).minute, hour: id(h_debut).hour+id(duree_mode_horaire).hour};
            - lambda: |-
                std::string mess= std::to_string(id(h_debut).hour)+":"+std::to_string(id(h_debut).minute)+"/"+std::to_string(id(h_fin).hour)+":"+std::to_string(id(h_fin).minute);
                id(aff_heure_filtration).publish_state(mess.c_str());
                
            - script.execute: _calcul_ma_at_ppe_filtration

  # Calcul l'heure de debut et fin de filtration en fonction la durée de filtration et de l'heure pivot
  # La variable: g_tps_filtration contient la durée en heure
  - id: _calcul_hdebut_hfin
    mode: single
    then:
      #RAZ des secondes de l'heure pivot
      - datetime.time.set:
          id: heure_pivot
          time: !lambda |-
            return {second: 0,minute: id(heure_pivot).minute, hour: id(heure_pivot).hour};

      - logger.log:
          format: "HPivot %2d:%.2d:%2d"
          args: [ 'id(heure_pivot).hour', 'id(heure_pivot).minute', 'id(heure_pivot).second' ]
          level: DEBUG

      # Heure de debut = Heure pivot converti en minutes - temps filtration converti en minutes
      - lambda: |-
          static double dt=0;
          static double hp=0;
          
          hp = id(heure_pivot).hour*60+id(heure_pivot).minute;
          dt = id(heure_pivot).hour*60+id(heure_pivot).minute-((id(g_tps_filtration)/2)*60);

          id(g_hh)=int(dt/60);
          id(g_mm)=dt-id(g_hh)*60;

      - logger.log:
          format: "H debut Filtration %2d: %.2d"
          args: [ 'id(g_hh)', 'id(g_mm)' ]
          level: DEBUG

      - datetime.time.set:
          id: h_debut
          time: !lambda |-
            return {second: 0, minute: static_cast<uint8_t>(id(g_mm)), hour: static_cast<uint8_t>(id(g_hh))};

      # Heure de fin = Heure pivot converti en minutes + temps filtration converti en minutes
      - lambda: |-
          static double dt=0;
          static double hp=0;
          
          hp = id(heure_pivot).hour*60+id(heure_pivot).minute;
          dt = id(heure_pivot).hour*60+id(heure_pivot).minute+((id(g_tps_filtration)/2)*60);

          id(g_hh)=int(dt/60);
          id(g_mm)=dt-id(g_hh)*60;

      - logger.log:
          format: "H debut Filtration %2d:%.2d"
          args: [ 'id(g_hh)', 'id(g_mm)' ]
          level: DEBUG

      - datetime.time.set:
          id: h_fin
          time: !lambda |-
            return {second: 0, minute: static_cast<uint8_t>(id(g_mm)), hour: static_cast<uint8_t>(id(g_hh))};

      # Convertion et affichage de la durée de filtration en hh:mm
      - lambda: |-
          CONVERT_SECONDS(id(g_tps_filtration)*3600, id(g_hh), id(g_mm), id(g_ss));     
      - logger.log:
          format: "Durée Filtration %2d: %.2d Tps Filtrat: %2f"
          args: [ 'id(g_hh)', 'id(g_mm)', 'id(g_tps_filtration)'  ]
          level: INFO     
      - datetime.time.set:
          id: duree_filtration
          time: !lambda |-
            return {second: 0, minute: static_cast<uint8_t>(id(g_mm)), hour: static_cast<uint8_t>(id(g_hh))};
      
      - lambda: |-
          std::string mess= std::to_string(id(h_debut).hour)+":"+std::to_string(id(h_debut).minute)+"/"+std::to_string(id(heure_pivot).hour)+":"+std::to_string(id(heure_pivot).minute)+"/"+std::to_string(id(h_fin).hour)+":"+std::to_string(id(h_fin).minute);
          id(aff_heure_filtration).publish_state(mess.c_str());          

      - script.execute: _calcul_ma_at_ppe_filtration

  # Calcul la sortie de commande la pompe de filtration en fonction de l'heure actuelle, de l'heure de début et de l'heure de fin
  - id: _calcul_ma_at_ppe_filtration
    mode: single
    then:
      - lambda: |-
          auto time = id(sntp_time).now();
      - logger.log:
          format: "H now: %.2d:%2d:%d"
          args: [ 'id(sntp_time).now().hour', 'id(sntp_time).now().minute', 'id(heure_pivot).second' ]
          level: DEBUG
      - logger.log:
          format: "HT: %.2d - HD:%2d - HF:%d"
          args: [ 'id(sntp_time).now().hour*60+id(sntp_time).now().minute', 'id(h_debut).hour*60+id(h_debut).minute', 'id(h_fin).hour*60+id(h_fin).minute' ]
          level: DEBUG
      - if:
          condition:
            time.has_time:
          else:
            - logger.log:
                format: "L'heure n'est ni initialisée, ni validée!"
                level: INFO
      - if:
          condition:
            - lambda: 'return (id(sntp_time).now().is_valid());'
            - lambda: 'return (id(sntp_time).now().hour*60+id(sntp_time).now().minute >= id(h_debut).hour*60+id(h_debut).minute && id(sntp_time).now().hour*60+id(sntp_time).now().minute < id(h_fin).hour*60+id(h_fin).minute);'
          then:
            - switch.turn_on: cde_ppe_filtration
          else:
            - switch.turn_off: cde_ppe_filtration
    
             
  # Envoi d'un message à Telegram via HA, celui ci doit etre operationnel
  # Message à construire au format String avant appel de ce script
  - id: _message_telegram
    parameters:
      mess1: string
    then:
      - lambda: |-
          std::string mess = id(sntp_time).now().strftime("%Y-%m-%d %H:%M:%S").c_str();
          mess += "\n";
          mess += mess1;
          id(telegram_msg_buffer) = mess;
      - homeassistant.service:
          service: notify.telegram
          data:
            message: !lambda 'return id(telegram_msg_buffer).c_str();'
      - lambda: |-
          std::string mess = id(sntp_time).now().strftime("%Y-%m-%d %H:%M:%S").c_str();
          mess += ",";
          mess += mess1;
          id(telegram_msg_buffer) = mess;
      - homeassistant.service:
          service: script.envoyer_message_log_esp178
          data:
            message: !lambda 'return id(telegram_msg_buffer).c_str();'

  - id: _message_telegram_v2
    parameters:
      mess1: string
      duree_sec: float
    then:
      - lambda: |-
          int h, m, s;
          // id(convert_seconds)(duree_sec, h, m, s);
          CONVERT_SECONDS(duree_sec, h, m, s);
          char buf[128];
          snprintf(buf, sizeof(buf), 
              "%s\n"
              "Tps: %02d:%02d:%02d\n", mess1.c_str(), h, m, s);

          std::string mess = id(sntp_time).now().strftime("%Y-%m-%d %H:%M:%S").c_str();
          mess += "\n";
          mess += buf;

          id(telegram_msg_buffer) = mess;
      - homeassistant.service:
          service: notify.telegram
          data:
            message: !lambda 'return id(telegram_msg_buffer).c_str();'
      - lambda: |-
          int h, m, s;
          CONVERT_SECONDS(duree_sec, h, m, s);

          char buf[128];
          snprintf(buf, sizeof(buf), "%s,%02d:%02d:%02d", mess1.c_str(), h, m, s);

          std::string mess = id(sntp_time).now().strftime("%Y-%m-%d %H:%M:%S").c_str();
          mess += ",";
          mess += buf;

          id(telegram_msg_buffer) = mess;
      - homeassistant.service:
          service: script.envoyer_message_log_esp178
          data:
            message: !lambda 'return id(telegram_msg_buffer).c_str();'

  - id: _log_message
    parameters:
      mess1: string
    then:
      - lambda: |-
          std::string mess = id(sntp_time).now().strftime("%Y-%m-%d %H:%M:%S").c_str();
          mess += ",";
          mess += mess1;
          id(telegram_msg_buffer) = mess;
      - homeassistant.service:
          service: script.envoyer_message_log_esp178
          data:
            message: !lambda 'return id(telegram_msg_buffer).c_str();'

Sous programme pH:

time:
  - platform: sntp
    timezone: Europe/Paris
    servers:
      - 0.pool.ntp.org
      - 1.pool.ntp.org
      - 2.pool.ntp.org
    on_time:
      # Déclenchement du script de regul pH deux fois par jour
      - seconds: 0
        minutes: 00
        hours: 12
        then:
          - script.execute: _regul_ph
      - seconds: 0
        minutes: 00
        hours: 16
        then:
          - script.execute: _regul_ph
      # reset le compteur de temps à minuit
      - seconds: 0
        minutes: 0
        hours: 0
        then:
          - sensor.duty_time.reset: _temps_fonctionnement_ppe_ph
          - number.set:
              id: conso_ph_jour
              value: 0
        # Notification du rapport journalier sur Telegram
      - seconds: 05
        minutes: 59
        hours: 23
        then:
          - lambda: |-
              std::string mess = "ESP178 Rapport Journalier pH\n";

              CONVERT_SECONDS(id(_temps_fonctionnement_ppe_ph).state, id(g_hh), id(g_mm), id(g_ss));
              char buf1[32];
              snprintf(buf1, sizeof(buf1), "Tps Ppe pH: %02d:%02d:%02d\n", id(g_hh), id(g_mm), id(g_ss));
              mess += buf1;

              id(_message_telegram)->execute(mess);

globals:
  - id: g_memoire_ph
    type: float
    restore_value: yes
    initial_value: '7.2'

  # temps d'injection ph
  - id: g_tps_injection_ph_moins
    type: float
    restore_value: no

# déclaration des modes de fonctionnement dans des "input select"
select:
  - platform: template
    name: "Mode_Fonctionnement_regul_ph"
    optimistic: true
    restore_value: true
    options:
      - Auto
      - Ma_f
      - At_f
    id: _Mode_Fonctionnement_regul_ph
    on_value: 
      then:
        - logger.log:
            format: "Mode Fonct Regul pH --> %s"
            args: [ 'id(_Mode_Fonctionnement_regul_ph).state.c_str()' ]
            level: INFO
        - if:
            condition:
              or:
                - lambda: 'return id(_Mode_Fonctionnement_regul_ph).state == "Ma_f";'
                - lambda: 'return id(_Mode_Fonctionnement_regul_ph).state == "At_f";'
            then:                    
              - script.stop: _regul_ph   
              - delay: 1s            
              - script.execute: _regul_ph       

button:
  - platform: template
    name: "BP_Cycle_Regul_pH"
    on_press:
      - script.execute: _regul_ph

datetime: 
  - platform: template
    id: duree_injection_ph
    type: time
    name: "duree_injection_pH"
    optimistic: yes
    restore_value: true

# Input Number
number:
  # Simulation Niveau pH
  - platform: template
    name: "simule_pH"
    id: simul_ph_ezo
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 10
    unit_of_measurement: "pH"
    step: 0.01

  # Cible Régulation pH
  - platform: template
    name: "pH_Cible"
    id: _ph_cible
    optimistic: true
    restore_value: true
    mode: box
    min_value: 6
    max_value: 7.6
    unit_of_measurement: "pH"
    step: 0.01

  # Hysterisis pH: S'additionne à la consigne dans la comparaison avec la cible
  - platform: template
    name: "pH_Hysteris"
    id: _ph_hysteresis
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 1
    unit_of_measurement: "pH"
    step: 0.01

  # Facteur de correction par cycle (fraction de la correction totale)
  - platform: template
    name: "facteur_correction_ph"
    id: facteur_correction_ph
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0.01
    max_value: 1.0
    unit_of_measurement: ""
    step: 0.01
    icon: mdi:percent

  # Debit Pompe pH moins
  # Etalonnage du 18/07/2024: 4.272 l/h
  # Etalonnage du 17/05/2025: 4.269 l/h
  - platform: template
    name: "debit_ppe_ph_moins"
    id: _debit_ppe_moins
    optimistic: true
    initial_value: 4.269
    mode: box
    min_value: 0.5
    max_value: 7.2
    unit_of_measurement: "l/h"
    step: 0.001

  # Durée marche pompe ph cycle
  - platform: template
    name: "durée_injection_phmoins"
    id: duree_inject_phmoins
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 120
    unit_of_measurement: "s"
    step: 1
    icon: mdi:clock

  # Comptabilise le volume de pH injecté
  - platform: template
    name: "conso_ph_total"
    id: conso_ph_total
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 100
    step: 0.001
    unit_of_measurement: "l"
    icon: mdi:ph

  - platform: template
    name: "conso_ph_jour"
    id: conso_ph_jour
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 100
    step: 0.001
    unit_of_measurement: "l"
    icon: mdi:ph

sensor:
  # Mesure du pH
  # Procédure étalonnage:
    # Mettre 1 s dans "update interval"
    # Mettre accuracy_decimals: 3
    # Decommenter le log
    # Commenter les 4 lignes calibrate_linear
    # Décommenter le LOGI du "on_value"  
    # ainsi on moyenne sur 15 s avec un affichage toutes les 5s 
    # 1-Faire une mesure avec solution étalon de 4.0
    # 2-Attendre 2 à 3 minutes que ca se stabilise
    # 3-relever la valeur du pH
    # 4-Toujours bien rincer la sonde à l'eau déminéralisée entre deux solutions
    # Refaire étapes 1 à 4 avec une solution étalon de 6.86
    # Puis avec une solution étalon de 9.18
    # Saisir les valeurs relevés dans "Calibrate_linear"
    # Décommenter les 4 lignes calibrate_linear    
    # Remettre 60 s dans "update interval"
    # Mettre accuracy_decimals: 2
    # Commenter le LOGI du "on_value"  
    # Compiler
  # Fin procédure étalonnage

  # Etalonnage 28 juin 2023          
  #      - 4.547 -> 4.0
  #      - 7.282 -> 6.86
  #      - 9.447 -> 9.18
  # Etalonnage 6 juillet 2022          
  #      - 4.44 -> 4.0
  #      - 7.17 -> 6.86
  #      - 9.41 -> 9.18
  # Etalonnage 20 juin 2024
  #      - 4.665 -> 4.0
  #      - 7.482 -> 6.86
  #      - 9.505 -> 9.18
  # Etalonnage 21 mai 2025
  #      - 4.76 -> 4.0
  #      - 7.50 -> 6.86
  #      - 9.49 -> 9.18
  - platform: ezo
    id: ph_ezo
    name: "ph_ezo"
    address: 99
    unit_of_measurement: "pH"
    accuracy_decimals: 2
    state_class: measurement
    update_interval: 60s
    filters:
      - sliding_window_moving_average:
          window_size: 15
          send_every: 1
          send_first_at: 1
      - calibrate_linear:
          - 4.76 -> 4.0
          - 7.50 -> 6.86
          - 9.49 -> 9.18
    on_value:
      then:
        - lambda: |-
            // ESP_LOGI("ph_ezo", "Mesure pH (étalonnage): %.2f", id(ph_ezo).state);

  # mémorise le temps d'injection calculé
  - platform: template
    name: "tps_injection_ph_moins"
    id: _tps_injection_ph_moins
    unit_of_measurement: "s"
    state_class: "measurement" 

  # Affiche le volume de pH moins à injecter
  - platform: template
    name: "vol_injection_ph_moins"
    id: _vol_injection_ph_moins
    unit_of_measurement: "l"
    state_class: "measurement" 
    
  # Ppe pH
  - platform: duty_time
    id: _temps_fonctionnement_ppe_ph
    name: 'temps_ma_ppe_ph'
    lambda: "return id(cde_ppe_ph_moins).state == true;"
    restore: true
    filters: 
      - round: 0

switch:
  - platform: gpio
    name: "cde_ppe_ph_moins"
    pin: GPIO33
    id: cde_ppe_ph_moins

interval:
  - interval: 5s
    then: 
      - script.execute: _memorisation_ph
      - script.execute: _securisation_regul_ph

script:
  # Si la pompe tourne depuis au moins "tempo_recirculation" on raffraichit la memoire du Ph qui est
  # prise en compte dans les scripts.
  # sinon on travaille avec le pH mémorisé avant l'arret précédent
  - id: _memorisation_ph
    then:
      - if:
          condition:
            lambda: 'return id(flag_tempo_ppe_filtre) == true;'
          then:
            - lambda: |-
                id(g_memoire_ph)=id(ph_ezo).state;            
          else:
            - lambda: |-
                id(g_memoire_ph)=id(g_memoire_ph);
      - if:
          condition:
            lambda: 'return isnan(id(ph_ezo).state);' # verifie si ph_ezo est valide
          then:
            - logger.log:
                format: "ph_EZO invalide: %.2f"
                args: [ 'id(ph_ezo).state']
                level: DEBUG
            - lambda: |-
                id(g_memoire_ph)=id(g_memoire_ph);
          else: 
            - logger.log:
                format: "ph_EZO Valide: %.2f"
                args: [ 'id(ph_ezo).state']
                level: DEBUG   

      - logger.log:
          format: "Flag Temp: %i / Ph EZO: %.2f / Mem Ph: %.2f"
          args: [ 'id(flag_tempo_ppe_filtre)','id(ph_ezo).state','id(g_memoire_ph)' ]
          level: DEBUG
  # Script principal pour la régulation du pH
  - id: _regul_ph
    mode: single
    then:
      - if:
          condition:
            - lambda: 'return id(_Mode_Fonctionnement_regul_ph).state == "Auto";'
          then:
            - logger.log:
                format: "Log mem ph: %f / pH Cible: %f"
                args: [ 'id(g_memoire_ph)','id(_ph_cible).state' ]
                level: debug
            - if:
                condition:
                  and:
                    - lambda: 'return id(g_memoire_ph) > (id(_ph_cible).state+id(_ph_hysteresis).state);'
                    - lambda: 'return id(g_memoire_ph) > 0;'
                    - switch.is_on: cde_ppe_filtration          
                then:
                  - lambda: |-
                      float ecart = abs(id(g_memoire_ph) - id(_ph_cible).state);
                          // Volume de la piscine (fixe à 43,2 m³)
                          float volume_piscine = 43.2;
                          // Quantité de référence : 0,2 l pour 0,1 unité de pH pour 10 m³
                          float quantite_ref_ph = 0.2;
                          // Calcul de la quantité nécessaire pour corriger l'écart total (en litres)
                          float quantite_necessaire = (ecart / 0.1) * quantite_ref_ph * (volume_piscine / 10.0);
                          // Conversion du débit de l/h en ml/s (1 l = 1000 ml, 1 h = 3600 s)
                          float debit_ml_s = (id(_debit_ppe_moins).state * 1000.0) / 3600.0;  // Exemple : 4,272 l/h = 1,187 ml/s
                          // Calcul de la durée nécessaire pour injecter la quantité (quantité en ml, débit en ml/s)
                          float duree = (quantite_necessaire * 1000.0) / debit_ml_s;
                          // Ajustement pour éviter des corrections trop brutales : on limite à une fraction par cycle
                          // Utilisation du facteur de correction défini par l'utilisateur
                          float facteur_correction = id(facteur_correction_ph).state;
                          duree = duree * facteur_correction;
                          // Limites de sécurité : entre 1 et 60 secondes
                          duree = std::max(1.0f, std::min(duree, 60.0f));
                          // Log pour débogage
                          ESP_LOGI("regul_ph", "Écart: %.2f, Quantité: %.2f ml, Débit: %.3f ml/s, Facteur: %.2f, Durée: %.2f s", ecart, quantite_necessaire * 1000.0, debit_ml_s, facteur_correction, duree);
                          id(g_tps_injection_ph_moins) = duree;

                else:
                  - lambda: |-
                      id(g_tps_injection_ph_moins)=0;

            - lambda: |-
                id(_tps_injection_ph_moins).publish_state(id(g_tps_injection_ph_moins));

            # Convertion et affichage de la durée d'injection pH en hh:mm
            - lambda: |-
                CONVERT_SECONDS(id(g_tps_injection_ph_moins), id(g_hh), id(g_mm), id(g_ss));                        
            - datetime.time.set:
                id: duree_injection_ph
                time: !lambda |-
                  return {second: static_cast<uint8_t>(id(g_ss)), minute: static_cast<uint8_t>(id(g_mm)), hour: static_cast<uint8_t>(id(g_hh))};

            - logger.log:
                format: "Log tps injection: %f"
                args: [ 'id(g_tps_injection_ph_moins)' ]
                level: DEBUG

            - if:
                condition:
                  and:
                    - lambda: 'return id(g_tps_injection_ph_moins) > 0;'
                then:
                  - lambda: |-
                      std::string mess = "ESP178 Debut injection pH\n";
                      mess += "Cible pH: " + std::to_string(id(_ph_cible).state) + "\n";
                      mess += "Mesure pH: " + std::to_string(id(g_memoire_ph));
                      id(_message_telegram_v2)->execute(mess,id(g_tps_injection_ph_moins));                      
                  - logger.log:
                      format: "Ph Mesure ph: %f / pH Cible: %f"
                      args: [ 'id(g_memoire_ph)','id(_ph_cible).state' ]
                      level: INFO                      
                  - switch.turn_on: cde_ppe_ph_moins
                  - logger.log: 
                      format: "Marche ppe Ph moins en Auto"
                      level: INFO        
                  - delay: !lambda "return id(g_tps_injection_ph_moins)*1000;"
                  - switch.turn_off: cde_ppe_ph_moins
                  - lambda: |-
                      std::string mess = "ESP178 Fin Injection pH";
                      id(_message_telegram_v2)->execute(mess,id(g_memoire_ph));
                  - logger.log: 
                      format: "Arret ppe Ph moins en Auto"
                      level: INFO
                  # Comptabilise le nombre de litres de pH injecté
                  - lambda: |-
                      float debit = id(_debit_ppe_moins).state;  // l/h
                      float duree = id(_tps_injection_ph_moins).state;  // s
                      float volume = debit * duree / 3600.0;  // conversion en litres
                      // Mise à jour des compteurs
                      id(conso_ph_jour).publish_state(id(conso_ph_jour).state + volume);
                      id(conso_ph_total).publish_state(id(conso_ph_total).state + volume);

                else:
                  - switch.turn_off: cde_ppe_ph_moins
                  - logger.log: 
                      format: "Arret ppe Ph moins en Auto"
                      level: INFO

      - if:
          condition:
            and:
              - lambda: 'return id(_Mode_Fonctionnement_regul_ph).state == "Ma_f";'
              - switch.is_on: cde_ppe_filtration
          then:
            - switch.turn_on: cde_ppe_ph_moins
            - logger.log: 
                format: "Marche Forcée ppe pH"
                level: INFO
      - if:
          condition:
            or:
              - lambda: 'return id(_Mode_Fonctionnement_regul_ph).state == "At_f";'
              - switch.is_off: cde_ppe_filtration
          then:
            - switch.turn_off: cde_ppe_ph_moins
            - logger.log: 
                format: "Arret Forcé ppe pH"
                level: INFO
  # Stoppe le Script de regule pH quand la mesure devient inferieure à la consigne
  # Cela évite de trop injecter de pH Moins
  - id: _securisation_regul_ph
    mode: single
    then:
      - if:
          condition:
            - lambda: 'return id(_Mode_Fonctionnement_regul_ph).state == "Auto";'
            - lambda: 'return id(g_memoire_ph) < id(_ph_cible).state;'
            - script.is_running: _regul_ph

          then:
            - logger.log:
                format: "Log mem ph: %f / pH Cible: %f"
                args: [ 'id(g_memoire_ph)','id(_ph_cible).state' ]
                level: DEBUG  
            - script.stop: _regul_ph                
            - switch.turn_off: cde_ppe_ph_moins
            - lambda: |-
                float Q = id(_temps_fonctionnement_ppe_ph).state * id(_debit_ppe_moins).state / 3600;
                std::string mess = "ESP178 Arret Sécurisé Script Injection pH\n";
                mess += "Cible pH: " + std::to_string(id(_ph_cible).state) + "\n";
                mess += "Mesure pH: " + std::to_string(id(g_memoire_ph)) + "\n";
                CONVERT_SECONDS(id(g_tps_injection_ph_moins), id(g_hh), id(g_mm), id(g_ss));
                char buf[64];
                snprintf(buf, sizeof(buf), "Tps Injec pH: %02d:%02d:%02d\n", id(g_hh), id(g_mm), id(g_ss));
                mess += buf;
                snprintf(buf, sizeof(buf), "Volume inj: %.2f L\n", Q);
                mess += buf;
                CONVERT_SECONDS(id(_temps_fonctionnement_ppe_ph).state, id(g_hh), id(g_mm), id(g_ss));
                snprintf(buf, sizeof(buf), "Tps Fonct Ppe pH: %02d:%02d:%02d\n", id(g_hh), id(g_mm), id(g_ss));
                mess += buf;
                id(_message_telegram)->execute(mess);               

Sous programme Chlore:

time:
  - platform: sntp
    id: sntp_time1  
    timezone: Europe/Paris
    servers:
     - 0.pool.ntp.org
     - 1.pool.ntp.org
     - 2.pool.ntp.org
    on_time:
      # Déclenchement du script Injection Clhore liquide 
      - seconds: 0
        minutes: 00
        hours: 10
        then:
          - script.execute: _regul_chlore
      # reset le compteur de temps à minuit
      - seconds: 0
        minutes: 0
        hours: 0
        then:
          - sensor.duty_time.reset: _temps_fonctionnement_ppe_chlore
          - number.set:
              id: conso_chlore_jour
              value: 0
      # Notification du rapport journalier sur Telegram
      - seconds: 10
        minutes: 59
        hours: 23
        then:
          - lambda: |-
              std::string mess = "ESP178 Rapport Journalier Chlore\n";
              CONVERT_SECONDS(id(_temps_fonctionnement_ppe_chlore).state, id(g_hh), id(g_mm), id(g_ss));
              char buf1[32];
              snprintf(buf1, sizeof(buf1), "Tps Ppe Chlore: %02d:%02d:%02d\n", id(g_hh), id(g_mm), id(g_ss));
              mess += buf1;
              id(_message_telegram)->execute(mess);

globals:
    # temps d'injection chlore
    - id: g_tps_injection_chlore
      type: float
      restore_value: no
      initial_value: '0'

select:
  - platform: template
    name: "Mode_Fonct_regul_chlore"
    optimistic: true
    restore_value: true
    options:
      - Auto
      - Ma_f
      - At_f
    id: _Mode_Fonctionnement_regul_chlore
    on_value: 
      then:
        - logger.log:
            format: "Mode Fonct Regul chlore --> %s"
            args: [ 'id(_Mode_Fonctionnement_regul_chlore).state.c_str()' ]
            level: INFO 
        - if:
            condition:
              or:
                - lambda: 'return id(_Mode_Fonctionnement_regul_chlore).state == "Ma_f";'
                - lambda: 'return id(_Mode_Fonctionnement_regul_chlore).state == "At_f";'   
            then:                
              - script.stop: _regul_chlore
              - delay: 1s            
              - script.execute: _regul_chlore
button:
  - platform: template
    name: "BP_Cycle_Regul_chlore"
    on_press:
      - script.execute: _regul_chlore
  - platform: template
    name: "BP_RAZ_Tps_Galet_Chlore"
    on_press:
      - sensor.duty_time.reset: _temps_galet_chlore

binary_sensor:
  # Etat galets Chlore
  # Si Tps > à temps max alors = True
  - platform: template
    name: "Etat_Galets_Chlore"
    id: _etat_galets_chlore
    lambda: |-
      return id(_temps_galet_chlore).state/3600 > id(_tps_max_galet_chlore).state;

datetime:
  - platform: template
    id: duree_injection_chlore
    type: time
    name: "duree_injection_chlore"
    optimistic: yes
    restore_value: true

number:
  # Cible Niveau Chlore
  - platform: template
    name: "chlore_Cible"
    id: _chlore_cible
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 10
    unit_of_measurement: "ppm"
    step: 0.01

  # Tps Max Galet Chlore
  - platform: template
    name: "tps_Max_galet_Chlore"
    id: _tps_max_galet_chlore
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 100
    unit_of_measurement: "h"
    step: 1

  # Debit Pompe chlore
  # Etalonnage du 17/05/2025: 4.957 l/h
  - platform: template
    name: "debit_ppe_chlore"
    id: _debit_ppe_chlore
    optimistic: true
    initial_value: 4.957
    mode: box
    min_value: 0.5
    max_value: 7.2
    unit_of_measurement: "l/h"
    step: 0.001

  # Comptabilise le volume de ch injecté
  - platform: template
    name: "conso_chlore_total"
    id: conso_chlore_total
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 100
    step: 0.001
    unit_of_measurement: "l"
    icon: mdi:chemical-weapon

  - platform: template
    name: "conso_chlore_jour"
    id: conso_chlore_jour
    optimistic: true
    restore_value: true
    mode: box
    min_value: 0
    max_value: 100
    step: 0.001
    unit_of_measurement: "l"
    icon: mdi:chemical-weapon 

sensor:
  # Mesure de l'ORP
  # Procédure étalonnage:
    # Mettre 1 s dans "update interval"
    # Mettre accuracy_decimals: 3
    # Decommenter le log
    # Commenter les 3 lignes calibrate_linear
    # Décommenter le LOGI du "on_value"  
    # ainsi on moyenne sur 15 s avec un affichage toutes les 5s 
    # 1-Faire une mesure avec solution étalon de 256mV
    # 2-Attendre 2 à 3 minutes que ca se stabilise
    # 3-relever la valeur de ORP
    # Décommenter les 3 lignes calibrate_linear    
    # Remettre 60 s dans "update interval"
    # Mettre accuracy_decimals: 2
    # Commenter le LOGI du "on_value"  
    # Compiler
  # Fin procédure étalonnage    
  - platform: ezo
    id: orp_ezo
    name: "orp_ezo"
    address: 98
    unit_of_measurement: "mV"
    accuracy_decimals: 2
    device_class: voltage
    state_class: measurement
    update_interval: 60s
    icon: mdi:chemical-weapon 
    # Filtrage en option (décommenter si besoin)
    filters:
      - sliding_window_moving_average:
          window_size: 15
          send_every: 1
          send_first_at: 1
      - calibrate_linear:
          - 150 -> 150
          - 256 -> 256
    on_value:
      then:
        - lambda: |-
            //ESP_LOGI("ORP_ezo", "Mesure ORP (étalonnage): %.2f", id(orp_ezo).state);

  # mémorise le temps d'injection calculé
  - platform: template
    name: "tps_injection_chlore"
    id: _tps_injection_chlore
    unit_of_measurement: "s"
    state_class: "measurement"

  - platform: duty_time
    id: _temps_galet_chlore
    name: 'temps_galet_chlore'
    sensor: ppe_filt_en_fonctionnement
    restore: true
    filters: 
      - round: 0
    
  # Ppe Chlore
  - platform: duty_time
    id: _temps_fonctionnement_ppe_chlore
    name: 'temps_ma_ppe_chlore'
    lambda: "return id(cde_ppe_chlore).state == true;"
    restore: true
    filters: 
      - round: 0
  # Estimation du taux de chlore
  - platform: template
    name: "Chlore estimé (ppm)"
    unit_of_measurement: "ppm"
    accuracy_decimals: 2
    update_interval: 60s
    icon: mdi:chemical-weapon 
    lambda: |-
      const float orp = id(orp_ezo).state;  // en mV
      const float ph = id(ph_ezo).state;    // pH de 0 à 14

      if (orp <= 0 || ph <= 0) {
        return NAN;
      }

      // Formule empirique basée sur un modèle simplifié
      // Source : adaptation de modèles de traitement d'eau et docs industrielles
      float ppm = 10 * exp((orp - 650.0) / 100.0) * pow(10, -(ph - 7.0));

      // Optionnel : borne les valeurs
      if (ppm < 0.0) ppm = 0.0;
      if (ppm > 5.0) ppm = 5.0;

      return ppm;


switch: 
  - platform: gpio
    name: "cde_ppe_chlore"
    pin: GPIO25
    id: cde_ppe_chlore

interval: 
  - interval: 1h # Test temps usage Galets Chlore
    then: 
      - script.execute: _fonction_galet_chlore

script: 
  # Injection chlore liquide
  # Calcul du temps d'injection
  - id: _regul_chlore
    mode: single
    then:
      - logger.log:
          format: "Script Injection Chlore"
          level: INFO      
      - if:
          condition:
            and:
              - switch.is_on: cde_ppe_filtration  
              - lambda: 'return id(_Mode_Fonctionnement_regul_chlore).state == "Auto";'        
          then:
            - lambda: |-
                static float nb = 0;
                static float mp = 96.0f;  // Concentration de chlore actif en g/L (9.6%)
                static float vb = 50000.0f;  // Volume de la piscine en L
                static float de = 0;
                static float q = 0;
                nb = id(_chlore_cible).state;  // Concentration cible en mg/L
                de = id(_debit_ppe_chlore).state;  // Débit en L/h
                q = (nb * vb) / (mp * 1000.0f);  // Quantité en L
                if (de > 0) {
                  id(g_tps_injection_chlore) = std::min((q / de) * 3600.0f, 3600.0f);  // Temps en secondes, limité à 1 heure
                } else {
                  id(g_tps_injection_chlore) = 0;
                }
                id(_tps_injection_chlore).publish_state(id(g_tps_injection_chlore));
                ESP_LOGI("Dosage Chlore", "Quantité: %.2f L, Temps: %.2f s", q, id(g_tps_injection_chlore));
          else:
            - lambda: |-
                id(g_tps_injection_chlore)=0;
                id(_tps_injection_chlore).publish_state(0);
            # Convertion et affichage de la durée d'injection pH en hh:mm
      - lambda: |-    
          CONVERT_SECONDS(id(g_tps_injection_chlore), id(g_hh), id(g_mm), id(g_ss));          
      - datetime.time.set:
          id: duree_injection_chlore
          time: !lambda |-
            return {second: static_cast<uint8_t>(id(g_ss)), minute: static_cast<uint8_t>(id(g_mm)), hour: static_cast<uint8_t>(id(g_hh))};
          
      - if:
          condition:
            - lambda: 'return id(_Mode_Fonctionnement_regul_chlore).state == "Auto";'
          then:
            - if:
                condition:
                  and:
                    - lambda: 'return id(_tps_injection_chlore).state > 0;'
                    - switch.is_on: cde_ppe_filtration
                then:
                  - switch.turn_on: cde_ppe_chlore
                  - logger.log: 
                      format: "Marche ppe Ph Chlore en Auto"
                      level: INFO
                  - lambda: |-
                      // Affichage du temps d'injection Ppe Chlore
                      std::string mess = "ESP178 Temps inj Chlore";
                      id(_message_telegram_v2)->execute(mess,id(g_tps_injection_chlore));

                  - delay: !lambda "return id(g_tps_injection_chlore)*1000;"
                  - switch.turn_off: cde_ppe_chlore
                  - logger.log: 
                      format: "Arret ppe chlore en Auto"
                      level: INFO
                  - lambda: |-
                      std::string mess = "Fin Injection Chlore en Auto";                                         
                      id(_message_telegram)->execute(mess.c_str());                      
                  # Comptabilise le volume de ch injecté
                  - lambda: |-
                      float debit = id(_debit_ppe_chlore).state;  // l/h
                      float duree = id(_tps_injection_chlore).state;  // s
                      float volume = debit * duree / 3600.0;  // conversion en litres
                      // Mise à jour des compteurs
                      id(conso_chlore_jour).publish_state(id(conso_chlore_jour).state + volume);
                      id(conso_chlore_total).publish_state(id(conso_chlore_total).state + volume);
    
                else:
                  - switch.turn_off: cde_ppe_chlore
                  - lambda: |-
                      id(_tps_injection_chlore).publish_state(0);
                  - logger.log: 
                      format: "Arret ppe chlore en Auto"
                      level: INFO

      - if:
          condition:
            and:
              - lambda: 'return id(_Mode_Fonctionnement_regul_chlore).state == "Ma_f";'
              - switch.is_on: cde_ppe_filtration
          then:
            - switch.turn_on: cde_ppe_chlore
            - logger.log: 
                format: "Marche Forcée ppe Chlore"
                level: INFO
      - if:
          condition:
            or:
              - lambda: 'return id(_Mode_Fonctionnement_regul_chlore).state == "At_f";'
              - switch.is_off: cde_ppe_filtration
          then:
            - switch.turn_off: cde_ppe_chlore
            - logger.log: 
                format: "Arret Forcé ppe Chlore"
                level: INFO

  # Log état galets Chlore en heure
  - id: _fonction_galet_chlore
    then:
      - if:
          condition:
            and:
              - lambda: 'return id(_Mode_Fonctionnement_regul_chlore).state == "Auto";'
              - lambda: 'return id(_etat_galets_chlore).state == true;'
          then:
            - logger.log: 
                format: "Galets Chlore Dépassé"
                level: INFO  
            - lambda: |-
                std::string mess = "ESP178 Tps Utilisation Galets Chlore Atteint";
                id(_message_telegram)->execute(mess);

          else:
            - lambda: 
                id(_etat_galets_chlore).publish_state(false);

Sous programme Volet:

globals:
  - id: action_ouv_executee
    type: bool
    restore_value: false
    initial_value: 'false'
  - id: action_ferm_executee
    type: bool
    restore_value: false
    initial_value: 'false'

select:
  - platform: template
    name: "Mode_Fonctionnement_volet"
    optimistic: true
    restore_value: true
    options:
      - Auto
      - Horaire
      - At_f
    id: _Mode_Fonctionnement_volet
    on_value: 
      then:
        - logger.log:
            format: "Mode Fonct Volet --> %s"
            args: [ 'id(_Mode_Fonctionnement_volet).state.c_str()' ]
            level: INFO

binary_sensor: 
  # GPIO sur module extension SX1509

  - platform: gpio
    name: "volet_ferme"
    id: volet_ferme
    pin:
      sx1509: sx1509_hub1
      # Use pin number 0 on the SX1509
      number: 0
      mode:
        input: true
        pullup: false
      inverted: false
    filters:
      - delayed_on_off: 500ms

datetime:
  - platform: template
    id: h_ouv_volet
    type: time
    name: "h_ouv_volet"
    optimistic: yes
    restore_value: true
    
  - platform: template
    id: h_ferm_volet
    type: time
    name: "h_ferm_volet"
    optimistic: yes
    restore_value: true

# Déclaration Volet piscine
cover:
  - platform: template
    name: "volet_piscine"
    lambda: |-
      if (id(volet_ferme).state) {
        return COVER_CLOSED;
      } else {
        return COVER_OPEN;
      }
    open_action:
      - script.execute: script_ouv_volet
    close_action:
      - script.execute: script_ferm_volet
    stop_action:
      - script.execute: script_stop_volet
    optimistic: true

switch:
  - platform: gpio
    name: "cde_volet_ouverture"
    pin: GPIO27
    id: cde_volet_ouverture
    interlock: [cde_volet_fermeture]
    on_turn_on:
      then:
        - delay: 120s
        - script.execute: _regul_eau

  - platform: gpio
    name: "cde_volet_fermeture"
    pin: GPIO14
    id: cde_volet_fermeture
    interlock: [cde_volet_ouverture]

interval:
  - interval: 1s
    then:
      - script.execute: _calcul_cde_volet

script: 
  # Scripts Commande Volet
  # En mode "Horaire" La commande ouverture & fermeture volet est asservie à l'heure d'ouverture et de l'heure de fermeture du volet
  # En mode "Auto": La commande ouverture volet est asservi à l'heure de debut ma pompe en modes Palier/Classique/Horaire/Abaque
  #               : La commande fermeture volet est asservi à l'heure de fermeture du volet
  - id: _calcul_cde_volet
    mode: single
    then:
      - lambda: |-
          auto time = id(sntp_time).now();
      - logger.log:
          format: "H now: %.2d:%2d:%d"
          args: [ 'id(sntp_time).now().hour', 'id(sntp_time).now().minute', 'id(heure_pivot).second' ]
          level: DEBUG
      - logger.log:
          format: "HT: %.2d - HD:%2d - HF:%d"
          args: [ 'id(sntp_time).now().hour*60+id(sntp_time).now().minute', 'id(h_ouv_volet).hour*60+id(h_ouv_volet).minute', 'id(h_ferm_volet).hour*60+id(h_ferm_volet).minute' ]
          level: DEBUG
      - if:
          condition:
            time.has_time:
          else:
            - logger.log:
                format: "L'heure n'est ni initialisée, ni validée!"
                level: INFO
      # En mode Horaire, l'heure ouverture volet est calée sur le l'heure d(ouverture volet
      # le drapeau id(action_ouv_executee) est mis à un à la premiere commande d'ouverture et RAZ à minuit
      - if:
          condition:
            - lambda: 'return (id(sntp_time).now().is_valid());'
            - lambda: 'return (id(sntp_time).now().hour*60+id(sntp_time).now().minute == id(h_ouv_volet).hour*60+id(h_ouv_volet).minute);'
            - lambda: 'return id(_Mode_Fonctionnement_volet).state == "Horaire";'
            - lambda: 'return !id(action_ouv_executee);'
          then:
            - script.execute: script_ouv_volet
            - lambda: 'id(action_ouv_executee) = true;'
      - if:
          condition:
            - lambda: 'return (id(sntp_time).now().is_valid());'
            - lambda: 'return (id(sntp_time).now().hour*60+id(sntp_time).now().minute == id(h_ferm_volet).hour*60+id(h_ferm_volet).minute);'
            - lambda: 'return id(_Mode_Fonctionnement_volet).state == "Horaire";'
            - lambda: 'return !id(action_ferm_executee);'
          then:
            - script.execute: script_ferm_volet
            - lambda: 'id(action_ferm_executee) = true;'

      # En mode Auto, l'heure ouverture volet est calée sur le l'heure de debut filtration
      # le drapeau id(action_ouv_executee) est mis à un à la premiere commande d'ouverture et RAZ à minuit
      - if:
          condition:
            - lambda: 'return (id(sntp_time).now().is_valid());'
            - lambda: 'return (id(sntp_time).now().hour*60+id(sntp_time).now().minute == id(h_debut).hour*60+id(h_debut).minute );'
            - lambda: 'return id(_Mode_Fonctionnement_volet).state == "Auto";'
            - lambda: 'return !id(action_ouv_executee);'
            - or:
              - lambda: 'return id(_Mode_Fonctionnement_filtration).state == "Horaire";'
              - lambda: 'return id(_Mode_Fonctionnement_filtration).state == "Palier";'
              - lambda: 'return id(_Mode_Fonctionnement_filtration).state == "Classique";'
              - lambda: 'return id(_Mode_Fonctionnement_filtration).state == "Abaque";'
          then:
            - script.execute: script_ouv_volet
            - lambda: 'id(action_ouv_executee) = true;'

      # En mode Auto, l'heure fermeturee volet est calée sur le l'heure de fermeture Volet
      # le drapeau id(action_ferm_executee) est mis à un à la premiere commande de fermeture et RAZ à minuit
      - if:
          condition:
            - lambda: 'return (id(sntp_time).now().is_valid());'
            - lambda: 'return (id(sntp_time).now().hour*60+id(sntp_time).now().minute == id(h_ferm_volet).hour*60+id(h_ferm_volet).minute);'
            - lambda: 'return id(_Mode_Fonctionnement_volet).state == "Auto";'
            - lambda: 'return !id(action_ferm_executee);'
          then:
            - script.execute: script_ferm_volet
            - lambda: 'id(action_ferm_executee) = true;'

      # Remise à zéro des drapeaux à minuit
      - lambda: |-
          if (id(sntp_time).now().hour == 0 && id(sntp_time).now().minute == 0) {
            id(action_ouv_executee) = false;
            id(action_ferm_executee) = false;
          }                  

  - id: script_ouv_volet
    mode: single
    then:
      - switch.turn_off: cde_volet_fermeture
      - delay: 2s
      - switch.turn_on: cde_volet_ouverture
      - delay: 5s
      - switch.turn_off: cde_volet_ouverture
          
  - id: script_ferm_volet
    mode: single
    then:
      - switch.turn_off: cde_volet_ouverture
      - delay: 2s
      - switch.turn_on: cde_volet_fermeture
      - switch.turn_on: cde_eclairage
      - delay: 90s
      - switch.turn_off: cde_volet_fermeture
      - switch.turn_off: cde_eclairage
                    
  - id: script_stop_volet
    mode: single
    then:
      - switch.turn_off: cde_volet_ouverture
      - switch.turn_off: cde_volet_fermeture
      - delay: 2s
      - switch.turn_on: cde_volet_fermeture
      - delay: 2s
      - switch.turn_off: cde_volet_fermeture
      - switch.turn_off: cde_eclairage

Sous programme Appoint Eau:

time:
  - platform: sntp
    timezone: Europe/Paris
    servers:
      - 0.pool.ntp.org
      - 1.pool.ntp.org
      - 2.pool.ntp.org

    on_time:
      # reset le compteur de temps à minuit
      - seconds: 0
        minutes: 0
        hours: 0
        then:
          - sensor.duty_time.reset: _temps_fonctionnement_ev_eau

select:
  - platform: template
    name: "Mode_Fonct_appoint_eau"
    optimistic: true
    restore_value: true
    options:
      - Auto
      - Ma_f
      - At_f
    id: _Mode_Fonctionnement_regul_eau
    on_value: 
      then:
        - logger.log:
            format: "Mode Fonct Regul Eau --> %s"
            args: [ 'id(_Mode_Fonctionnement_regul_eau).state.c_str()' ]
            level: INFO        
        - script.stop: _regul_eau
        - delay: 1s            
        - script.execute: _regul_eau

button:
  # Lance un Appoint d'Eau
  - platform: template
    name: "BP_Appoint Eau"
    on_press:
      - script.execute: _regul_eau

binary_sensor: 
  # calcul des niveaux piscine
  # Si LSL ou LSH recouvert alors True sinon False
  - platform: template
    name: "niv_haut"
    id: niv_haut
      
  - platform: template
    name: "niv_inter"
    id: niv_inter

  - platform: template
    name: "niv_bas"
    id: niv_bas

  - platform: template
    name: "niv_defaut"
    id: niv_defaut

  # GPIO sur module extension SX1509
  # Niveau haut à 1 si decouvert
  - platform: gpio
    name: "tp_plein_lsh"
    id: lsh
    pin:
      sx1509: sx1509_hub1
      number: 1
      mode:
        input: true
        pullup: false
      inverted: true
    filters:
      - delayed_on_off: 5s
    on_press:
      then:
        - script.stop: _regul_eau
        - switch.turn_off: cde_ev_eau

  # Niveau bas à 1 si decouvert
  - platform: gpio
    name: "tp_plein_lsl"
    id: lsl
    pin:
      sx1509: sx1509_hub1
      number: 2
      mode:
        input: true
        pullup: false
      inverted: true
    filters:
      - delayed_on_off: 5s

sensor:
  # EV Eau
  - platform: duty_time
    id: _temps_fonctionnement_ev_eau
    name: 'temps_ma_ev_eau'
    lambda: "return id(cde_ev_eau).state == true;"
    restore: true
    filters: 
      - round: 0

switch:
  - platform: gpio
    name: "cde_ev_eau"
    pin: GPIO12
    id: cde_ev_eau

interval:
  - interval: 5s
    then:      
      - script.execute: _calcul_niveau_eau
  - interval: 60s
    then:
      - lambda: |-
          // Reset le compteur à 00:00:00      
          auto now = id(sntp_time).now();
          if (now.hour == 0 && now.minute == 0 && now.second == 0) {
            id(_temps_fonctionnement_ev_eau).reset();
          }          

script: 
  # Regulation du niveau d'eau piscine
  # Declenché par BP Appoint Eau ou 120s apres la cde ouverture volet apres 120s
  - id: _regul_eau
    mode: single  
    then:
      - if:
          condition:
            and:
              - lambda: 'return id(_Mode_Fonctionnement_regul_eau).state == "Auto";'
              - binary_sensor.is_off: niv_defaut
              - binary_sensor.is_off: volet_ferme
              - or:
                - binary_sensor.is_on: niv_bas
                - binary_sensor.is_on: niv_inter
          then:
            - logger.log: 
                format: "Ouverture vanne eau Mode Auto"
                level: INFO
            - switch.turn_on: cde_ev_eau
            - delay: 15min
            - logger.log: 
                format: "Fermeture vanne eau sur Timeout 15 min"
                level: WARN
            - switch.turn_off: cde_ev_eau
      - if:
          condition:
            and:
              - lambda: 'return id(_Mode_Fonctionnement_regul_eau).state == "Auto";'
              - or:
                - binary_sensor.is_on: niv_haut
                - binary_sensor.is_on: niv_defaut
          then:
            - logger.log: 
                format: "Fermeture vanne eau Mode Auto"
                level: INFO
            - switch.turn_off: cde_ev_eau

      - if:
          condition:
            and:
              - lambda: 'return id(_Mode_Fonctionnement_regul_eau).state == "Ma_f";'
          then: 
            - logger.log: 
                format: "Ouverture vanne eau Mode Ma Forçé"
                level: DEBUG
            - switch.turn_on: cde_ev_eau

      - if:
          condition:
            or:
              - lambda: 'return id(_Mode_Fonctionnement_regul_eau).state == "At_f";'
          then:
            - logger.log: 
                format: "Fermeture vanne eau Mode At Forçé"
                level: DEBUG
            - switch.turn_off: cde_ev_eau

  # Calcul des niveaux d'eau en fonction des sondes de niveaux
  # si niveau haut et niveau bas => niveau haut
  - id: _calcul_niveau_eau
    then:
      - if: 
          condition:
            and:
              - binary_sensor.is_on: lsh
              - binary_sensor.is_on: lsl
          
          then:
            - binary_sensor.template.publish:
                id: niv_haut
                state: ON
          else:
            - binary_sensor.template.publish:
                id: niv_haut
                state: OFF
      # si pas niveau haut et niveau bas => defaut intermédiaire
      - if: 
          condition:
            and:
              - binary_sensor.is_off: lsh
              - binary_sensor.is_on: lsl
          
          then:
            - binary_sensor.template.publish:
                id: niv_inter
                state: ON
          else:
            - binary_sensor.template.publish:
                id: niv_inter
                state: OFF     
      # si pas niveau haut et pas niveau bas => niveau bas
      - if: 
          condition:
            and:
              - binary_sensor.is_off: lsh
              - binary_sensor.is_off: lsl
          
          then:
            - binary_sensor.template.publish:
                id: niv_bas
                state: ON
          else:
            - binary_sensor.template.publish:
                id: niv_bas
                state: OFF                
      # si niveau haut et pas niveau bas => defaut niveau
      - if: 
          condition:
            and:
              - binary_sensor.is_on: lsh
              - binary_sensor.is_off: lsl
          then:
            - binary_sensor.template.publish:
                id: niv_defaut
                state: ON
          else:
            - binary_sensor.template.publish:
                id: niv_defaut
                state: OFF

Sous programme Hors gel:

globals:
    # flag marche Hors gel activé
    - id: g_flag_hg
      type: bool
      restore_value: yes

select:
  # défini l'activation du mode Hors Gel
  - platform: template
    name: "Mode_Hors_Gel"
    optimistic: true
    restore_value: true
    options:
      - Desactivé
      - Activé
    id: _Mode_Fonctionnement_hg
    on_value: 
      then:
        - logger.log:
            format: "Mode Fonct Hors Gel --> %s"
            args: [ 'id(_Mode_Fonctionnement_hg).state.c_str()' ]
            level: INFO
number:
  # Seuil 1 Temp Hors gel
  - platform: template
    name: "Seuil1_Temp_HG"
    id: s1_temp_hg
    optimistic: true
    restore_value: true
    mode: box
    min_value: -5
    max_value: 0
    device_class: temperature
    step: 0.1

  # Seuil 2 Temp Hors gel
  - platform: template
    name: "Seuil2_Temp_HG"
    id: s2_temp_hg
    optimistic: true
    restore_value: true
    mode: box
    min_value: -10
    max_value: 0
    device_class: temperature
    step: 0.1

interval:
  - interval: 900s # Test HG toutes les 15 mn (900s)
    then: 
      - script.execute: _fonction_hors_gel

script: 
# Fonctionnement Hors Gel
# Si temp extérieure inferieur à seuil1 et supérieur à seuil2 alors Ma pompe Filtration pendant 15 mn
# Si temp extérieure inferieur à seuil2 alors Ma pompe Filtration pendant 30 mn
  - id: _fonction_hors_gel
    then:
      # Reset flag HG si temp >S1
      - if:
          condition:
            and:
              - lambda: 'return id(_Mode_Fonctionnement_hg).state == "Activé";'
              - lambda: 'return id(temp_ext).state > id(s1_temp_hg).state;'
          then:
            - lambda: |-
                id(g_flag_hg) = false;
            - logger.log: 
                format: "Reset Flag HG"
                level: INFO
       
      # Activation si S1 >temp >S2
      - if:
          condition:
            and:
              - lambda: 'return id(_Mode_Fonctionnement_hg).state == "Activé";'
              - lambda: 'return id(temp_ext).state < id(s1_temp_hg).state;'
              - lambda: 'return id(temp_ext).state > id(s2_temp_hg).state;'
              - lambda: 'return id(g_flag_hg) == false;'
          then:
            - lambda: |-
                id(g_flag_hg) = true;
            - logger.log: 
                format: "Set Flag HG Seuil1"
                level: INFO
            - lambda: |-                
                std::string mess = "ESP178 Debut Marche HG Seuil1\n";
                mess += "Temp Ext: " + std::to_string(id(temp_ext).state) + "\n";
                mess += "Seuil1: "   + std::to_string(id(s1_temp_hg).state) + "\n";
                id(_message_telegram)->execute(mess);                 
            - delay: 900s  #900s
            - lambda: |-
                id(g_flag_hg) = false;
            - logger.log: 
                format: "Reset Flag HG Seuil1"
                level: INFO     
            - lambda: |-                
                std::string mess= "ESP178 Fin Marche HG Seuil1";
                mess += "Temp Ext: "+ std::to_string(id(temp_ext).state)+"\n";
                mess += "Seuil1: "+ std::to_string(id(s1_temp_hg).state)+"\n";
                id(_message_telegram)->execute(mess.c_str());                               
      # Activation si temp > S2
      - if:
          condition:
            and:
              - lambda: 'return id(_Mode_Fonctionnement_hg).state == "Activé";'
              - lambda: 'return id(temp_ext).state < id(s1_temp_hg).state;'
              - lambda: 'return id(temp_ext).state < id(s2_temp_hg).state;'
              - lambda: 'return id(g_flag_hg) == false;'
          then:
            - lambda: |-
                id(g_flag_hg) = true;
            - logger.log: 
                format: "Set Flag HG Seuil2"
                level: INFO
            - lambda: |-                
                std::string mess= "ESP178 Debut Marche HG Seuil2";
                mess += "Temp Ext: "+ std::to_string(id(temp_ext).state)+"\n";
                mess += "Seuil2: "+ std::to_string(id(s2_temp_hg).state)+"\n";
                id(_message_telegram)->execute(mess.c_str());                   
            - delay: 1800s  #1800s
            - lambda: |-
                id(g_flag_hg) = false;
            - logger.log: 
                format: "Reset Flag HG Seuil2"
                level: INFO      
            - lambda: |-                
                std::string mess= "ESP178 Fin Marche HG Seuil2";
                mess += "Temp Ext: "+ std::to_string(id(temp_ext).state)+"\n";
                mess += "Seuil2: "+ std::to_string(id(s2_temp_hg).state)+"\n";
                id(_message_telegram)->execute(mess.c_str());   

Sous programme Mesure électrique:

    # mesure grandeurs electriques avec un PZEM-004T-100A

# Configuration UART
uart:
  rx_pin: GPIO3 #rx
  tx_pin: GPIO1 #tx
  baud_rate: 9600

# modbus necessaire au PZEM  
modbus:

sensor:
  - platform: pzemac
    update_interval: 30s
    current:
      name: "pzem_pisc_courant"
      unit_of_measurement: "A"
    voltage:
      name: "pzem_pisc_tension"
      unit_of_measurement: "V"
    energy:
      name: "pzem_pisc_energy"
      unit_of_measurement: "kWh"
      filters:
        - multiply: 0.001
    power:
      name: "pzem_pisc_puissance"
      unit_of_measurement: "W"
      id: puissance

Sous Programme Mesure de pression:


ads1115:
  - address: 0x48

binary_sensor:
  # Etat pression filtre
  # Si pression superieure à p_Max alors = True
  - platform: analog_threshold
    name: "Etat_pression_Filtre"
    sensor_id: pression_filtre    
    id: _etat_pression_filtre
    threshold: ${pression_max} # Défini dans Substitution en bar
    on_press:
        - lambda: |-                
            std::string mess= "ESP178 Seuil Pression Filtre Atteint";
            id(_message_telegram)->execute(mess.c_str());     
    on_release: 
        - lambda: |-                
            std::string mess= "ESP178 Pression filtre OK";
            id(_message_telegram)->execute(mess.c_str());   

sensor:
    # Mesure de la pression filtre Entrée ANA 3
  - platform: ads1115
    multiplexer: 'A3_GND'
    gain: 6.144
    name: "Pression_filtre"          
    update_interval: 10s
    unit_of_measurement: "bar"
    device_class: pressure
    state_class: "measurement"        
    id: pression_filtre
    filters:
    - calibrate_linear:
        - 0.561 -> 0.0
        - 2.213 -> 0.81
    # moyenne sur 30 mn + affichage toutes les 2 mn
    - sliding_window_moving_average:
        window_size: 30
        send_every: 2
        send_first_at: 1

Intégration dans Home Assistant

L’ESP178 s’intègre via l’API ESPHome. Dans HA, j’ai créé un dashboard avec :

  • Graphiques : pH, température, pression, puissance.
  • Réglages : consignes pH/chlore, modes filtration, seuils hors gel.
  • Notifications Telegram via un text_sensor dédié.

Notification avec Telegram

N’ayant pas trouver de programme de notification « Télégram » fonctionel dans ESP Home, j’utilise ma notification telegram fonctionnelle dans HA. Un automatisme surveille un changement de valeur dans le « sensor.esp178_message_notif_telegram » et notifie en cas de chanement de valeur.

alias: Notification message Telegram de ESP178
description: " Notifie sur Telegram le message mis à jour par l'ESP178 Piscine"
mode: single
triggers:
  - entity_id:
      - sensor.esp178_message_notif_telegram
    for:
      hours: 0
      minutes: 0
      seconds: 0
    trigger: state
conditions:
  - condition: not
    conditions:
      - condition: or
        conditions:
          - condition: state
            entity_id: sensor.esp178_message_notif_telegram
            state: unavailable
          - condition: state
            entity_id: sensor.esp178_message_notif_telegram
            state: unknown
actions:
  - data:
      message: "{{states('sensor.esp178_message_notif_telegram')}}"
      title: Message ESP178!!!
    action: notify.telegram

Exemple de carte HA

type: custom:pool-monitor-card
display:
  compact: false
  show_names: true
  show_labels: true
  show_last_updated: false
  show_icons: true
  show_units: true
  gradient: true
  language: fr
colors:
  normal_color: "#00b894"
  low_color: "#fdcb6e"
  warn_color: "#e17055"
  cool_color: "#00BFFF"
  marker_color: "#000000"
  hi_low_color: "#00000099"
sensors:
  temperature:
    - entity: sensor.esp178_temperature_eau
      setpoint: 30
      step: 3
  ph:
    - entity: sensor.esp178_ph_ezo
      setpoint: 7.2
      step: 0.2
  free_chlorine:
    - entity: sensor.my_sampling_point_pl_chlorine_free
      setpoint: 2
      chlorine_step: 1
  total_chlorine:
    - entity: sensor.my_sampling_point_pl_chlorine_total
      setpoint: 3
      step: 1
  cya:
    - entity: sensor.my_sampling_point_pl_cyanuric_acid
      setpoint: 37.5
      step: 12.5
  alkalinity:
    - entity: sensor.my_sampling_point_pl_alkalinity
      setpoint: 90
      step: 30
  pressure:
    - entity: sensor.esp178_pression_filtre
      unit: bar
      setpoint: 1
      step: 1
  product_weight:
    - entity: sensor.esp129_poids_ph_moins

Vous pouvez utiliser « auto-entites », vous visualisez ainsi toutes les entités contenant « ESP178 » dans mon cas (c’est le nom de mon ESP).

type: custom:auto-entities
card:
  type: entities
  show_header_toggle: false
  title: ESP 178
filter:
  include:
    - entity_id: "*esp178*"
      options: {}
  exclude: []
sort:
  method: entity_id

Améliorations Possibles

  • Ajouter une sonde ORP calibrée pour une régulation chlore plus précise.
  • Intégrer une prévision météo pour ajuster la filtration.
  • Tester le mode Ethernet (commenté dans le code) pour plus de stabilité.

Conclusion

Ce système offre une gestion autonome et personnalisable de ma piscine, avec un coût matériel raisonnable (< 100€ hors PCB). Le PCB maison et le schéma de câblage ont grandement simplifié l’installation et la maintenance. Le code est perfectible, mais il répond déjà à mes besoins. Si vous voulez les fichiers PCB, le schéma EasyEda, ou des précisions, laissez un commentaire !

Liste des publications en lien avec cet article:

  1. Filtration avec ESPHome et ESP32
  2. Filtration avec « AppDaemon »
  3. Filtration avec « Pool Pump Manager« 
  4. Mesure de puissance électrique
  5. Mise à niveau automatique
  6. Mesure du pH
  7. Régulation du Ph
  8. Mise Hors Gel
  9. Mesure de pression
  10. Mesure consommation d’eau
  11. Panneau de contrôle avec un ESP32
  12. Analyse de l’eau avec PoolLAB2.0

11 Comments on “HA-Gestion Complète d’une Piscine avec ESP32 et ESPHome”

  1. Hello, j’apprécie ton article. J’aimerai me baser sur ton tuto pour l’utiliser chez moi avec un électrolyseur. pouvons nous en discuter ?

  2. Bonjour,
    Serait-il possible de m’envoyer les fichiers PCB correspondants ainsi qu’une liste complète des composants nécessaires à la réalisation ?
    J’ai beaucoup apprécié votre projet et souhaiterais le reproduire moi-même.
    Avec mes remerciements anticipés,

      1. Je vous remercie beaucoup pour votre réactivité. Vous avez fait un excellent travail, continuez ainsi !

      2. Bonsoir, serait-il possible d’avoir aussi une traduction du code YAML en anglais afin de mieux comprendre le code ?

  3. Bonjour,
    très beau projet , impressionnant!!!
    Est il possible d’avoir les les fichiers PCB, le schéma EasyEda

    merci
    Cordialement

Répondre à stratogk Annuler la réponse

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *