[RETEX] : Pilotage complet d'un variteur piscine Varipool / iSAVER en RS485 avec ESPHome et une LilyGO T-CAN485

Bonjour à tous,

Après plusieurs semaines de rétro-ingénierie, de nombreux essais et énormément de temps à analyser le protocole RS485 passé du variateur Varipool / iSAVER, je souhaitais partager le résultat de mon travail afin qu'il puisse servir à d'autres.

Avant toute chose, je tiens à préciser que je ne suis absolument pas parti de zéro. Ce projet est basé sur les excellents travaux déjà réalisés par plusieurs développeurs de la communauté.

Je me suis notamment appuyé sur :

le projet ha-esp32-variable-speed-drive-esphome de htilly

les différents forks présents sur GitHub

plusieurs sujets du forum Home Assistant

différents échanges entre utilisateurs ayant travaillé sur le protocole iSAVER

Mon objectif n'était donc pas de réécrire entièrement le projet, mais de l'adaptateur à mon matériel, de corriger plusieurs comportements observés et surtout de rendre l'ensemble beaucoup plus stable pour mon cas.

Un grand merci à tous les auteurs des projets originaux.

Objectif du projet
Pouvoir piloter complètement un variateur Varipool de chez Aqualux (Revendeur Français) depuis Home Assistant.

Aujourd'hui mon ESPHome permet :

:coche_blanche:Marche / Arrêt

:coche_blanche:Choix de la vitesse

:coche_blanche:Démarrage automatique à une vitesse configurable (mémorisée, survit aux coupures de courant)

:coche_blanche:Confirmation réelle des commandes (ACK) avec réessai automatique

:coche_blanche:Estimation du débit

:coche_blanche:Estimation de la puissance

:coche_blanche:Calcul de l'énergie consommée

:coche_blanche:Surveillance de la liaison RS485

:coche_blanche:État en ligne / hors ligne

:coche_blanche:Indication visuelle directement sur la LilyGO (LED verte/rouge)

Matériel utilisé
LilyGO T-CAN485 (ESP32)

ESPHome

Assistant à domicile

Le câblage RS485 : A et B + SGND sur des paires torsadées d'un Cat5e (important à 1200 bauds près d'un variateur de fréquence, qui rayonne beaucoup de parasites), moins d'un mètre de câble dans mon cas.

Paramètres RS485
Issus de la documentation constructeur et confirmés par mes essais :

1200 bauds

8 bits de données

Pas de parité

1 bit de stop

Adresse esclave : 0xAA

:avertissement:Le point crucial : la gestion du pin FR (direction du bus)
C'est LE point qui m'a fait perdre le plus de temps, et celui sur lequel je veux insister car plusieurs montages publics se plantent dessus.

La LilyGO T-CAN485 embarque un émetteur-récepteur avec une broche d'activation (EN, sur GPIO17). Sur mon montage, ce pin se comporte comme un DE/RE classique : EN haut = émission, EN bas = réception . Il faut donc le piloter, et le piloter au bon moment :

Le couper trop tôt après l'écriture UART (quelques millisecondes) tronque la trame . À 1200 bauds, une trame de 8 octets avec 66,7 ms à sortir physiquement sur le fil — l'écriture UART, elle, revient immédiatement. Résultat si on coupe trop tôt : commandes acceptées aléatoirement, il faut appuyer 3-4 fois pour que ça passe. C'était exactement mon symptôme pendant des semaines.

Le laisser en permanence à l'état haut tue la réception : plus aucun octet ne rentre, le variateur ne peut plus répondre.

La solution qui fonctionne chez moi : EN monté au début de l'émission, coupé à 70 ms précis (le temps que les 66,7 ms de trame soient intégralement sorties), puis retour en écoute. Le tout géré par une machine à états non bloqués, sans delay()ni flush()— ces appels bloqués provoquaient chez moi des reboots watchdog dans les premières versions.

La vraie source de stabilité : sérialiser le trafic
Deuxième cause de mes commandes aléatoires : les collisions de bus . À 1200 bauds, un échange complet (demande 67 ms + réponse ~75 ms) occupe le bus ~150-250 ms. Si le polling tourne à cadence rapide et qu'une commande utilisateur partie pendant que le variateur est en train de répondre à un sondage, les deux trames se percutent et deviennent illisibles. Symptôme trompeur : ça ne se produit que pompe en marche (c'est elle qui répond aux sondages), donc on soupçonne à tort le bruit électrique du moteur.

