Contrôle local chauffe eau thermodynamique Thermor/Atlantic

Bonjour à tous,

Je suis en train d’essayer d’analyser la communication sur le bus de mon chauffe-eau thermodynamique Atlantic Aeromax split V2 (identique au Thermor calypso split et surement d’autres).

J’ai réussi à analyser avec un adaptateur série les différentes trames en me connectant sur le port du boitier de commande (9800 bauds).

On y lit les différentes valeurs des sondes et on voit passer les commandes lors des diverses manipulations du boitier.

Le but serait de remplacer le boitier d’origine par un microcontrôleur pour commander le chauffe-eau plus facilement qu’avec l’application mobile.

Avant d’aller plus loin, je voulais savoir si certains avaient déjà bossé là-dessus et surtout si cela intéresse un peu de monde !

En espérant avoir quelques retours… :slight_smile:

Ca sent le « reverse engineering » et la gestion du surplus solaire ta demande :smiley:

Bon je ne peut pas t’aider mais je peut te donner mon avis sur des pistes a suivre…

malheureusement … trop peu de constructeur jouent le jeu de l’open source et sont donc très avares d’infos sur leurs systèmes de gestion et protocoles de communication …
Mais si dejas tu arrive a faire remonter les infos de températures dans jeedom c’est cool !
Il te reste donc a bypasser leur système propriétaire et donc fermé a toutes modifications ou prise de contrôle pour le remplacer par le tiens en fonction de tes compétences… tout en gardant le système d’origine fonctionnel au cas ou …

Donc soit avec des modules a contact sec a installer sur le chauffe eau pour la mise en marche forcée de la pompe a chaleur et de la résistance d’appoint … voir … pour la résistance d’appoint, un module dimmer robodyn contrôlé par un wemos D1 en wifi et donc pouvoir injecter une puissance modulable en fonction de ta surproduction solaire dans la résistance directement contrôlé par jeedom… :wink:

ce qui implique de modifier le câblage interne de ton chauffe eau thermodynamique mais qui reste la solution la plus simple…

Soit … et la ca deviens du reverse engineering pur et dur ! … cracker leurs code pour une prise de contrôle total de leur système !
Sur ce coup … je ne peut pas t’aider, je n’ai pas les compétences …

Perso, j’ai abandonner l’idée d’installer un chauffe eau thermodynamique pour ces raisons… mais aussi car, en France, comme on nous prends pour des cons sur la gestions du solaire et du rachat de la surproduction… de mon point de vue, c’est plus rentable et écologique pour moi de conserver mon bon vieux chauffe eau de 30 ans qui fonctionne très bien et d’y injecter la totalité de ma surproduction !
Cela fait maintenant 1 ans que la totalité de ma production d’eau chaude est a 99% solaire grâce a jeedom et un routeur diy wemosD1 et carte robodyn :wink:

C’est exactement ça !!!

Pour la résistance d’appoint, j’ai déjà fait quelques modifications et fait quelques tests avec un dimmer en manuel.

Pour info sur ce type de chauffe-eau la résistance stéatite fait 1800W soit 29,4 ohms.
Ce sont en fait deux fils résistifs de 58,8 ohms en parallèle
Je les ai recâblées en série pour faire une résistance de 117,6 Ohms (environ 450W), ça permet de réduire les parasites dans le réseau et ça soulage un peu le dimmer…

Je pense qu’il est possible de piloter l’inverter de la pompe en direct assez facilement, mais l’intérêt reste limité pour moi.
Le simple fait d’avoir la main sur l’interface utilisateur présente sur le CE via la domotique est très largement suffisant pour démarrer ou arrêter la pompe à chaleur sur demande. Et ça permet surtout de conserver toutes les sécurités de mise en marche pour ne pas faire travailler la pompe en dehors de ses plages de températures fonctionnement.

En effet c’est un calcul à faire quand on a une production photovoltaïque et qu’on sait bricoler pour se faire un petit routeur solaire ! Instinctivement, je dirai que si on a 2000 euros à dépenser, il est plus judicieux d’installer 1500W de panneaux que d’acheter un chauffe-eau thermodynamique.

Voici les échanges en hexadécimal sur le bus :

Le boitier de commande :

 C2 23 21 02 02 00 04 00 00 00 00 58 20 30 00 00 00 00 11 21 02 3a 14 00 00 00 00 00 ff ff ff ae

Une seconde trame venant des cartes de contrôles :

 43 1f 09 00 00 00 00 00 00 00 00 00 78 06 00 00 12 00 00 00 88 06 00 00 5a cf 14 00 00 00 00 00 75

une troisième trame venant également des cartes de contrôles :

 C1 25 6c 02 49 02 6e 02 00 00 96 00 00 00 00 00 00 00 04 00 00 00 00 40 ff ff ff 00 53 46 21 02 00 00 c8 00 54 00 eb

