[TUTO] - Gestion de sa piscine avec sonde iopool

Bonjour,

J’ai trouvé ici beaucoup de ressources pour m’aider à basculer de mon installation Jeedom à mon installation HA. Merci à tous.
Il est désormais temps de partager un peu à mon tour.
Ce tutoriel est pour gérer sa piscine analysé par une sonde EcO iopool.

Sur Jeedom, j’ai développé un plugin et je voulais retrouver le même comportement à savoir :

  • Connaitre l’état de l’eau remonté par la sonde EcO dans HA
  • Gérer la durée de filtration de l’eau à partir de la recommandation de iopool (basé sur 2 slots dans la journée)

Etape 1 : Les sensors

iopool met à disposition une API publique disponible ici : API publique iopool | iopool FAQ

Elle nécessite une clé API que vous pouvez trouver directement dans l’application iopool en allant dans vos paramètres (Plus > Paramètres)

Il vous faudra aussi votre id de bassin que vous pouvez trouver avec cette commande (nécessite d’avoir CURL installé ) :

curl --header 'x-api-key: icimacléapiiopool' https://api.iopool.com/v1/pools/

Le premier champs (id) du retour de la commande, sera votre id de bassin. Si vous avez plusieurs sondes active, choisissez le bon bassin.

Les sensors indiqués ci-dessous sans dans un package. Pour l’utiliser, il faut dans le fichier configuration.yaml, ajouter ceci :

homeassistant:
  # Load packages
  packages: !include_dir_merge_named includes/packages

Nous allons aussi déclaré notre apikey dans le fichier secrets.yaml

# iopool
iopool_api_key: icimacléapiiopool

Vous pouvez ne pas l’utiliser et mettre directement votre clé API en lieu et place du !secret iopool_api_key ci-dessous.
Il vous faudra aussi modifier le contenu ci-dessous pour inclure l’id de bassin que vous venez de récupérer

Le contenu ci-dessous sera donc à mettre dans le repertoire includes/packages (à moins de le changer ci-dessus), dans un fichier de votre choix. Pour ma part, je l’ai nommé pool.yaml.

pool:
  sensor:
    - platform: rest
      unique_id: fabc1ee2-0bbe-416e-b23d-2474ac25fe4e
      name: iopool
      resource: https://api.iopool.com/v1/pool/<votre_id_de_bassin_ici>
      value_template: "{{ value_json.title }}"
      json_attributes:
        - id
        - latestMeasure
        - hasAnActionRequired
        - advice
        - mode
      headers:
        x-api-key: !secret iopool_api_key
      scan_interval: 300
      icon: mdi:pool
    - platform: history_stats
      name: Temps de filtration écoulé
      entity_id: switch.pool_switch
      state: "on"
      type: time
      start: "{{ now().replace(hour=0, minute=0, second=0) }}"
      end: "{{ now() }}"

  template:
    - sensor:
        - name: "Température Sonde Piscine"
          unique_id: b336b008-dc88-4e3b-afd9-d662979fb0c1$
          state: "{{ state_attr('sensor.iopool', 'latestMeasure')['temperature'] | round(2) }}"
          device_class: temperature
          unit_of_measurement: "°C"
          state_class: measurement
          icon: mdi:pool-thermometer
          attributes:
            source: "{{ state_attr('sensor.iopool', 'latestMeasure')['mode'] }}"
            isValid: "{{ state_attr('sensor.iopool', 'latestMeasure')['isValid'] }}"
            measuredAt: "{{ state_attr('sensor.iopool', 'latestMeasure')['measuredAt'] }}"
        - name: "pH Sonde Piscine"
          unique_id: f4804a67-1224-4507-a4fb-21d983958b7c
          state: "{{ state_attr('sensor.iopool', 'latestMeasure')['ph'] | round(1) }}"
          unit_of_measurement: "pH"
          attributes:
            source: "{{ state_attr('sensor.iopool', 'latestMeasure')['mode'] }}"
            isValid: "{{ state_attr('sensor.iopool', 'latestMeasure')['isValid'] }}"
            measuredAt: "{{ state_attr('sensor.iopool', 'latestMeasure')['measuredAt'] }}"
        - name: "Capacité de désinfection Sonde Piscine"
          unique_id: e0ef9122-c53a-41ae-be72-517f3fcbb443
          state: "{{ state_attr('sensor.iopool', 'latestMeasure')['orp'] | round(0) }}"
          unit_of_measurement: "mV"
          attributes:
            source: "{{ state_attr('sensor.iopool', 'latestMeasure')['mode'] }}"
            isValid: "{{ state_attr('sensor.iopool', 'latestMeasure')['isValid'] }}"
            measuredAt: "{{ state_attr('sensor.iopool', 'latestMeasure')['measuredAt'] }}"
        - name: "Recommandation Durée de Filtration Sonde Piscine"
          unique_id: f53659ba-922f-4861-9198-73a7dd43ae6a
          state: "{{ state_attr('sensor.iopool', 'advice')['filtrationDuration'] * 60 }}"
          device_class: duration
          unit_of_measurement: "min"
          icon: mdi:sun-clock-outline
        - name: "Mode Sonde Piscine"
          unique_id: af6db587-be33-44e7-950c-fa52f0453d1f
          state: "{{ state_attr('sensor.iopool', 'mode') }}"
          icon: mdi:auto-mode
        - name: "Heure fin de Filtration Piscine Slot1"
          unique_id: bb04f92f-29a3-47b2-ad4f-81af2a850f02
          state: "{{ states('input_datetime.pool_stop_slot1') }}"
          icon: mdi:clock-outline
        - name: "Heure fin de Filtration Piscine Slot2"
          unique_id: cde90d21-b10a-4259-9767-bc2d174a7ca6
          state: "{{ states('input_datetime.pool_stop_slot2') }}"
          icon: mdi:clock-outline

    - binary_sensor:
        - name: "Actions requises Sonde Piscine"
          unique_id: fb6bb7e0-86ad-4f27-90ee-47c39db0ab12
          state: "{{ state_attr('sensor.iopool', 'hasAnActionRequired') }}"
          device_class: problem
          icon: mdi:checkbox-marked-circle-plus-outline

  input_datetime:
    pool_start_slot1:
      name: Heure début de Filtration Piscine Slot1
      icon: mdi:clock-outline
      has_date: false
      has_time: true
    pool_stop_slot1:
      name: Heure fin de Filtration Piscine Slot1
      icon: mdi:clock-outline
      has_date: false
      has_time: true
    pool_start_slot2:
      name: Heure début de Filtration Piscine Slot2
      icon: mdi:clock-outline
      has_date: false
      has_time: true
    pool_stop_slot2:
      name: Heure fin de Filtration Piscine Slot2
      icon: mdi:clock-outline
      has_date: false
      has_time: true

  input_select:
    pool_boost:
      name: Boost de Filtration Piscine
      icon: mdi:plus-box-multiple
      options:
        - Aucun
        - 1H
        - 4H
        - 8H
        - 24H
    pool_mode:
      name: Mode
      icon: mdi:sun-snowflake-variant
      options:
        - Standard
        - Active
        - Passive

  timer:
    pool_boost:
      name: Timer de boost
      restore: true
      icon: mdi:timer-plus

  input_number:
    filtration_duration_calculated:
      name: Temps de filtration calculé
      icon: mdi:clock-outline
      step: 1
      min: 0
      max: 1440