La solution : une machine à états uniques qui sérialise tout le trafic. Une seule trame à la fois sur le bus, jamais de commande pendant qu'une réponse est en cours, temps de garde de 250 ms entre deux échanges (la doc constructeur impose 50 ms minimum). Les commandes utilisateur sont mises en fichier et prioritaires sur le polling.

Fonctionnement général
Le protocole repose sur deux commandes.

Commande D0 : écriture de la vitesse
Trame : AA D0 0B B9 <RPM_hi> <RPM_lo> <CRC_lo> <CRC_hi>(CRC16 Modbus, registre 3001 = 0x0BB9).

Elle permet l'arrêt, le démarrage et le changement de vitesse. Le code convertit automatiquement les RPM dans le format attendu par mon variateur et recalcule le CRC.

Trois mécanismes rendent l'envoi fiable :

Vérification d'ACK : le variateur acquitte chaque commande D0 par une trame commençant par AA D0(Tableau 11 de la doc). Le firmware attend cet ACK avant de confirmer la commande dans Home Assistant.

Filtrage de l'écho : piège classique — les 4 premiers octets de l'ACK sont identiques à ceux de la requête. Un code qui cherche naïvement AA D0dans le buffer peut valider sa propre écho et afficher "OK" alors que la pompe n'a rien reçu. Mon firmware compare le début du buffer avec la trame émise, octet par octet, et ne cherche l'ACK qu'après.

Réessai automatique ×3 : si aucun ACK n'arrive sous 400 ms, la commande repart toute seule (jusqu'à 3 fois). C'est ce qui a remplacé mon mode dégradé de mes "3-4 appuis manuels".

Un débounce de 350 ms complète le tout : si plusieurs consignes arrivent rapidement (ex. glissement du slider 1200 → 1700 → 2400 → 2900), seule la dernière est envoyée. Cela évite de saturer le bus.

Commande C3 : lecture d'état
La commande C3 ( AA C3 07 D1 00 00 + CRC, registre 2001) est censée renvoyer les informations de fonctionnement : code d'erreur, état marche/arrêt, RPM (Table 9 de la doc).

Malheureusement, c'est ici que les choses deviennent intéressantes. Les projets GitHub montrent généralement une vraie réponse AA C3 ...avec les RPM. Chez moi, malgré tous mes essais, je n'ai jamais obtenu cette trame. Mon variateur renvoie simplement RX 1 octet | 00, ou parfois rien du tout ( RX 0 octet).

J'ai testé : inversion A/B, différents délais, différentes fenêtres d'attente, différentes tailles de buffer UART, lectures de 7/8/9 octets, lecture jusqu'au timeout, plusieurs méthodes d'analyse, plusieurs timings. Impossible d'obtenir la réponse décrite dans certains projets GitHub. Je soupçonne fortement une différence de firmware selon les versions de variateurs.

Changement de philosophie : surveiller plutôt que décoder
Plutôt que de m'acharner à décoder une trame qui n'arrive jamais, j'ai changé d'approche : le mais n'est plus de décoder le C3, mais simplement de vérifier que le bus se comporte normalement .

Le sondage C3 est envoyé toutes les 2 secondes . La logique d'état est la suivante :

Réponse C3 valide (trame AA C3) → En ligne , LED verte (et dans ce cas le firmware décode quand même RPM, état marche et registre d'erreurs — si votre variateur répond, vous avez tout)

Silence (RX 0 octet) → En ligne , LED verte — car chez moi c'est le fonctionnement normal

Octets reçus mais aucune trame valide (suite de parasites) → Hors ligne , LED rouge

:avertissement: Limite assumée de cette approche : comme le silence est considéré comme normal, une pompe physiquement débranchée ne fera pas basculer le statut en Hors ligne. Ce compromis est adapté à mon variateur qui ne répond pas au C3 ; Si le vôtre répond correctement, préférez une logique classique "pas de réponse valide depuis X secondes = Hors ligne".

Bonus pour ceux dont le variateur répond au C3 : la trame contient un registre d'erreurs de 16 bits (surchauffe radiateur, surintensité sortie, tension d'entrée anormale, erreur driver moteur, sonde HS...). Mon firmware le décode en clair dans un capteur texte "Défaut variateur" — de quoi être notifié d'une mise en protection avant même d'aller voir le bornier.

Vitesse de démarrage configurable
Un numberESPHome permet de choisir la vitesse utilisée pour l'allumage de la pompe. Elle est mémorisée en flash ( restore_value) : si vous la changez dans HA, elle survit aux reboots. Après chaque coupure de courant, le firmware relance automatiquement la pompe à cette vitesse — avec ACK et réessais, donc même si le variateur est prêté à se réveiller, la commande finie par passer.