Une première analyse :

431f0900
00000000
00000000
78060000 heure 1656 (PAC)
12000000 heure 18 (résistance éléc)
88060000 heure 1672 (total ?)
5acf1400 consommation 1363802 Wh
00000000
75 CRC XOR

et

C1
256c
0249 température sonde 1 585 soit 58,5° (eau chaude)
026e température sonde 2 622 soit 62,2° (haut ballon)
0200 température sonde 3 512 soit 51,2° (condenseur)
0096 température sonde 4 150 soit 15° (extérieur)
0000
0000
0000
0004
0000
0000
40ff
ffff
0053
4621
0200
00c8
0054
00
eb CRC XOR

Pas mal ! Joli reverse engineering !

Perso, je serai parti de l’interface web (locale?) et j’aurais cherché, avec le débogueur, comment sont récup les données dans la page. Si elles sont injectée directement dans le HTML, c’est un peu compliqué de scraper, si c’est une api avec du json, tu est très vite le roi du pétrole.

Édit : Tu as vu ça :
« Le ballon thermodynamique possède son propre protocole de connexion, via un bridge CozyTouch afin de le rendre « connecté » et de s’interfacer à la box Jeedom via le plugin dédié sur le market de Jeedom. »

Bad

Il n’y a pas d’interface locale, tout passe par le cloud via cozytouch, pour l’instant j’utilise le plugin Cozytouch pour interfacer le ballon avec jeedom mais c’est assez limité en fait…

J’ai un peu avancé depuis mon dernier message, j’ai décodé presque toutes les fonctions de base et j’arrive à piloter le chauffe-eau sans son IHM même si pour l’instant c’est fait « à l’arrache »…

Il me reste encore à analyser les codes défauts et quelques autres parties des trames, mais voici ce que ça donne :

frameStruct_C2 = Struct(
    "msg_id" / Default(Byte, 0xC2),
    "frame_length" /  Default(Byte, 35),
    "consigne" / Default(Int16sl, 500),
    "plage_mode" / BitStruct(
        "plage" / Default(Nibble, 0b0000),
        "mode" / Default(Nibble, 0b0000),
    ),
    "byte5" / Default(Byte, 0),
    "legionelle" / Default(Byte, 0),
    "mode_secours" / Default(Byte, 0),
    "byte8" / Default(Byte, 0),
    "byte9" / Default(Byte, 0),
    "autorisation_elec" / Default(Byte, 0),
    "plage_chaufe_debut_1" / Default(Byte, 0),
    "plage_chaufe_duree_1" / Default(Byte, 0),
    "plage_chaufe_debut_2" / Default(Byte, 0),
    "plage_chaufe_duree_2" / Default(Byte, 0),
    "byte15" / Default(Byte, 0),
    "byte16" / Default(Byte, 0),
    "Defaut ?" / BitStruct(
        "byte17_1" / Default(Bit, 0),
        "Perte de l'heure" / Default(Bit, 0),
        "byte17_3" / Default(Bit, 0),
        "byte17_4" / Default(Bit, 0),
        "byte17_5" / Default(Bit, 0),
        "byte17_6" / Default(Bit, 0),
        "byte17_7" / Default(Bit, 0),
        "byte17_8" / Default(Bit, 0),
    ),
    "seconde" / Default(Byte, 56),
    "date" / ExprAdapter(Default(Int16ul, 545),
        decoder = lambda obj, ctx: obj,
        encoder = lambda obj, ctx: (datetime.now().year - 2016) << 9 | (datetime.now().month << 5) | datetime.now().day,
    ),
    "annee" / Computed(lambda ctx: (ctx.date  >> 9) + 2016 if ctx.date is not None else Byte),
    "mois" / Computed(lambda ctx: ctx.date >> 5 & 0xF if ctx.date is not None else Byte),
    "jour" / Computed(lambda ctx: ctx.date & 0x1F if ctx.date is not None else Byte),   
    "minute" / Default(Byte, 47),
    "heure" / Default(Byte, 19),
    "mode_test_bool" / BitStruct(
        "byte23_1" / Default(Bit, 0),
        "byte23_2" / Default(Bit, 0),
        "byte23_3" / Default(Bit, 0),
        "byte23_4" / Default(Bit, 0),
        "test_pac_froid_a_verif" / Default(Bit, 0),
        "test_pac_chaud_a_verif" / Default(Bit, 0),
        "test_mode_pac_elec_a_verif" / Default(Bit, 0),
        "byte23_8" / Default(Bit, 0),
    ),
    "byte24" / Default(Byte, 0),
    "byte25" / Default(Byte, 0),
    "byte26" / Default(Byte, 0),
    "byte27" / Default(Byte, 0x03),
    "byte28" / Default(Byte, 0),
    "byte29" / Default(Byte, 0),
    "byte30" / Default(Byte, 0),
    "byte31" / Default(Byte, 0),
    "byte32" / Default(Byte, 0),
    "byte33" / Default(Byte, 0xFF),
    "byte34" / Default(Byte, 0xFF),
    "byte35" / Default(Byte, 0xFF),
    "crc" /  Default(Byte, 0x00), 
)