Détaillons un peu le contenu :

  • Le sensor en plateform rest, permet de récupérer toutes les 5 minutes, les informations par l’API iopool en les stockant dans les attributs du sensor. La valeur du sensor est le nom de votre sonde.

  • Un second sensor est créé pour connaitre le temps de filtration déjà effectuée sur la journée. Il vous faudra remplacer le entity_id switch.pool_switch par le switch correspond à la commande de votre pompe

  • Ensuite, dans la partie template, nous créons un sensors pour chaque informations utiles récupérée par l’appel API précédent (temperature, pH, ORP, recommandation de filtration, mode de la piscine, actions requises) et nous ajoutons 2 sensors supplémentaires permettant de définir l’heure de fin des 2 plages de filtration prévue (cf. le chapitre filtration)

  • Nous créons ensuite 4 input_datetime de type heure uniquement pour l’heure de début et fin de chaque plage de filtration (cf. le chapitre filtration)

  • Les input select permette de gérer une liste de durée de boost bien utile lors de l’application des produits ((cf. le chapitre filtration) ainsi que le mode de la piscine (standard = Eté / Active = Hivernage Actif / Passive = Hivernage Passif).

  • Un timer qui permet de suivre le temps restant d’un boost

  • Un input_number qui permet de stocker le temps de filtration calculé (car vous verrez dans le chapitre filtration, l’on peut surcharger le temps de filtration recommandé d’iopool).

Ne pas oublier de vérifier et relancer votre configuration
Open your Home Assistant instance and show your server controls..

Avant de passer à l’étape suivante, vérifiez que vos entités ont bien récupéré les informations par API à commencer par le sensor.iopool puisque c’est de lui que les autres entités vont avoir les informations.

Etape 2 : La filtration

Pour gérer l’intelligence de la filtration, j’ai décidé d’utiliser AppDaemon qui est un addon. Vous trouverez facilement des tuto sur l’installation.

Point particulier, dans la configuration de appDaemon il faut ajouter un package python humanize.
Pour cela, il suffit d’aller dans la configuration de AppDaemon et dans le champs Python Package saisir humanize et faire ‹ entrer › afin qu’il apparaisse ainsi


et redémarrer l’addon AppDaemon

Une fois installé et configuré, il faut mettre en place l’app. Pour cela, dans /config/appdaemon/apps, créer un fichier pool_pump_manager.py avec le contenu suivant :

import hassapi as hass
import math
from datetime import datetime, timedelta, time
import humanize

"""
Describe here
"""

class pool_pump_manager(hass.Hass):

    # Initialize Class
    # ----------------
    def initialize(self):
        self.log("Initialized")
        # Verify app configuration conformity
        if (self._check_configuration() != True):
            self.log("Error in configuration. Stopping app waiting configuration correction")
            self.stop_app(self.name)
            raise RuntimeError("Invalid configuration. Please look critical events in logs")
        self.log("INIT - Pool is in mode %s", self.get_state(self.args['mode_sensor_id']))
        # Depending of mode in progress
        if self.get_state(self.args['mode_sensor_id']).lower() == "Standard".lower():
            # Create task for slots
            self.slot1_start_handler = self._generate_tasks(1, self.get_state(self.args['times'][0]['start_time_id']))
            self.slot2_start_handler = self._generate_tasks(2, self.get_state(self.args['times'][1]['start_time_id']))
            # Create end task SLOT1 if not exist - for security in case app is reload
            self.slot1_end_handler = ""
            if self.timer_running(self.slot1_end_handler) == False:
                self.slot1_end_handler = self.run_at(self.callback_stop_filtration, self.get_state(self.args['times'][0]['stop_time_id']), slot=1, message="Slot1 Automation")
                self.log("INIT - Based on slot1 end time in %s, we create a task with handler %s at %s", self.args['times'][0]['stop_time_id'], self.slot1_end_handler, self.get_state(self.args['times'][0]['stop_time_id']), level = "INFO")
            # Create end task SLOT2 if not exist - for security in case app is reload
            self.slot2_end_handler = ""
            if self.timer_running(self.slot2_end_handler) == False:
                self.slot2_end_handler = self.run_at(self.callback_stop_filtration, self.get_state(self.args['times'][1]['stop_time_id']), slot=2, message="Slot2 Automation")
                self.log("INIT - Based on slot2 end time in %s, we create a task with handler %s at %s", self.args['times'][1]['stop_time_id'], self.slot2_end_handler, self.get_state(self.args['times'][1]['stop_time_id']), level = "INFO")
            # Listen change on slot times
            self.listen_state(self.callback_change_start_slots, self.args['times'][0]['start_time_id'], duration=1, slot=1)
            self.listen_state(self.callback_change_start_slots, self.args['times'][1]['start_time_id'], duration=1, slot=2)
        elif self.get_state(self.args['mode_sensor_id']).lower() == "Active".lower():
            # Create the start task for active winter filtration
            self.run_daily(self.callback_start_filtration, self.args['winter'][0]['start_time'], message="Active Winter")
            # Create the end task for active winter filtration
            self.run_daily(self.callback_stop_filtration, (datetime.strptime(self.args['winter'][0]['start_time'], "%H:%M:%S") + timedelta(minutes=self.args['winter'][0]['duration_minutes'])).time(), message="Active Winter")
        # Listen change in mode sensor
        self.listen_state(self.callback_change_mode, self.args['mode_sensor_id'], duration=1)
        # Listen change in boost selector and timer
        self.listen_state(self.callback_boost_trigger, self.args['boost_selector_sensor_id'])
        self.listen_event(self.callback_boost_timer_finished, entity_id = self.args['boost_timer_sensor_id'], event = "timer.finished")
        # List all existing handler for this app
        self._list_all_handler()


    # Check if all app configuration is good. In case of error, we stopping app
    # -------------------------------------------------------------------------
    def _check_configuration(self) -> bool:
        if self.entity_exists(self.args['pump_sensor_id']) == False:
            self.log("Entity defined in 'pump_sensor_id' configuration don't exist. Please update your configuration !", level = "CRITICAL")
            return False
        if self.entity_exists(self.args['pool_filtration_duration_sensor_id']) == False:
            self.log("Entity defined in 'pool_filtration_duration_sensor_id' configuration don't exist. Please update your configuration !", level = "CRITICAL")
            return False
        if self.entity_exists(self.args['mode_sensor_id']) == False:
            self.log("Entity defined in 'mode_sensor_id' configuration don't exist. Please update your configuration !", level = "CRITICAL")
            return False
        if self.entity_exists(self.args['times'][0]['start_time_id']) == False:
            self.log("Entity defined in 'times.slot1.start_time_id' configuration don't exist. Please update your configuration !", level = "CRITICAL")
            return False
        if self.entity_exists(self.args['times'][1]['start_time_id']) == False:
            self.log("Entity defined in 'times.slot2.start_time_id' configuration don't exist. Please update your configuration !", level = "CRITICAL")
            return False
        if not math.isclose(self.args['times'][0]['duration_percent'] + self.args['times'][1]['duration_percent'], 1, rel_tol=0, abs_tol=0):
            self.log("Sum of all duration_percent in times configuration is not equal to 1. Please update your configuration !", level = "CRITICAL")
            return False
        if datetime.strptime(self.get_state(self.args['times'][0]['start_time_id']), "%H:%M:%S") >= datetime.strptime(self.get_state(self.args['times'][1]['start_time_id']), "%H:%M:%S"):
            self.log("Slot1 start time is before or equal to slot2 start time. Please update your configuration !", level = "CRITICAL")
            return False
        if self.entity_exists(self.args['pool_filtration_duration_storage']) == False:
            self.log("Entity defined in 'pool_filtration_duration_storage' configuration don't exist. Please update your configuration !", level = "CRITICAL")
            return False
        if 'pool_filtration_duration_min' in self.args and 'pool_filtration_duration_max' in self.args and self.args['pool_filtration_duration_min'] > self.args['pool_filtration_duration_max']:
            self.log("pool_filtration_duration_min (%i) cannot be higher than pool_filtration_duration_max (%i)", self.args['pool_filtration_duration_min'], self.args['pool_filtration_duration_max'], level = "CRITICAL")
            return False
        return True


    # Return Filtration Duration
    # --------------------------
    def _get_filtration_duration(self) -> int:
        filtration_duration = int(float(self.get_state(self.args['pool_filtration_duration_sensor_id'])))
        if 'pool_filtration_duration_min' in self.args and filtration_duration < self.args['pool_filtration_duration_min']:
            filtration_duration = self.args['pool_filtration_duration_min']
        if 'pool_filtration_duration_max' in self.args and filtration_duration > self.args['pool_filtration_duration_max']:
            filtration_duration = self.args['pool_filtration_duration_max']
        self.set_value(self.args['pool_filtration_duration_storage'], filtration_duration)
        self.log("Based on configuration, filtration duration is %i minutes (iopool recommandation is %i minutes)", filtration_duration, int(float(self.get_state(self.args['pool_filtration_duration_sensor_id']))), level = "INFO")
        return filtration_duration


    # Callback to restart app and force a new initialize when mode change
    # If mode is Standard or Active
    # With this, we trigger new tasks by cleaning all old tasks
    # -------------------------------------------------------------------
    def callback_change_mode(self, entity, attribute, old, new, **kwargs: dict) -> None:
        self.log("New mode detected : %s (Old: %s)", new, old, level = "INFO")
        self.restart_app(self.name)


    # Generate scheduled task
    # -----------------------
    def _generate_tasks(self, slot: int, time: str) -> dict:
        if slot == 1:
            slot_start_handler = self.run_daily(self.callback_slot_start, time, slot=slot)
        elif slot == 2:
            slot_start_handler = self.run_daily(self.callback_slot_start, time, slot=slot)
        self.log("Generate a daily task at %s for slot%i start", time, slot, level = "INFO")
        return slot_start_handler


    # Callback when one slot start
    # ----------------------------
    def callback_slot_start(self, **kwargs: dict) -> None:
        if kwargs['slot'] == 1:
            # Start filtration
            self.callback_start_filtration(message="Pump starting for slot {}".format(kwargs['slot']))
            # Calculate time delta between slot1 and slot2
            timedelta_minutes = int((datetime.strptime(self.get_state(self.args['times'][1]['start_time_id']), "%H:%M:%S") - datetime.strptime(self.get_state(self.args['times'][0]['start_time_id']), "%H:%M:%S")).total_seconds() / 60)
            # Calculate filtration duration based on configuration percent and iopool filtration time recommandation
            filtration_duration = self._get_filtration_duration() * float(self.args['times'][0]['duration_percent'])
            self.log("Duration Between slots : %s minutes / Filtration Duration : %s (minutes)", int(timedelta_minutes), int(filtration_duration), level = "DEBUG")
            # If time between slot1 and slot 2 is superior than filtration time
            if int(timedelta_minutes) > int(filtration_duration):
                start_time = datetime.strptime(self.get_state(self.args['times'][0]['start_time_id']), "%H:%M:%S")
                end_time = datetime.strftime(start_time + timedelta(minutes=filtration_duration), "%H:%M:%S")
                # Setting end time in a HA input_datetime for slot1
                self.set_state(self.args['times'][0]['stop_time_id'], state=end_time)
                self.log("Slot1 Filtration times - Start : %s / End : %s", start_time.time(), end_time, level = "INFO")
                # If an existing timer run for self.slot1_end_handler handler, we cancel it
                if self.timer_running(self.slot1_end_handler) == True:
                    self.cancel_timer(self.slot1_end_handler)
                    self.log("Cancel existing timer for slot1 with handler %s", self.slot1_end_handler, level = "INFO")
                # We create an new timer based on stored input_datetime - end of slot1
                self.slot1_end_handler = self.run_at(self.callback_stop_filtration, end_time, slot=1, message="Slot1 Automation")
                self.log("Create new timer for slot1 with handler %s", self.slot1_end_handler, level = "INFO")
            else:
                self.log("Duration between slot1 and slot2 start time (%s minutes) is less than time need for filtration (%s minutes). We will not stop filtration for slot1", timedelta_minutes, filtration_duration, level="WARNING")
        elif kwargs['slot'] == 2:
            # Retrieves the time (in minutes) the pump was already running today
            elapsed_filtration_minutes = int(float(self.get_state(self.args['pump_stats_sensor_id'])) * 60)
            # Calculate the remaining time of filtration
            remaining_filtration_minutes = self._get_filtration_duration() - elapsed_filtration_minutes
            self.log("Slot2 - Elapsed time of filtration : %s minutes / Remaining time : %s minutes", elapsed_filtration_minutes, remaining_filtration_minutes, level = "INFO")
            start_time = datetime.strptime(self.get_state(self.args['times'][1]['start_time_id']), "%H:%M:%S")
            end_time = datetime.strftime(start_time + timedelta(minutes=remaining_filtration_minutes), "%H:%M:%S")
            # Setting end time in a HA input_datetime for slot2
            self.set_state(self.args['times'][1]['stop_time_id'], state=end_time)
            self.log("Slot2 Filtration times - Start : %s / End : %s", start_time.time(), end_time, level = "INFO")
            # If an existing timer run for self.slot2_end_handler handler, we cancel it
            if self.timer_running(self.slot2_end_handler) == True:
                self.cancel_timer(self.slot2_end_handler)
                self.log("Cancel existing timer for slot2 with handler %s", self.slot2_end_handler, level = "INFO")
            # If remaining time is superior than 0
            if remaining_filtration_minutes > 0:
                # Start filtration
                self.callback_start_filtration(message="Pump starting for slot {}".format(kwargs['slot']))
                # We create an new timer based on stored input_datetime - end of slot1
                self.slot2_end_handler = self.run_at(self.callback_stop_filtration, end_time, slot=2, message="Slot2 Automation")
                self.log("Create new timer for slot2 with handler %s", self.slot2_end_handler, level = "INFO")
            else:
                self.log("There is no more filtration time left to do", level = "INFO")

    # Callback when start time change on slot1 or slot2
    # ---------------------------------------------
    def callback_change_start_slots(self, entity, attribute, old, new, **kwargs: dict) -> None:
        self.log("Change detected in %s time - old value %s / new value : %s", entity, old, new, level = "INFO")
        # Generate new task for a specific slot
        slot_handler = self._generate_tasks(kwargs['slot'], self.get_state(entity))
        self.log("Generate a new task with handler %s for slot%i - at %s", slot_handler, kwargs['slot'], new, level = "INFO")
        if kwargs['slot'] == 1:
            # Cancel previous handler
            self.cancel_timer(self.slot1_start_handler)
            # Store new handler
            self.slot1_start_handler = slot_handler
        elif kwargs['slot'] == 2:
            # Cancel previous handler
            self.cancel_timer(self.slot2_start_handler)
            # Store new handler
            self.slot2_start_handler = slot_handler
        self.log("New handler %s stored for slot%i", slot_handler, kwargs['slot'], level = "DEBUG")


    # Callback who start filtration
    # -----------------------------
    def callback_start_filtration(self, **kwargs: dict) -> None:
        if self.get_state(self.args['pump_sensor_id']).lower() == 'off':
            self.log("Starting Pump - %s", kwargs['message'], level = "INFO")
            self.turn_on(self.args['pump_sensor_id'])
        else:
            self.log("%s - Pump is already On !", kwargs['message'], level = "INFO")


    # Callback who stop filtration
    # ----------------------------
    def callback_stop_filtration(self, **kwargs: dict) -> None:
        if self._boost_in_progress() == False:
            if self.get_state(self.args['pump_sensor_id']).lower() == 'on':
                self.log("Stopping Pump - %s", kwargs['message'], level = "INFO")
                self.turn_off(self.args['pump_sensor_id'])
            else:
                self.log("Pump is already Off !", level = "INFO")
        else:
            self.log("Boost is in progress (%s). Pump stopping is canceled.", self.get_state(self.args['boost_selector_sensor_id']), level = "INFO")
        # If slot is in method arguments and equal to slot2, we send a notification
        if 'slot' in kwargs and kwargs['slot'] == 2:
            self._end_notification()

    # Callback triggerred when boost time change is detected
    # ------------------------------------------------------
    def callback_boost_trigger(self, entity, attribute, old, new, **kwargs: dict) -> None:
        self.log("Change in boost trigger detected : %s (Old: %s)", new, old, level = "INFO")
        if new != "Aucun":
            boost_duration_hour = int(''.join(filter(str.isdigit, new)))
            boost_duration_timer = (datetime(1900, 1, 1) + timedelta(hours=boost_duration_hour)).strftime('%H:%M:%S')
            self.call_service("timer/start", entity_id=self.args['boost_timer_sensor_id'], duration=boost_duration_timer)
            self.callback_start_filtration(message="Boost {}".format(new))
        elif new == "Aucun":
            self.call_service("timer/cancel", entity_id=self.args['boost_timer_sensor_id'])
            if self._is_during_slot() is False:
                self.callback_stop_filtration(message="Stopping boost {}".format(old))
                self.log("Boost mode change from %s to %s. Stopping filtration", old, new, level = "INFO")
            else:
                self.log("As boost change from mode %s to %s during normal filtration time, pump stopping is canceled", old, new, level = "INFO")


    # Triggered when boost time finish to stop pump if we are not in normal filtration periods
    # ----------------------------------------------------------------------------------------
    def callback_boost_timer_finished(self, event, data, **kwargs: dict) -> None:
        self.log("Boost Timer finish (%s) with data: %s", event, data, level = "INFO")
        if self._is_during_slot() is False:
            self.log("Boost timer finished at %s - Stopping filtration", data['finished_at'], level = "INFO")
            self._send_notification(
                message="Fin du boost {}. Arrêt de la pompe.".format(self.get_state(self.args['boost_selector_sensor_id'])),
                tag="pool_boost")
        else:
            self.log("As boost %s end during normal filtration time, pump stopping is canceled", self.get_state(self.args['boost_selector_sensor_id']), level = "INFO")
            self._send_notification(
                message="Fin du boost {}. La pompe n'est pas arrêté car c'est la période de filtration normale.".format(self.get_state(self.args['boost_selector_sensor_id'])),
                tag="pool_boost")
        self.select_option(entity_id=self.args['boost_selector_sensor_id'], option="Aucun")


    # Return boolean depending if now time is between start and stop time of each slots
    # ---------------------------------------------------------------------------------
    def _is_during_slot(self) -> bool:
        if self.now_is_between(self.get_state(self.args['times'][0]['start_time_id']), self.get_state(self.args['times'][0]['stop_time_id'])) or self.now_is_between(self.get_state(self.args['times'][1]['start_time_id']), self.get_state(self.args['times'][1]['stop_time_id'])):
            return True
        else:
            return False

    # Detect if boost is in progress
    # ------------------------------
    def _boost_in_progress(self) -> bool:
        if self.get_state(self.args['boost_timer_sensor_id']) == "active":
            return True
        else:
            return False


    # Send notification via Home Assistant
    # ------------------------------------
    def _send_notification(self, **kwargs) -> None:
        self.fire_event("NOTIFIER",
            action = "send_to_marc",
            title = "💦 Temps de filtration piscine", 
            message = kwargs['message'],
            tag = kwargs['tag'])


    # Send End Filtration notification
    # --------------------------------
    def _end_notification(self, **kwargs) -> None:
        filtration_duration = self._get_filtration_duration() # Duration in minutes
        filtration_done = float(self.get_state(self.args['pump_stats_sensor_id'])) * 60 # Duration in minutes
        filtration_percent = (100 / filtration_duration) * filtration_done
        messages = []
        messages.append(
            "La piscine a était filtrée durant {} sur {} soit {}%".format(
                timedelta(minutes=filtration_done),
                timedelta(minutes=filtration_duration),
                str(round(filtration_percent))
            )
        )
        if self._boost_in_progress() is True:
            boost_type = self.get_state(self.args['boost_selector_sensor_id'])
            boost_end_date = datetime.strptime(self.get_state(self.args['boost_timer_sensor_id'], attribute="finishes_at"), "%Y-%m-%dT%H:%M:%S%z")
            now = datetime.now().astimezone()
            humanize.i18n.activate("fr_FR")
            remaining_boost = humanize.precisedelta(boost_end_date.astimezone() - now, minimum_unit="minutes")
            messages.append(
                "Un boost de {} est en cours et il reste {} de filtration (Fin : {})".format(
                    boost_type,
                    remaining_boost,
                    boost_end_date.astimezone().strftime("%d-%m-%Y %H:%M")
                )
            )
        self._send_notification(message='. '.join(map(str, messages)), tag="pool_end_filtration")

    # List existing Handlers
    # ----------------------
    def _list_all_handler(self) -> None:
        all_handlers = self.get_scheduler_entries()['pool_pump_manager']
        for handler in all_handlers:
            self.log("Existing Handlers => Date : %s / repeat : %s / interval : %s / callback : %s / kwargs : %s", 
                        all_handlers[handler]['timestamp'],
                        all_handlers[handler]['repeat'],
                        all_handlers[handler]['interval'],
                        all_handlers[handler]['callback'],
                        all_handlers[handler]['kwargs'],
                        level="DEBUG")

Je ne vais pas détailler l’ensemble de l’app, mais dans les grandes lignes, elle gère plusieurs choses :

  1. La surcharge du temps de filtration recommandé par iopool avec un minimum et maximum de filtration (cf ci-dessous dans la configuration de l’app)
  2. 2 slots de filtration avec un pourcentage de repartition (dans l’exemple de configuration ci-dessous, 40% en slot1 et 60% en slot2)
  3. La gestion des boosts sur une durée au choix entre 1H, 4H, 8H, 24H
  4. Une notification en fin de filtration (j’y reviendrais dans le prochain chapitre)

On va déclarer le fichier de log de appdaemon pour l’application qu’on vient de créer. Dans le fichier appdaemon.yaml present dans /config/appdaemon avec ce contenu à ajouter dans la partie log :

notifier:
    name: notifier
    filename: /config/appdaemon/logs/notifier.log

CleanShot 2023-05-31 at 14.15.37

Et redémarrer l’addon AppDeamon
Open your Home Assistant instance and show the dashboard of a Supervisor add-on.

Les logs intéressant en cas de besoin sont appdaemon.log, error.log et pool_pump_manager.log

Maintenant que l’app est créé, il faut faire une configuration qui va l’activer dans le fichier /config/appdaemon/apps/apps.yaml :

pool_pump_manager:
  module: pool_pump_manager
  class: pool_pump_manager
  use_dictionary_unpacking: true
  log: pool_pump_manager
  log_level: DEBUG
  pump_sensor_id: switch.pool_switch # Le switch qui gère votre pompe de piscine
  pump_stats_sensor_id: sensor.temps_de_filtration_ecoule # l'history_stats qui retourne le temps de filtration écoulé (créé dans le chapitre 1)
  pool_filtration_duration_sensor_id: sensor.iopool_filtration_time_recommandation # Le sensor avec la recommandation de filtration d'iopool (créé dans le chapitre 1)
  pool_filtration_duration_storage: input_number.filtration_duration_calculated # Le sensor qui stocke la durée réelle de filtration a effectué basé sur la recommandation iopool et le min et max ci-dessous
  pool_filtration_duration_min: 240 # Optionnel
  #pool_filtration_duration_max: 150 # Optionnel
  mode_sensor_id: input_select.pool_mode # Le mode défini pour la filtration (créé dans le chapitre 1)
  times:
    - name: slot1
      start_time_id: input_datetime.pool_start_slot1 # L'heure de démarrage du slot 1 de filtration (créé dans le chapitre 1)
      stop_time_id: input_datetime.pool_stop_slot1 # L'heure de fin du slot 1 de filtration (calculé par l'app est stocké dans ce sensor) - (créé dans le chapitre 1)
      duration_percent: 0.4 # La répartition entre les 2 slots (ici 40% sur le slot 1)
    - name: slot2
      start_time_id: input_datetime.pool_start_slot2 # L'heure de démarrage du slot 2 de filtration (créé dans le chapitre 1)
      stop_time_id: input_datetime.pool_stop_slot2 # L'heure de fin du slot 2 de filtration (calculé par l'app est stocké dans ce sensor) - (créé dans le chapitre 1)
      duration_percent: 0.6 # La répartition entre les 2 slots (ici 60% sur le slot 2)
  winter:
    - name: winter_filtration
      duration_minutes: 120 # Temps de filtration en mode Active (hivernage actif)
      start_time: "02:00:00" # Heure de démarrage de la filtration (ici 2h du matin)
  boost_selector_sensor_id: input_select.pool_boost # Le selecteur de boost (créé dans le chapitre 1)
  boost_timer_sensor_id: timer.pool_boost # Le timer du temps restant au boost en cours (créé dans le chapitre 1)

Il vous faut maintenant, configuré les heures de démarrage de la filtration pour le slot1 et slot2 respectivement dans les input_datetime.pool_start_slot1 et input_datetime.pool_start_slot2 a partir des outils de développement ou dans les helpers (Paramètres > Appareils et services > Entrées)
Open your Home Assistant instance and show your helper entities.

Il faut aussi savoir, que les heures de démarrage des slots de filtration sont fixes mais pas les fins. La fin du slot1 est calculé par rapport au pourcentage du temps de filtration appliqué dans le configuration, mais pour le slot2, c’est selon le temps restant de filtration.
Dans si la sonde iopool augmente son temps de filtration recommandé entre le slot1 et le slot2, le slot2 le prendra en compte dans la durée de filtration qu’il va devoir effectuer.

Une fois que vous aurez validé le fonctionnement de l’app, vous pouvez modifier dans apps.yaml le log level pour mettre WARNING.
Les log_level supportés sont : INFO , WARNING , ERROR , CRITICAL , DEBUG , NOTSET .

Etape 3 : Les notifications

L’app ajouté dans le chapitre précédent, envoi en fin de filtration et en fin de boost une notification.
Pour les notifications, j’utilisais déjà la super app AppDaemon de Horizon Domotique qui est très bien expliqué en video : https://www.youtube.com/watch?v=chJylIK0ASo et disponible sur son GitHub.

Etape 4 : L’affichage

J’ai l’habitude de renommer mes entity_id car j’aime avoir mes nom en français mais le fait que HA utilise le nom pour générer les entity_id me gêne un peu.
Donc les entity_id utilisé dans les cartes, sont à ajuster.
Voir ce post qui en parle : [TUTO] - Gestion de sa piscine avec sonde iopool - #31 par mguyard

Maintenant, il faut nous donner de la visibilité sur l’ensemble. J’ai donc ajouté tout cela dans l’ordre :

En dehors de la card de @Wilsto, j’utilise les card mushroom disponible et dont l’installation est expliqué ici : GitHub - piitaya/lovelace-mushroom: Mushroom Cards - Build a beautiful dashboard easily 🍄

Voici ce que cela donne :

Et le code YAML associé :

square: false
type: grid
cards:
  - type: custom:mushroom-title-card
    title: Piscine
    alignment: center
    title_tap_action:
      action: none
    subtitle_tap_action:
      action: none
  - type: horizontal-stack
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.iopool_mode
      - type: custom:mushroom-entity-card
        entity: binary_sensor.iopool_actions_need
        name: Actions Requises
        icon_color: red
  - type: custom:mushroom-entity-card
    entity: switch.pool_switch
    icon_color: green
    icon: mdi:pump
    secondary_info: last-changed
  - type: horizontal-stack
    cards:
      - type: custom:mushroom-select-card
        entity: input_select.pool_mode
        fill_container: true
        name: Mode de Filtration
      - type: custom:mushroom-select-card
        entity: input_select.pool_boost
        name: Boost
        fill_container: false
  - type: horizontal-stack
    cards:
      - type: custom:mushroom-template-card
        primary: Temps Filtration
        secondary: >
          {% if states('sensor.iopool_filtration_time_recommandation') !=
          states(entity) %}
            {{ (states(entity)|int * 60)|timestamp_custom('%H:%M', false) }} (iopool: {{ (states('sensor.iopool_filtration_time_recommandation')|int * 60)|timestamp_custom('%H:%M', false) }} )
          {% else %}
            {{ (states(entity)|int * 60)|timestamp_custom('%H:%M', false) }}
          {% endif %}
        icon: mdi:clock-check-outline
        icon_color: blue
        multiline_secondary: true
        fill_container: false
        tap_action:
          action: more-info
        hold_action:
          action: none
        double_tap_action:
          action: none
        entity: input_number.filtration_duration_calculated
      - type: custom:mushroom-template-card
        primary: Filtration effectuée
        secondary: '{{ timedelta(hours=states(entity) | float(0)) }}'
        icon: mdi:chart-line
        entity: sensor.temps_de_filtration_ecoule
        fill_container: true
        icon_color: blue
        tap_action:
          action: more-info
        hold_action:
          action: more-info
        double_tap_action:
          action: more-info
        multiline_secondary: false
  - type: horizontal-stack
    cards:
      - type: custom:mushroom-template-card
        primary: Horaires de filtration
        secondary: >
          {{ "- Slot 1 : " +
          states('input_datetime.pool_start_slot1').split(':')[:-1] | join(':')
          + " / " +  states('input_datetime.pool_stop_slot1').split(':')[:-1] |
          join(':')}}

          {{ "- Slot 2 : " +
          states('input_datetime.pool_start_slot2').split(':')[:-1] | join(':')
          + " / " +  states('input_datetime.pool_stop_slot2').split(':')[:-1] |
          join(':')}}
        icon: mdi:clock
        icon_color: blue
        multiline_secondary: true
        fill_container: false
        tap_action:
          action: none
        hold_action:
          action: none
        double_tap_action:
          action: none
        layout: vertical
  - type: custom:mini-graph-card
    entities:
      - entity: sensor.iopool_temperature
        name: Température iopool
    hours_to_show: 96
    animate: true
    line_width: 5
    group_by: hour
    state_adaptive_color: true
    hour24: true
    decimals: 1
    show:
      extrema: true
      average: true
      labels: false
    color_thresholds:
      - value: 20
        color: '#44739e'
      - value: 24
        color: '#12f33f'
      - value: 30
        color: '#f39c12'
      - value: 32
        color: '#c0392b'
  - type: custom:pool-monitor-card
    title: Données de piscine
    temperature: sensor.iopool_temperature
    ph: sensor.iopool_ph
    orp: sensor.iopool_orp
    show_labels: true
    language: fr
columns: 1

Ce tuto est plutôt long et pas si simple a expliqué pour tout les niveaux d’utilisateurs HA donc si vous avez des questions ou des remarques, n’hésitez pas.
Merci

2 « J'aime »

super tuto @mguyard bravo, j’aime beaucoup ton install et ton dash.
Je suis super hype par la gestion auto de la pompe et les notifications, je vais plancher pour voir comment installer çà chez moi.
Et je connaissais pas horizon domotique, merci du partage

1 « J'aime »

De mon coté impossible de faire fonctionner la commande CURL
j’obtiens la réponse suivante:
curl : (3 ) URL using bad/illegal format or missing URL
curl : (3 ) URL using bad/illegal format or missing URL

Je suis un peu perdu j’ai bien copié ma clef api

Bonjour,

Tu es sur d’avoir écrit ma commande curl complète exactement comme indiqué ? Tu peux partager ici ta commande en remplaçant ta clé api par key ?

curl --location ‹ htt*://api.iopool.com/v1/pools/ › --header ‹ x-api-key: key ›

c’est ce que j’ai tapé
Merci de m’avoir répondu rapidement

Peux être que ta version de CURL ne supporte pas le --location
Essaye ca :

curl "https://api.iopool.com/v1/pools/" --header "x-api-key: monapikey"

j’ai le même retour que précédemment…

Salut,
Tu n’a pas besoin des quotes pour l’URL.
Et vérifie que tes -- sont bien les caractères moins

Bonjour,

quelle sonde as-tu pris ? EcoStart ou EcoStart+Connect ?
car d’après ce que je vois, les deux remontent des infos.
image

Hello @LeLapinFou,

Tu peux récupérer les informations de la sonde par Bluetooth. Mais il faut que ton téléphone soit à portée de la sonde.
Une fois que ton téléphone a récupérer les informations il les partage avec le Cloud iopool ce qui te permet de récupérer les données par API.

Mais afin de ne pas rater la moindre mesure, je recommande d’utiliser le relais Connect. Ce relais est connecté à ton wifi et en même temps en Bluetooth à la sonde (il faut donc qu’il soit positionné à un endroit stratégique). Ainsi pas besoin d’être à portée de la sonde pour avoir les informations car la sonde va les envoyer au Cloud iopool et ton application mobile les récupérera depuis le Cloud. Donc même à l’autre bout du monde, tu peux savoir si ils faut mettre du chlore.

Si tu es intéressé par un achat de la sonde, voici un code de parrainage qui te fera économiser 25€ sur l’achat : https://mguyard.github.io/Jeedom-Documentations/fr_FR/iopool_EcO/documentation#Economisez%20de%20l’argent%20avec%20le%20parrainage%20iopool

Merci.
j’attend les devis des piscinistes dans un premier temps :slight_smile:

Bonjour,
Super tuto merci.J’utilise la sonde depuis un an et je vais faire remontez ttes les infos dans HA.
Je buttes sur l’étape 1 avec les unique_id des sensord.
Ils viennent d’où?Comment les trouver où les déterminer?
Merci
Gilles

Bonjour,

Merci.
J’utilise l’addon VS-code pour écrire les contenus et avec un clic droit on peut gérer des UUID pour les mettre en unique_id.

Mais en soit il y a peu de chance que ceux que j’ai mis soit déjà utiliser chez vous donc vous pouvez les laisser ainsi. Si vous avez un id utilisé plusieurs fois vous aurez un log d’erreur au redémarrage de HA

Merci pour votre réponse.
Je démarre cette configuration et j’ai un souci lors de la verification "Integration error: packages - Integration ‹ packages › not found.
Une idée de la cause?
Cdt
Gilles

Hello super boulot,
j’ai un probleme avec :

pool:
sensor:
- platform: rest
unique_id: fabc1ee2-0bbe-416e-b23d-2474ac25fe4e
name: iopool
resource: https://api.iopool.com/v1/pool/<votre_id_de_bassin_ici>
value_template: « {{ value_json.title }} »
json_attributes:

cela ne remonte pas les valeurs du coup dans la l’affichage j’ai le capteur mode inexistant

Bonjour,
J’avais un souci similaire car j’avais inversé l’id du bassin avec l’API Key…:woozy_face:
Si ça peux aider.

Bonjour Gillles. Ça ressemble à une mauvaise déclaration de package dans le configuration.yaml

Il doit être dans la partie homeassistant et donc bien indenté.
Vérifie que c’est bien comme dans le tuto.

Bonjour Jean-François,

As tu une erreur dans les logs ?

Es-tu sur d’avoir bien configuré la clé API et l’ID de bassin ?
Le capteur mode est inexistant ou inconnu (unknown) ?