Débit
Le débit n'est pas fourni par le variateur, il est donc augmenté. Au départ le calcul était linéaire ; je l'ai remplacé par la courbe constructeur :

tr/min Débit
1200 6,1 m³/h
1700 8,2 m³/h
2200 9,5 m³/h
2900 13,5 m³/h
Entre ces points, le code réalise une interpolation. Le résultat est beaucoup plus réaliste.

Puissance et énergie
Même principe pour la puissance : elle est estimée à partir d'une table de points de fonctionnement (26 points relevés entre 1200 et 2900 RPM) et évolue automatiquement selon les RPM. Le code intègre ensuite Puissance × Temps pour obtenir la consommation de la session, remise à zéro d'un bouton.

Entités exposées dans Home Assistant
Numbers : vitesse de démarrage, consigne de vitesse (slider) Switchs : pompe piscine, activation RS485 Capteurs : RPM actuels, puissance estimée, débit L/min, débit m³/h, énergie de session, délai depuis la dernière réponse, RSSI, qualité WiFi %, température ESP32 Capteurs texte : statut RS485, défaut variateur, retour brut D0 (hexa), retour brut C3 (hexa), durée de fonctionnement, SSID, IP, MAC, version ESPHome Binaires : pompe en fonctionnement, ESP connecté Boutons : préréglages ECO / NORMAL / FILTRATION / MAX, reset énergie, redémarrage, mode sans échec

Les deux capteurs de retour brut en hexadécimal sont précieux pour le debug : on voit exactement ce qui circule sur le bus sans sortir l'analyseur logique.

LED intégrée
La LED RGB de la LilyGO sert d'indicateur : verte = communication OK (trame valide ou silence normal), rouge = parasites détectés sur le bus, orange = commande conservée sans ACK après les 3 réessais.