frameStruct_43 = Struct(
    "msg_id" / Byte,
    "frame_length" / Byte,
    "p_pac" / Int16ul,
    "p_elec" / Int16ul,
    "reserve_16bit_1" / Int16ul,
    "reserve_16bit_2" / Int16ul,
    "reserve_16bit_3" / Int16ul,
    "pac_time" / Int32ul,
    "elec_time" / Int32ul,
    "total_time" / Int32ul,
    "conso" / Int32ul,
    "reserve_32bit" / Int32ul,
    "crc" / Byte,
)

frameStruct_C1 = Struct(
    "msg_id" / Byte,
    "frame_length" / Byte,
    "temp_ballon" / Int16sl,
    "temp_condensseur" / Int16sl,
    "temp_haut" / Int16sl,
    "temp_reserve_1" / Int16sl,
    "temp_ext" / Int16sl,
    "temp_reserve_2" / Int16sl,
    "temp_reserve_3" / Int16sl,
    "temp_reserve_4" / Int16sl,
    "etat_bool" / BitStruct(
        "def_sonde_1_2ballon_condens" / Bit,
        "def_sonde_2_2ballon" / Bit,
        "byte18_3" / Bit,
        "byte18_4" / Bit,
        "pac_froid" / Bit,
        "pac_ok" / Bit,
        "pac_marche" / Bit,
        "relais_elec" / Bit,
    ),
    "com_carte_regul" / Byte,
    "byte20" / Byte,
    "vitesse_compresseur" / Byte,
    "Int32ul_1" / Int32ul,
    "Int32ul_2" / Int32ul,
    "temp_consigne" / Int16ul,
    "Int16ul_1" / Int16ul,
    "Int16ul_2" / Int16ul,
    "Int16ul_3" / Int16ul,
    "crc" / Byte,
)

# Constantes pour les types de trame

frames_config: List[Dict[str, Union[int, bytes, Struct]]] = [
    {
        "frame_id": 0xC2,
        "payload_length": 0x23,
        "frame_marker": (0xC2, 0x23),
        "structure": frameStruct_C2,
    },
    {
        "frame_id": 0x43,
        "payload_length": 0x1F,
        "frame_marker": (0x43, 0x1F),
        "structure": frameStruct_43,
    },
    {
        "frame_id": 0xC1,
        "payload_length": 0x25,
        "frame_marker": (0xC1, 0x25),
        "structure": frameStruct_C1,
    },
]

class consigne_enum(Enum):
    _3_DOUCHES =    500
    _4_DOUCHES =    525
    _5_DOUCHES =    545
    BOOST =         500
    ABSENCE =       200
    LEGIONELLE =    620
    SECOURS =       650

class plages_enum(Enum):
    PAC_24h_ELEC_24h =      0b0000 # 0
    PAC_24h_ELEC_HC =       0b0001 # 1
    PAC_HC_ELEC_24h =       0b0010 # 2 Non disponible
    PAC_HC_ELEC_HC =        0b0011 # 3
    PAC_Prog_ELEC_Prog =    0b0100 # 4

class mode_config_enum(Enum):
    ABSENCE =       0b0000 # 0
    MANUEL_ECO =    0b0001 # 1 # L’appoint électrique démarre seulement en cas d’alarme PAC ou si la PAC est hors plage de températures.
    MANUEL =        0b0010 # 2 # L’appoint électrique démarre si la pompe à chaleur ne chauffe pas assez vite ou si la PAC est hors plage de températures.
    BOOST =         0b0011 # 3
    AUTO =          0b0100 # 4 # L’appoint électrique démarre si la pompe à chaleur ne chauffe pas assez vite ou si la PAC est hors plage de températures.
1 « J'aime »

Hello si tu arrive à faire ça je suis preneur sur thermor malicio 3

Bonjour
Moi aussi ça m’intéresse avec mon chauffe eau split
:partying_face:

Bonjour,

Moi aussi je suis preneur pour un Calypso connecté d’Atlantic :smile:

Bonjour,
Je suis absolument sidéré des mauvaises critiques concernant les chauffe-eaux thermodynamiques, non rentables, ça fuit, ça fait du bruit, difficile à domotiser, bref.
J’ai le tout dernier modèle d’Atlantic, avec split extérieur. C’est mieux surtout pour ceux qui ont une maison individuelle, mais sinon ce n’est que du bonheur.
Je consomme environ 1 kw alors que mon ancien ballon consommait 3,5 Kw pour exactement le même temps de fonctionnement pour récuperer mon eau chaude consommée en une journée. L’avantage saute aux yeux immédiatement.
Comme j’ai des panneaux solaires, je chauffe en journée (je suis avantagé car près de Marseille), en utilisant 80 % des panneaux, donc coût très faible. Ce qui était impossible avec mon ancien ballon, car placer 3 KW pendant 1 heure et demie en pleine journée c’était impossible à partir d’octobre jusqu’à mars, alors que avec ma nouvelle installation qui consiste à passer environ 600 w pendant une heure et demie, c’est sans souci toute l’année !
De plus j’ai automatisé le processus, je regarde si le jour j’ai de la revente à EDF de presque 1 kw, si oui je chauffe, sinon je ne chauffe pas, et je fais ce test toutes les 15 minutes. Le soir je regarde (seul truc visuel, car je ne sais pas faire automatiquement, le plugin Cozytouch n’est pas compatible) si j’ai au moins 80 % d’eau chaude et si oui je ne chauffe pas de nuit, si non, je chauffe en heure de nuit.
Un petit renseignement technique, la mise en route du chauffe eau est très simple, je commande un commutateur qui simule l’heure de nuit, et dans les paramètres du chauffe eau je dis qu’il ne fonctionne qu’en heure de nuit.

Désolé de ne pas avoir répondu plus tôt, je suis parti sur autre chose pour le moment…

Dès que j’ai un peu de temps et de motivation,je reprendrai le développement.

@grouais, je fais le même constat que toi, pour savoir si un CE thermodynamique est rentable il suffit de savoir en combien de temps il est amortissable, le choix doit se faire sur la base d’un calcul et non d’un sentiment ou de « on dit ».

Les techniques du contact heure creuse ou du mode absence fonctionnent,mais ne sont pas parfaites :

  • La sonde basse commande l’arrêt de la chauffe, dès qu’elle mesure une température supérieure à la température de consigne, le CE s’arrête.

  • La sonde haute commande le démarrage de la chauffe, dès qu’elle mesure une température inférieure à la consigne, le CE démarre.

La stratification de l’eau fait que le ballon peut être presque entièrement rempli d’eau froide et ne pas démarrer tout de suite, je ne trouve pas ça pratique.

Autre chose qui n’a rien à voir avec la chauffe, lorsque la température extérieure passe sous les 5 degrés, le CE consomme 35W au lieu de 6w en veille pour garder l’huile du compresseur à température.

L’hiver par grand froid c’est 0,840 Wh de gaspillé chaque jour, soit le même ordre de grandeur que l’énergie nécessaire pour chauffer l’eau…

Ma solution c’est de couper totalement l’alimentation du CE, se pose alors le problème du système anticorrosion.

Pour garder un courant dans l’anode même quand le ballon n’est pas alimenté, j’ai shunté avec une résistance de 500K l’alim de l’anode (sinon le CE se met en défaut) et j’ai branché une batterie Li-Ion en direct à la place.

Le couple galvanique étant d’environ 2V, les 2,8 à 4,2V de la batterie sont largement suffisant (de mémoire le CE envoi entre 3 et 4V)

Je ne suis pas tout à fait ok pour toutes tes remarques. A suivre.
Concernant le -5, je ne serai jamais ou presque jamais concerné, j’ai cette chance.
Par contre hier, coupure de courant Enedis pendant 2 heures, et je l’ai déjà remarqué, à la suite de coupure longue, le chauffe eau ne détecte plus le 220V qui dit que l’on est en heures creuses. C’est très génant. Il suffit de couper le chauffe-eau 30 secondes, de le rallumer et c’est bon. Je vais faire des essais avant de leur signaler, ils sont très à l’écoute du client.

C’est à +5°c que la réchauffe du compresseur s’active…

Hello,

Tu aurais un code pour tester exploitable afin de comparer ?
(j’ai un Calypso de chez atlantic mais visiblement les trames sont équivalente sur tous les modèles presque je pense, même mode, même programmation etc …)

Voici le code brut de fonderie, c’est pas très propre :slight_smile:

la variable EMULATION est réglée sur True, pour passer sur l’interface série raccordée au materiel il faut mettre False.

import serial
from construct import *
from construct import Int16sl, Int16ul, Int32ul, Nibble, If, Computed
from PyQt5.QtWidgets import *
import sys
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from bitstring import BitStream
from typing import *
from datetime import datetime
from enum import Enum
from functools import reduce
EMULATION = True
port_serie = 'COM3'
frameStruct_C2 = Struct(
    "msg_id" / Default(Byte, 0xC2),
    "frame_length" /  Default(Byte, 35),
    "consigne" / Default(Int16sl, 500),
    "plage_mode" / BitStruct(
        "plage" / Default(Nibble, 0b0000),
        "mode" / Default(Nibble, 0b0000),
    ),
    "byte5" / Default(Byte, 0),
    "legionelle" / Default(Byte, 0),
    "mode_secours" / Default(Byte, 0),
    "byte8" / Default(Byte, 0),
    "byte9" / Default(Byte, 0),
    "autorisation_elec" / Default(Byte, 0),
    "plage_chaufe_debut_1" / Default(Byte, 0),
    "plage_chaufe_duree_1" / Default(Byte, 0),
    "plage_chaufe_debut_2" / Default(Byte, 0),
    "plage_chaufe_duree_2" / Default(Byte, 0),
    "byte15" / Default(Byte, 0),
    "byte16" / Default(Byte, 0),
    "Defaut ?" / BitStruct(
        "byte17_1" / Default(Bit, 0),
        "Perte de l'heure" / Default(Bit, 0),
        "byte17_3" / Default(Bit, 0),
        "byte17_4" / Default(Bit, 0),
        "byte17_5" / Default(Bit, 0),
        "byte17_6" / Default(Bit, 0),
        "byte17_7" / Default(Bit, 0),
        "byte17_8" / Default(Bit, 0),
    ),
    "seconde" / Default(Byte, 56),
    "date" / ExprAdapter(Default(Int16ul, 545),
        decoder = lambda obj, ctx: obj,
        encoder = lambda obj, ctx: (datetime.now().year - 2016) << 9 | (datetime.now().month << 5) | datetime.now().day,
    ),
    "annee" / Computed(lambda ctx: (ctx.date  >> 9) + 2016 if ctx.date is not None else Byte),
    "mois" / Computed(lambda ctx: ctx.date >> 5 & 0xF if ctx.date is not None else Byte),
    "jour" / Computed(lambda ctx: ctx.date & 0x1F if ctx.date is not None else Byte),   
    "minute" / Default(Byte, 47),
    "heure" / Default(Byte, 19),
    "mode_test_bool" / BitStruct(
        "byte23_1" / Default(Bit, 0),
        "byte23_2" / Default(Bit, 0),
        "byte23_3" / Default(Bit, 0),
        "byte23_4" / Default(Bit, 0),
        "test_pac_froid_a_verif" / Default(Bit, 0),
        "test_pac_chaud_a_verif" / Default(Bit, 0),
        "test_mode_pac_elec_a_verif" / Default(Bit, 0),
        "byte23_8" / Default(Bit, 0),
    ),
    "byte24" / Default(Byte, 0),
    "byte25" / Default(Byte, 0),
    "byte26" / Default(Byte, 0),
    "byte27" / Default(Byte, 0x03),
    "byte28" / Default(Byte, 0),
    "byte29" / Default(Byte, 0),
    "byte30" / Default(Byte, 0),
    "byte31" / Default(Byte, 0),
    "byte32" / Default(Byte, 0),
    "byte33" / Default(Byte, 0xFF),
    "byte34" / Default(Byte, 0xFF),
    "byte35" / Default(Byte, 0xFF),
    "crc" /  Default(Byte, 0x00), 
)

frameStruct_43 = Struct(
    "msg_id" / Byte,
    "frame_length" / Byte,
    "p_pac" / Int16ul,
    "p_elec" / Int16ul,
    "reserve_16bit_1" / Int16ul,
    "reserve_16bit_2" / Int16ul,
    "reserve_16bit_3" / Int16ul,
    "pac_time" / Int32ul,
    "elec_time" / Int32ul,
    "total_time" / Int32ul,
    "conso" / Int32ul,
    "reserve_32bit" / Int32ul,
    "crc" / Byte,
)

frameStruct_C1 = Struct(
    "msg_id" / Byte,
    "frame_length" / Byte,
    "temp_ballon" / Int16sl,
    "temp_condensseur" / Int16sl,
    "temp_haut" / Int16sl,
    "temp_reserve_1" / Int16sl,
    "temp_ext" / Int16sl,
    "temp_reserve_2" / Int16sl,
    "temp_reserve_3" / Int16sl,
    "temp_reserve_4" / Int16sl,
    "etat_bool" / BitStruct(
        "def_sonde_1_2ballon_condens" / Bit,
        "def_sonde_2_2ballon" / Bit,
        "byte18_3" / Bit,
        "byte18_4" / Bit,
        "pac_froid" / Bit,
        "pac_ok" / Bit,
        "pac_marche" / Bit,
        "relais_elec" / Bit,
    ),
    "com_carte_regul" / Byte,
    "byte20" / Byte,
    "vitesse_compresseur" / Byte,
    "Int32ul_1" / Int32ul,
    "Int32ul_2" / Int32ul,
    "temp_consigne" / Int16ul,
    "Int16ul_1" / Int16ul,
    "Int16ul_2" / Int16ul,
    "Int16ul_3" / Int16ul,
    "crc" / Byte,
)