Pourquoi une LilyGO T-CAN485 ?
Après tous ces essais, je pense sincèrement que cette carte est bien adaptée : ESP32, émetteur-récepteur RS485 intégré, alimentation propre, LED RGB — le câblage est minimal. Un détail d'implémentation à connaître : ne pas oublier d'activer le GPIO16 (enable de l'alimentation du transceiver) au boot, et piloter le pin EN comme décrit plus haut — c'est le cœur de la fiabilité du montage.

Ce qui reste à comprendre
Le seul véritable mystère reste la trame C3. Les projets GitHub semblent obtenir une vraie réponse contenant les RPM ; mon variateur, lui, ne la renvoie jamais. Je soupçonne fortement une différence de firmware selon les versions de variateurs.

Si quelqu'un possède une capture UART complète d'un iSAVER renvoyant réellement la trame C3, je serais très intéressé pour comparer les deux protocoles.

Conclusion
Au final, même sans trame C3 exploitable, il est possible d'obtenir un système parfaitement fonctionnel. Les trois clés de la fiabilité, dans l'ordre d'importance :

Le chronométrage du pin FR : coupé à 70 ms, ni avant (trame tronquée), ni jamais (réception morte)

La sérialisation du trafic par une machine à états non bloquée : une seule trame à la fois sur le bus

ACK + filtrage d'écho + réessai automatique sur les commandes

En acceptant que certains variateurs ne répondent pas exactement comme ceux décrits dans les projets GitHub, on peut construire une solution robuste, parfaitement intégrée à Home Assistant, avec une surveillance fiable du bus RS485 et un pilotage complet de la pompe.

J'espère que ce retour d'expérience fera gagner du temps à ceux qui se lanceront dans le même projet. Si vous avez un firmware de variateur différent ou des captures UART de trames C3 complètes, je serai ravi d'échanger afin de continuer à faire avancer la compréhension de ce protocole.

Je vous partage mon YAML :

esphome:
name: varipool-rs485
friendly_name: "Varipool RS485"
comment: "Varipool / iSAVER RS485 - LilyGO T-CAN485 - machine a etats v2"
on_boot:
priority: -100
then:

EN (driver) OFF au repos : EN haut = emission, EN bas = ecoute.

C'est le chronometrage valide lors des tests.

output.turn_on: power_5v

output.turn_on: rs485_se

output.turn_off: rs485_en

lambda: |-
id(last_sample_ms) = millis();
id(run_energy_wh) = 0.0f;

light.turn_on:
id: lilygo_led
red: 0%
green: 100%
blue: 0%
brightness: 25%

delay: 2s

if:
condition:
lambda: |-
return id(modbus_enabled);
then:

Apres chaque coupure/reboot : relance TOUJOURS la pompe

a la vitesse de demarrage memorisee (restore_value)

lambda: |-
id(pending_req_rpm) = (uint16_t) id(pump_start_rpm).state;
id(retry_count) = 0;
id(last_user_command_ms) = millis();
id(pending_cmd) = true;
id(pool_pump_switch).publish_state(true);
id(pump_rpm).publish_state(id(pump_start_rpm).state);

esp32:
board: esp32dev
framework:
type: arduino

logger:
level: DEBUG

api:
encryption:
key: "YOUR_KEY"

ota:

platform: esphome
password: "1234"

wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
fast_connect: true

ap:
ssid: "Variateur Hotspot"
password: "1234"

captive_portal:

web_server:
port: 80

light:

platform: esp32_rmt_led_strip
id: lilygo_led
name: "LED LILYGO RS485"
pin: GPIO4
num_leds: 1
chipset: WS2812
rgb_order: GRB
restore_mode: ALWAYS_OFF
default_transition_length: 0s

uart:
id: modbus_uart
tx_pin: GPIO22
rx_pin: GPIO21
baud_rate: 1200
stop_bits: 1
data_bits: 8
parity: NONE

output:

platform: gpio
pin: GPIO17
id: rs485_en

platform: gpio
pin: GPIO19
id: rs485_se

platform: gpio
pin: GPIO16
id: power_5v

globals:

id: modbus_enabled
type: bool
restore_value: true
initial_value: 'true'

----- Machine a etats bus RS485 -----

0 = IDLE, 1 = emission en cours, 2 = attente reponse

id: comm_state
type: int
restore_value: false
initial_value: '0'

1 = poll C3, 2 = commande D0

id: tx_kind
type: int
restore_value: false
initial_value: '0'

id: pending_cmd
type: bool
restore_value: false
initial_value: 'false'

RPM demande cote utilisateur (0 = arret)

id: pending_req_rpm
type: uint16_t
restore_value: false
initial_value: '0'

Debounce : timestamp de la derniere consigne utilisateur

id: last_user_command_ms
type: uint32_t
restore_value: false
initial_value: '0'

id: retry_count
type: int
restore_value: false
initial_value: '0'

id: tx_time_ms
type: uint32_t
restore_value: false
initial_value: '0'

id: rx_start_ms
type: uint32_t
restore_value: false
initial_value: '0'

id: last_poll_ms
type: uint32_t
restore_value: false
initial_value: '0'

id: rx_buf
type: std::vector<uint8_t>
restore_value: false

id: tx_pkt
type: std::vector<uint8_t>
restore_value: false

----- Diagnostics -----

id: last_response_ms
type: uint32_t
restore_value: false
initial_value: '0'

id: read_fail_count
type: uint16_t
restore_value: false
initial_value: '0'

id: offline_reported
type: bool
restore_value: false
initial_value: 'false'

id: run_energy_wh
type: float
restore_value: false
initial_value: '0.0'

id: last_sample_ms
type: uint32_t
restore_value: false
initial_value: '0'

text_sensor:

platform: template
name: "Statut RS485"
id: modbus_status
icon: mdi:lan-connect

platform: template
name: "Défaut variateur"
id: drive_error
icon: mdi:alert-decagram-outline

platform: template
name: "Retour D0 brut"
id: ack_raw_text
icon: mdi:code-braces

platform: template
name: "Retour C3 brut"
id: c3_raw_text
icon: mdi:code-braces

platform: wifi_info
ip_address:
name: "Adresse IP"
ssid:
name: "WiFi SSID"
bssid:
name: "WiFi BSSID"
mac_address:
name: "Adresse MAC"

platform: template
name: "Durée de fonctionnement"
id: uptime_friendly
update_interval: 30s
lambda: |-
uint32_t secs = (uint32_t) id(uptime_s).state;
uint32_t days = secs / 86400;
uint32_t hours = (secs % 86400) / 3600;
uint32_t minutes = (secs % 3600) / 60;
uint32_t seconds = secs % 60;
char buffer[64];
if (days > 0) {
snprintf(buffer, sizeof(buffer), "%u j %u h %u min", days, hours, minutes);
} else if (hours > 0) {
snprintf(buffer, sizeof(buffer), "%u h %u min", hours, minutes);
} else if (minutes > 0) {
snprintf(buffer, sizeof(buffer), "%u min %u s", minutes, seconds);
} else {
snprintf(buffer, sizeof(buffer), "%u s", seconds);
}
return std::string(buffer);

platform: version
name: "Version ESPHome"

number:

platform: template
name: "Vitesse démarrage pompe"
id: pump_start_rpm
min_value: 1200
max_value: 2900
step: 50
unit_of_measurement: "RPM"
mode: slider
optimistic: true
restore_value: true
initial_value: 1700
icon: mdi:speedometer

platform: template
name: "Consigne vitesse pompe"
id: pump_rpm
min_value: 1200
max_value: 2900
step: 50
unit_of_measurement: "RPM"
mode: slider
optimistic: true
restore_value: true
icon: mdi:fan
set_action:

if:
condition:
lambda: |-
return id(modbus_enabled);
then:

Debounce : plusieurs changements rapides ne gardent que la

derniere consigne. La machine a etats attend 350ms de calme

avant d'emettre.

lambda: |-
id(pending_req_rpm) = (uint16_t) x;
id(retry_count) = 0;
id(last_user_command_ms) = millis();
id(pending_cmd) = true;
id(pool_pump_switch).publish_state(x >= 1200.0f);
id(modbus_status).publish_state("Commande en file");
else:

logger.log: "RS485 désactivé : consigne ignorée"

sensor:

platform: template
name: "RPM pompe actuels"
id: pump_rpm_state
unit_of_measurement: "RPM"
accuracy_decimals: 0
update_interval: never
force_update: false
icon: mdi:fan

platform: template
name: "Puissance pompe estimée"
id: pump_power_w
device_class: power
state_class: measurement
unit_of_measurement: "W"
accuracy_decimals: 0
update_interval: 1s
icon: mdi:flash
lambda: |-
const float rpm = id(pump_rpm_state).state;

if (rpm < 1200.0f) return 0.0f;

const float rpm_points
= {
1200, 1350, 1400, 1500, 1550, 1650, 1700,
1800, 1950, 2050, 2150, 2200, 2250, 2300,
2350, 2400, 2450, 2500, 2550, 2600, 2650,
2700, 2750, 2800, 2850, 2900
};

const float watt_points
= {
120, 128, 136, 152, 168, 184, 200,
232, 296, 328, 360, 376, 400, 432,
440, 464, 488, 520, 544, 568, 592,
624, 656, 688, 728, 752
};

const int count = sizeof(rpm_points) / sizeof(rpm_points[0]);

if (rpm >= rpm_points[count - 1]) {
return watt_points[count - 1];
}

for (int i = 1; i < count; i++) {
if (rpm <= rpm_points[i]) {
const float ratio = (rpm - rpm_points[i - 1]) / (rpm_points[i] - rpm_points[i - 1]);
return watt_points[i - 1] + ratio * (watt_points[i] - watt_points[i - 1]);
}
}

return 0.0f;

platform: template
name: "Débit pompe estimé"
id: pump_flow_lmin
unit_of_measurement: "L/min"
accuracy_decimals: 1
update_interval: 1s
icon: mdi:water-pump
lambda: |-
const float rpm = id(pump_rpm_state).state;

if (rpm < 1200.0f) return 0.0f;

const float rpm_points
= {
1200, 1700, 2200, 2900
};

const float flow_m3h_points
= {
6.1, 8.2, 9.5, 13.5
};

const int count = sizeof(rpm_points) / sizeof(rpm_points[0]);

float flow_m3h = 0.0f;

if (rpm >= rpm_points[count - 1]) {
flow_m3h = flow_m3h_points[count - 1];
} else {
for (int i = 1; i < count; i++) {
if (rpm <= rpm_points[i]) {
const float ratio =
(rpm - rpm_points[i - 1]) / (rpm_points[i] - rpm_points[i - 1]);
flow_m3h =
flow_m3h_points[i - 1] +
ratio * (flow_m3h_points[i] - flow_m3h_points[i - 1]);
break;
}
}
}

return flow_m3h / 0.06f;

platform: template
name: "Débit pompe estimé m³⁄h"
id: pump_flow_m3h
unit_of_measurement: "m³/h"
state_class: measurement
accuracy_decimals: 2
update_interval: 1s
icon: mdi:water-pump
lambda: |-
return id(pump_flow_lmin).state * 0.06f;

platform: template
name: "Énergie pompe session"
id: pump_energy_session
unit_of_measurement: "kWh"
device_class: energy
state_class: measurement
accuracy_decimals: 3
update_interval: 2s
icon: mdi:lightning-bolt
lambda: |-
return id(run_energy_wh) / 1000.0f;

platform: template
name: "Délais dernière réponse Varipool"
id: last_good_age_s
unit_of_measurement: "s"
accuracy_decimals: 0
update_interval: 2s
icon: mdi:clock-check-outline
lambda: |-
if (id(last_response_ms) == 0) return 0.0f;
return (millis() - id(last_response_ms)) / 1000.0f;

platform: wifi_signal
name: "Signal Wi-Fi dB"
id: wifi_rssi_db
update_interval: 30s

platform: template
name: "Signal Wi-Fi %"
id: wifi_signal_pct
update_interval: 30s
lambda: |-
float rssi = id(wifi_rssi_db).state;
float pct = (rssi + 90.0f) * (100.0f / 60.0f);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
return pct;
unit_of_measurement: "%"
icon: mdi:wifi

platform: internal_temperature
name: "Température LILYGO"

platform: uptime
name: "Uptime secondes"
id: uptime_s
update_interval: 30s

binary_sensor:

platform: status
name: "Varipool RS485 connecté"
device_class: connectivity

platform: template
name: "Pompe en fonctionnement"
id: pump_running
device_class: running
lambda: |-
return id(pump_rpm_state).state >= 1200.0f;

switch:

platform: template
name: "RS485 Varipool activé"
id: modbus_enabled_switch
optimistic: true
restore_mode: RESTORE_DEFAULT_ON
icon: mdi:lan-connect
turn_on_action:

lambda: |-
id(modbus_enabled) = true;
id(modbus_status).publish_state("RS485 activé");
turn_off_action:

lambda: |-
id(modbus_enabled) = false;
id(comm_state) = 0;
id(pending_cmd) = false;
id(rs485_en).turn_off();
id(modbus_status).publish_state("RS485 désactivé");

platform: template
name: "Pompe piscine RS485"
id: pool_pump_switch
optimistic: true
restore_mode: RESTORE_DEFAULT_OFF
icon: mdi:pump
turn_on_action:

if:
condition:
lambda: |-
return id(modbus_enabled);
then:

Demarre a la vitesse de demarrage configurable

lambda: |-
auto call = id(pump_rpm).make_call();
call.set_value(id(pump_start_rpm).state);
call.perform();
else:

logger.log: "RS485 désactivé : démarrage annulé"
turn_off_action:

if:
condition:
lambda: |-
return id(modbus_enabled);
then:

lambda: |-
id(pending_req_rpm) = 0;
id(retry_count) = 0;
id(last_user_command_ms) = millis();
id(pending_cmd) = true;
id(modbus_status).publish_state("Arrêt en file");
else:

logger.log: "RS485 désactivé : arrêt annulé"

button:

platform: template
name: "Pompe ECO - 1200 RPM"
icon: mdi:leaf
on_press:

number.set:
id: pump_rpm
value: 1200

platform: template
name: "Pompe NORMAL - 1700 RPM"
icon: mdi:fan-speed-2
on_press:

number.set:
id: pump_rpm
value: 1700

platform: template
name: "Pompe FILTRATION - 2200 RPM"
icon: mdi:fan-speed-3
on_press:

number.set:
id: pump_rpm
value: 2200

platform: template
name: "Pompe MAX - 2900 RPM"
icon: mdi:fan-alert
on_press:

number.set:
id: pump_rpm
value: 2900

platform: template
name: "Réinitialiser énergie session"
icon: mdi:counter
on_press:

lambda: |-
id(run_energy_wh) = 0.0f;
id(last_sample_ms) = millis();

platform: restart
name: "Redémarrer LILYGO RS485"

platform: safe_mode
name: "Démarrer en mode sans échec"

interval:

============================================================

MACHINE A ETATS RS485 - non bloquante, tick de 5ms

EN (driver) actif UNIQUEMENT pendant les 70ms d'emission.

Trafic serialise : jamais de commande pendant une reponse.

Debounce 350ms sur les consignes utilisateur.

Pas de delay(), pas de flush().

============================================================

interval: 5ms
then:

lambda: |-
if (!id(modbus_enabled)) return;

auto uart = id(modbus_uart);
const uint32_t now = millis();

// ---------- ETAT 0 : bus libre ----------
if (id(comm_state) == 0) {

// Debounce : on attend 350ms sans nouvelle consigne
const bool want_cmd = id(pending_cmd) &&
(now - id(last_user_command_ms)) >= 350;
const bool want_poll = (now - id(last_poll_ms)) > 2000;

if (!want_cmd && !want_poll) return;

// Temps de garde apres le dernier echange (T4 notice = 50ms min)
if (id(tx_time_ms) != 0 && (now - id(tx_time_ms)) < 250) return;

uint8_t base[6];

if (want_cmd) {
const uint16_t requested_rpm = id(pending_req_rpm);
uint16_t command_rpm;
if (requested_rpm < 1200) {
command_rpm = 1;
} else {
command_rpm = requested_rpm * 2;
if (command_rpm > 5800) command_rpm = 5800;
}
base[0] = 0xAA; base[1] = 0xD0; base[2] = 0x0B; base[3] = 0xB9;
base[4] = (command_rpm >> 8) & 0xFF;
base[5] = command_rpm & 0xFF;
id(tx_kind) = 2;
} else {
base[0] = 0xAA; base[1] = 0xC3; base[2] = 0x07; base[3] = 0xD1;
base[4] = 0x00; base[5] = 0x00;
id(tx_kind) = 1;
id(last_poll_ms) = now;
}

uint16_t crc = 0xFFFF;
for (int i = 0; i < 6; i++) {
crc ^= base[i];
for (int j = 0; j < 8; j++) {
if (crc & 0x0001) { crc >>= 1; crc ^= 0xA001; }
else { crc >>= 1; }
}
}

uint8_t packet[8] = {
base[0], base[1], base[2], base[3], base[4], base[5],
(uint8_t)(crc & 0xFF), (uint8_t)((crc >> 8) & 0xFF)
};

while (uart->available() > 0) {
uint8_t d;
uart->read_array(&d, 1);
}
id(rx_buf).clear();
id(tx_pkt).assign(packet, packet + 8);

// Driver actif UNIQUEMENT pendant l'emission
id(rs485_en).turn_on();
uart->write_array(packet, 8);

id(tx_time_ms) = now;
id(comm_state) = 1;
return;

}

// ---------- ETAT 1 : emission en cours ----------
if (id(comm_state) == 1) {
// 8 octets x 10 bits / 1200 bauds = 66,7 ms sur le fil.
// Coupure du driver a 70 ms, retour en ecoute.
if ((now - id(tx_time_ms)) >= 70) {
id(rs485_en).turn_off();
id(rx_start_ms) = now;
id(comm_state) = 2;
}
return;
}

// ---------- ETAT 2 : attente reponse ----------
while (uart->available() > 0 && id(rx_buf).size() < 64) {
uint8_t b;
uart->read_array(&b, 1);
id(rx_buf).push_back(b);
}

auto &rx = id(rx_buf);
const size_t n = rx.size();

// Filtrage de l'echo : on saute notre propre trame si presente
size_t search_from = 0;
auto &tx = id(tx_pkt);
for (size_t i = 0; i + 8 <= n; i++) {
bool same = true;
for (size_t k = 0; k < 8; k++) {
if (rx[i + k] != tx[k]) { same = false; break; }
}
if (same) { search_from = i + 8; break; }
}

// Helper hexa pour les capteurs de debug
auto make_hex = &rx, n -> std::string {
char buf[220];
size_t pos = 0;
pos += snprintf(buf + pos, sizeof(buf) - pos,
"RX %u octet%s | ", (unsigned int) n, n > 1 ? "s" : "");
for (size_t i = 0; i < n && pos < sizeof(buf) - 4; i++) {
pos += snprintf(buf + pos, sizeof(buf) - pos, "%02X ", rx[i]);
}
return std::string(buf);
};

// ----- Reponse a une COMMANDE (D0) -----
if (id(tx_kind) == 2) {
bool ack_found = false;
for (size_t i = search_from; i + 1 < n; i++) {
if (rx[i] == 0xAA && rx[i + 1] == 0xD0) {
ack_found = true;
break;
}
}

if (ack_found) {
id(pending_cmd) = false;
id(comm_state) = 0;

const uint16_t ui_rpm = id(pending_req_rpm);
id(pump_rpm_state).publish_state(ui_rpm);
id(pool_pump_switch).publish_state(ui_rpm > 0);

id(last_response_ms) = millis();
id(read_fail_count) = 0;
id(offline_reported) = false;
id(modbus_status).publish_state("Commande confirmée");
id(ack_raw_text).publish_state(make_hex());

auto call = id(lilygo_led).turn_on();
call.set_rgb(0.0f, 1.0f, 0.0f);
call.set_brightness(0.25f);
call.perform();
return;
}

// Timeout commande : 400ms apres fin d'emission
if ((now - id(rx_start_ms)) > 400) {
id(comm_state) = 0;
id(retry_count)++;
id(ack_raw_text).publish_state(
n > 0 ? make_hex() : std::string("RX 0 octet"));

if (id(retry_count) < 4) {
char msg[32];
snprintf(msg, sizeof(msg), "Réessai %d/3", id(retry_count));
id(modbus_status).publish_state(msg);
} else {
id(pending_cmd) = false;
id(read_fail_count)++;
id(modbus_status).publish_state("Commande sans ACK");

auto call = id(lilygo_led).turn_on();
call.set_rgb(1.0f, 0.5f, 0.0f);
call.set_brightness(0.25f);
call.perform();

}
}
return;

}

// ----- Reponse a un POLL (C3) -----
int response_pos = -1;
for (size_t i = search_from; i + 6 < n; i++) {
if (rx[i] == 0xAA && rx[i + 1] == 0xC3) {
const bool is_echo =
(i + 3 < n) && rx[i + 2] == 0x07 && rx[i + 3] == 0xD1;
if (!is_echo) {
response_pos = (int)i;
break;
}
}
}

if (response_pos >= 0 && response_pos + 6 < (int)n) {
// Trame C3 (Table 9 de la notice) :
// [0]=AA [1]=C3 [2..3]=Code erreur [4]=Etat marche [5..6]=RPM [7..8]=CRC

const uint16_t err_code =
((uint16_t)rx[response_pos + 2] << 8) |
(uint16_t)rx[response_pos + 3];

if (err_code == 0) {
if (id(drive_error).state != "Aucun défaut")
id(drive_error).publish_state("Aucun défaut");
} else {
std::string msg;
if (err_code & (1 << 4))  msg += "Erreur comm 485; ";
if (err_code & (1 << 5))  msg += "Réduction vitesse (temp.); ";
if (err_code & (1 << 6))  msg += "Erreur clavier/carte; ";
if (err_code & (1 << 7))  msg += "Erreur lecture EEPROM; ";
if (err_code & (1 << 8))  msg += "Erreur lecture RTC; ";
if (err_code & (1 << 9))  msg += "EEPROM carte mère; ";
if (err_code & (1 << 10)) msg += "Erreur circuit courant; ";
if (err_code & (1 << 11)) msg += "Erreur driver moteur; ";
if (err_code & (1 << 12)) msg += "Erreur sonde radiateur; ";
if (err_code & (1 << 13)) msg += "Surchauffe radiateur; ";
if (err_code & (1 << 14)) msg += "Surintensité sortie; ";
if (err_code & (1 << 15)) msg += "Tension entrée anormale; ";
if (msg.empty()) msg = "Défaut inconnu";
id(drive_error).publish_state(msg.c_str());
}

const uint8_t op_state = rx[response_pos + 4];
const bool pump_on = (op_state & 0x01) != 0;

const uint16_t raw =
((uint16_t)rx[response_pos + 5] << 8) |
(uint16_t)rx[response_pos + 6];

id(c3_raw_text).publish_state(make_hex());

float actual_rpm = raw;
if (raw > 2900 && raw <= 5800) {
actual_rpm = raw / 2.0f;
}

if (actual_rpm >= 0.0f && actual_rpm <= 2900.0f) {
id(pump_rpm_state).publish_state(actual_rpm);
if (actual_rpm >= 1200.0f) {
id(pump_rpm).publish_state(actual_rpm);
}
id(pool_pump_switch).publish_state(pump_on);

id(last_response_ms) = millis();
id(read_fail_count) = 0;
id(offline_reported) = false;
id(modbus_status).publish_state("En ligne");

auto call = id(lilygo_led).turn_on();
call.set_rgb(0.0f, 1.0f, 0.0f);
call.set_brightness(0.25f);
call.perform();
} else {
id(read_fail_count)++;
}

id(comm_state) = 0;
return;

}

// Timeout poll : 350ms apres fin d'emission
// Logique validee sur le terrain :
//  - RX 0 octet (ou echo seul) -> EN LIGNE, LED verte
//  - octets recus SANS trame AA C3 valide (bruit/parasites)
//    -> HORS LIGNE, LED rouge
if ((now - id(rx_start_ms)) > 350) {
const size_t useful = n - search_from;

if (useful == 0) {
id(c3_raw_text).publish_state("RX 0 octet");
id(last_response_ms) = millis();
id(offline_reported) = false;
id(modbus_status).publish_state("En ligne");

auto call = id(lilygo_led).turn_on();
call.set_rgb(0.0f, 1.0f, 0.0f);
call.set_brightness(0.25f);
call.perform();
} else {
id(c3_raw_text).publish_state(make_hex());
id(modbus_status).publish_state("Hors ligne");

auto call = id(lilygo_led).turn_on();
call.set_rgb(1.0f, 0.0f, 0.0f);
call.set_brightness(0.25f);
call.perform();
}
id(comm_state) = 0;

}

interval: 2s
then:

lambda: |-
const uint32_t now_ms = millis();
const uint32_t dt_ms = now_ms - id(last_sample_ms);

id(last_sample_ms) = now_ms;

const float power_w = id(pump_power_w).state;

if (id(pump_running).state && power_w > 0.0f) {
id(run_energy_wh) += power_w * ((dt_ms / 1000.0f) / 3600.0f);
}