# Constantes pour les types de trame

frames_config: List[Dict[str, Union[int, bytes, Struct]]] = [
    {
        "frame_id": 0xC2,
        "payload_length": 0x23,
        "frame_marker": (0xC2, 0x23),
        "structure": frameStruct_C2,
    },
    {
        "frame_id": 0x43,
        "payload_length": 0x1F,
        "frame_marker": (0x43, 0x1F),
        "structure": frameStruct_43,
    },
    {
        "frame_id": 0xC1,
        "payload_length": 0x25,
        "frame_marker": (0xC1, 0x25),
        "structure": frameStruct_C1,
    },
]

class consigne_enum(Enum):
    _3_DOUCHES =    500
    _4_DOUCHES =    525
    _5_DOUCHES =    545
    BOOST =         500
    ABSENCE =       200
    LEGIONELLE =    620
    SECOURS =       650

class plages_enum(Enum):
    PAC_24h_ELEC_24h =      0b0000 # 0
    PAC_24h_ELEC_HC =       0b0001 # 1
    PAC_HC_ELEC_24h =       0b0010 # 2 Non disponible
    PAC_HC_ELEC_HC =        0b0011 # 3
    PAC_Prog_ELEC_Prog =    0b0100 # 4

class mode_config_enum(Enum):
    ABSENCE =       0b0000 # 0
    MANUEL_ECO =    0b0001 # 1 # L’appoint électrique démarre seulement en cas d’alarme PAC ou si la PAC est hors plage de températures.
    MANUEL =        0b0010 # 2 # L’appoint électrique démarre si la pompe à chaleur ne chauffe pas assez vite ou si la PAC est hors plage de températures.
    BOOST =         0b0011 # 3
    AUTO =          0b0100 # 4 # L’appoint électrique démarre si la pompe à chaleur ne chauffe pas assez vite ou si la PAC est hors plage de températures.    

class config:
    def __init__(self):
        self.plage = plages_enum.PAC_24h_ELEC_24h
        #self.mode = mode_enum.ABSENCE
        self.consigne = consigne_enum.ABSENCE
        self.time = 0

    def update(self):
        True

class Frame:
    def __init__(self, frames_config: List[Dict[str, Union[int, bytes, Struct]]], rawFrame: bytes):
        self.frames_config = frames_config
        self.rawFrame = b''
        self.rawLength = 0
        self.Type = 0
        self.dataLength = 0
        self.crc = 0x00
        self.isValid = False
        self.parsed = None
        if rawFrame:
            self.update(rawFrame)

    def update(self, rawFrame: bytes) -> Union[None, dict]:
        self.rawFrame = rawFrame
        self.rawLength = len(rawFrame)
        self.Type = self.rawFrame[0]
        self.dataLength = self.rawFrame[1]
        self.crc = self.rawFrame[-1]
        self.isValid = self.crc_compare() and self.rawLength == self.dataLength + 2
        if self.isValid:
            self.parse()
        else:
            print("CRC invalide", self.rawFrame[0])
        return self.parsed

    def crc_compare(self) -> bool:
        crc = reduce(lambda x, y: x ^ y, self.rawFrame[1:-1])
        return crc == self.rawFrame[-1]
    
    def crc_calc(self, payload):
        self.crc = reduce(lambda x, y: x ^ y, payload[:-1])
        return self.crc
  
    def parse(self) -> None:
        for frame_info in self.frames_config:
            if frame_info["frame_id"] == self.Type:
                self.parsed = frame_info["structure"].parse(self.rawFrame)
                break
            else:
                self.parsed = None
        return
    
class ReadBus:
    def __init__(self, frames_config: List[Dict[str, Union[int, bytes, Struct]]]):
        self.frames_config = frames_config
        self.frames_markers = [frame_info["frame_marker"] for frame_info in frames_config]
        self.read_bits = 1
        self.buffer = bytearray()
        self.frame_list: List[Frame] = []
        if not EMULATION:
            self.init_serial()

    def init_serial(self) -> None:
        self.serialBus = serial.Serial(port_serie, baudrate=9600, timeout=0)
        return
    
    def read(self, frame) -> Union[None, dict]:
        if EMULATION:
            data = frame
        else:
            data = self.serialBus.read(self.read_bits)

        if data:
            self.buffer.extend(data)

            if not EMULATION and len(self.buffer) == 1 and self.buffer[0] == b'\xc2':
                self.serialBus.write(frame[1:-1])
                return None

            self.alignBuffer()

            if len(self.buffer) >= 40:
                self.extract_frame()

        if self.frame_list_len() > 0:
            return self.frame_list[0].parsed
        return None

    def frame_list_len(self) -> int:
        return len(self.frame_list)
        
    def frame_begin_check(self, marker_1: Union[None, bytes] = None, marker_2: Union[None, bytes] = None) -> bool:
        if marker_1 is not None and marker_2 is not None:
            return (marker_1, marker_2) in self.frames_markers
        else:
            return (self.buffer[0], self.buffer[1]) in self.frames_markers
    
    def alignBuffer(self) -> None:
        while len(self.buffer) > 2 and not self.frame_begin_check():
            #print("Alignement", self.buffer)
            self.buffer = self.buffer[1:]

        if len(self.buffer) >= 109:
            self.read_bits = 1
        return

    def extract_frame(self) -> None:
        i = 0
        buff_len = len(self.buffer)
        if buff_len < 2:
            return
        
        while i < buff_len - 1:
            frame_type = self.buffer[i]
            data_length = self.buffer[i + 1]
            if self.frame_begin_check(frame_type, data_length):
                frame_length = data_length + 2
                if i + frame_length <= buff_len:
                    self.ajouter_frame(self.buffer[i:i + frame_length])
                    self.buffer = self.buffer[i + frame_length :]
                    if not self.frame_list[-1].isValid:
                        self.supprimer_frame_la_plus_recente()
                        self.buffer = self.buffer[1:]
                        break
                    break
                else:
                    break
            else:
                i += 1
            
        return
    
    def ajouter_frame(self, data: bytes) -> None:
        frame = Frame(self.frames_config, data)
        self.frame_list.append(frame)

    def supprimer_frame_la_plus_ancienne(self) -> None:
        if len(self.frame_list) > 0:
            frame_supprimee = self.frame_list.pop(0)
        else:
            print("Aucune frame à supprimer, la liste est vide.")

    def supprimer_frame_la_plus_recente(self) -> None:
        if self.frame_list:
            frame_supprimee = self.frame_list.pop()
        else:
            print("Aucune frame à supprimer, la liste est vide.")

class UI(QMainWindow):
    def __init__(self):
        super().__init__()
        self.init_ui()
        self.readBus = ReadBus(frames_config)
        
        self.previous_data: Dict[int, dict] = {frame_info["frame_id"]: {} for frame_info in frames_config}

        self.timer = QTimer(self)
        self.timer.timeout.connect(self.add_data_to_table)
        self.timer.start(1)

    def init_ui(self) -> None:
        self.send_data_fields = 0
        self.mode = mode_config_enum.BOOST.value
        self.central_widget = QTabWidget()
        self.setCentralWidget(self.central_widget)
        self.tab_reception = QWidget()
        self.tab_envoi = QWidget()
        self.central_widget.addTab(self.tab_reception, "Réception de données")
        self.central_widget.addTab(self.tab_envoi, "Envoi de données")

        self.layout_reception = QHBoxLayout()
        self.layout_envoi = QHBoxLayout()
        self.tab_reception.setLayout(self.layout_reception)
        self.tab_envoi.setLayout(self.layout_envoi)
        self.frames_ids = [frame_info["frame_id"] for frame_info in frames_config]
        self.tables_list: Dict[QTableWidget] = {}

        for frame_info in frames_config:
            frame_id = frame_info["frame_id"]
            self.tables_list[frame_id] = self.create_table()
            self.layout_reception.addWidget(self.tables_list[frame_id])

        self.layout_envoi.addWidget(self.create_send_data_widgets())  # Ajouter les champs d'envoi de données

    def create_send_data_widgets(self):
        send_data_widget = QWidget()
        layout = QFormLayout()  # Utilisez un QFormLayout pour placer les champs en face des noms
        send_data_widget.setLayout(layout)       

        field_input1 = QLineEdit()
        layout.addRow(QLabel("consigne:"), field_input1)

        mode_combo = QComboBox()
        mode_combo.addItem("ABSENCE")
        mode_combo.addItem("MANUEL_ECO")
        mode_combo.addItem("MANUEL")
        mode_combo.addItem("BOOST")
        mode_combo.addItem("AUTO")
        mode_combo.currentIndexChanged.connect(self.on_mode_selection)
        layout.addRow(QLabel("mode:"), mode_combo)

        print(self.send_data_fields)

        # Créez un bouton d'envoi
        send_button = QPushButton("Envoyer")
        send_button.clicked.connect(self.send_data)  # Connectez-le à une méthode d'envoi
        layout.addRow(send_button)

        return send_data_widget
    
    def on_mode_selection(self, index):
        if index >= 0:
            selected_mode = mode_config_enum(index).value
            choix_mode = selected_mode
            print("Mode sélectionné:", choix_mode)
            self.send_data_fields = choix_mode

    def send_data(self):
        self.mode = self.send_data_fields

    def create_table(self) -> QTableWidget:
        table = QTableWidget()
        table.setColumnCount(4)
        table.setHorizontalHeaderLabels(["Frame Type", "Decimal Value", "Hex Value", "Binary Value"])

        font = table.font()
        font.setPointSize(8)
        table.setFont(font)

        table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
        table.verticalHeader().setDefaultSectionSize(20)
        table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
        table.horizontalHeader().setDefaultSectionSize(100)
        table.setRowCount(1)

        table.setItem(0, 0, QTableWidgetItem("Variable"))
        table.setItem(0, 1, QTableWidgetItem("Decimal Value"))
        table.setItem(0, 2, QTableWidgetItem("Hex Value"))
        table.setItem(0, 3, QTableWidgetItem("Binary Value"))

        return table



       
    def add_data_to_table(self) -> None:
        if EMULATION:
            data = self.readBus.read(self.serialize())
        else :
            data = self.readBus.read(False)
        #data = False
        if data:
            self.readBus.supprimer_frame_la_plus_ancienne()
            frame_type = data["msg_id"]
            
            table: QTableWidget = None
            for frame_info in frames_config:
                if frame_info["frame_id"] == frame_type:
                    table = self.tables_list[frame_type]
                    break
            else:
                print(f"Table for frame_type {frame_type} not found")
                return

            row_position = 0
            for field, value in list(data.items())[1:]:
                is_container = isinstance(value, Container)
                valQTable = self.bitToInt(value) if is_container else value
                #valQTable = 255 if is_container else value

                fieldColumn, intColumn, hexColumn, bitColumn = 0, 1, 2, 3

                table.setRowCount(row_position + 1)
                table.setItem(row_position, fieldColumn, QTableWidgetItem(field))
                table.setItem(row_position, intColumn, QTableWidgetItem(str(valQTable)))
                table.setItem(row_position, hexColumn, QTableWidgetItem(self.hexPrint(valQTable)))
                table.setItem(row_position, bitColumn, QTableWidgetItem(self.binPrint(valQTable)))

                if is_container:
                    row_position += 1
                    fieldColumn, intColumn = 1, 2
                    valQTable = value

                    for field, value in list(value.items())[1:]:
                        valQTable = value
                        table.setRowCount(row_position + 1)
                        table.setItem(row_position, fieldColumn, QTableWidgetItem(str(field)))
                        table.setItem(row_position, intColumn, QTableWidgetItem(str(valQTable)))
                        if self.data_changed(frame_type, field, value):
                            self.updateHighlight(table, row_position, intColumn)
                        row_position += 1
                else :              
                    if self.data_changed(frame_type, field, value):
                        self.updateHighlight(table, row_position, intColumn)
                row_position += 1             

    def serialize(self):
        consigne = consigne_enum._4_DOUCHES.value
        mode = self.mode

        maintenant = datetime.now()
        heure = maintenant.hour
        minute = maintenant.minute
        seconde = maintenant.second
        date = (maintenant.year - 2016) << 9 | (maintenant.month << 5) | maintenant.day

        send_frame = frameStruct_C2.build(
            {
                "consigne": consigne,
                "plage_mode": {
                    "mode": mode
                },
                "heure": heure,
                "minute": minute,
                "seconde": seconde
            }
        )

        crc = reduce(lambda x, y: x ^ y, send_frame[1:-1])

        send_frame = frameStruct_C2.build(
            {
                "crc": crc,
                "consigne": consigne,
                "plage_mode": {
                    "mode": mode
                },
                "heure": heure,
                "minute": minute,
                "seconde": seconde
            }
        )
        return send_frame

    def binPrint(self, value: int) -> str:
        return ' '.join(format(value, '016b')[i:i+4] for i in range(0, 16, 4))

    def hexPrint(self, value: int) -> str:
        hex_string = '{:0{width}X}'.format(value, width=((value.bit_length() + 7) // 8) * 2)
        hex_data = ' '.join([hex_string[i:i+2] for i in range(0, len(hex_string), 2)])
        return str(hex_data)
    
    def bitToInt(self, bitTable: Container) -> int:
        if len(bitTable) == 9:
            return int(''.join(map(str, [int(bitvalue) for bitvalue in list(bitTable.values())[1:]])), 2)
        elif len(bitTable) == 3:
            return int(''.join(map(str, [format(bitvalue, '04b') for bitvalue in list(bitTable.values())[1:]])), 2)
     
    def updateHighlight(self, table: QTableWidget, row: int, column: int) -> None:
        table.item(row, column).setBackground(QColor(255, 0, 0))

    def data_changed(self, frame_type: int, field: str, value: int) -> bool:
        previous_data = self.previous_data[frame_type]
        if field in previous_data and previous_data[field] != value and not field.endswith("_bool"):
            return True
        else:
            self.previous_data[frame_type][field] = value
            return False


if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = UI()
    window.show()
    sys.exit(app.exec_())