Chauffage solaire avec Raspberry Pi

Hello hello! Comme indiqué dans le titre, nous allons parler dans cet article de mon installation perso de chauffage solaire. Ça fait un bon moment que j’avais mis ça en place en fait (presque 2 ans je dirais… ? Mais bon, avec ma mémoire complètement HS, j’en sais rien en fait, je raconte peut être des cracks 🥴). Et tout marche plutôt très bien jusque là: en été, on coupe le chauffe-eau électrique complètement, et la température de l’eau chaude dans le ballon de stockage peut monter facile jusqu’à 80°C.

Le principe de fonctionnement

Le principe au final est relativement simple: un boitier de contrôle reçoit quatre valeurs de température en entrée:

  1. La température qu’il fait dans un des panneaux solaires thermiques sur le toit
  2. La température dans le circuit de chauffage à l’arrivée depuis les panneaux solaires dans la chaufferie (avant d’aller chauffer le ballon de stockage)
  3. La température dans le circuit de chauffage à la sortie après avoir chauffé notre eau chaude sanitaire (avant de remonter vers les panneaux solaires)
  4. La température dans le ballon de stockage d’eau chaude sanitaire.

En fait parmi ces températures, seules T1 et T4 sont vraiment utiles: les températures intermédiaires dans le circuit de chauffage, c’est plus à titre informatif au final. Et le contrôleur va donc tout simplement calculer des moyennes glissantes pour ces températures et ensuite comparer les valeurs (moyennes) de température sur le toit et dans le ballon: Si la situation est favorable, alors on active le circulateur pour récupérer la chaleur depuis le toit et l’injecter dans le ballon de stockage. et sinon, ben on coupe le circulateur of course! Et c’est tout ce dont on a besoin en fait 😉!

Remplacement du contrôleur

Seulement, pour cette installation de base j’utilisais jusque là un peut boitier fait-maison avec une carte Arduino pour contrôler l’alimentation du circulateur sur le circuit de chauffage:

Alors comme je le disais, ça fonctionnait très bien déjà, mais en ce moment j’essaie de « moderniser un peu mon installation » (parce que j’ai de grannnnnddds projets pour l’avenir [enfin en admettant qu’il nous en reste un, d’avenir… 🤣 mais ça c’est une autre histoire…]). Et j’ai donc entrepris de remplacer ce boitier Arduino par un raspberry pi! Avec connexion au réseau, plus de relais, disque dur et tout et tout hein:

Nouveau contrôleur à base de Raspberry Pi

A noter que maintenant j’ai beaucoup plus de place dans ce boitier pour travailler, ce qui n’était pas du luxe! J’ai même un petit transfo 220V AC -> 5V DC digne de ce nom intégré qui me permet d’alimenter le raspberry + capteurs + disque dur avec assez de puissance ✌ (comparé au mini transfo que j’utilisais jusque là avec l’Arduino qui aurait eu un peu de mal ici je pense…

Re-câblage des capteurs

J’en ai aussi profité pour remettre un peu au propre mes câblages pour les différents capteurs de température: j’avais fait ça un peu à l’arrache lors de ma première itération, mais comme depuis j’ai découvert les connecteurs Dupond, et bien, tout de suite la vie devient plus facile! 😁

Petite note personnelle (pour mémoire… défaillante 😂) au passage: comme convention pour les câbles de capteurs de température j’utilise l’ordre: Masse / 3.3V / Signal de la droite vers la gauche, face « clips » sur les connecteurs Dupond comme on peut le voir sur la photo et mon joli petit diagramme ci-dessous:

Programmation du Raspberry Pi

Une fois les questions de connectique gérées, il fallait s’attaquer à la partie programmation du Raspberry Pi. J’avais toujours accès au programme Arduino que j’avais écris pour ce projet, mais bien évidemment, aucune compatibilité à ce niveau hein 😜! Je me suis basé sur quelques ressources trouvées sur le net pour commencer:

=> Tous ces tutoriaux indiquent que la communication entre les sondes DS18B20 (les capteurs de température que j’utilise) et le raspberry pi se fait avec le protocol 1Wire. Ce qui nous donne un schéma de connexion comme le suivant:

On commence donc par ajouter le support pour 1wire parmi les modules chargés au démarrage, ainsi que le support spécifique pour les sondes de température, on se fait donc un petit coup de sudo nano /etc/modules et on ajoute dans le fichier les lignes suivantes:

w1-therm
w1-gpio pullup=1

Un petit redémarrage après ça, et puis on peut ensuite vérifier si nos sondes sont bien détectées en vérifiant si chacune d’elles dispose d’un répertoire dédié:

pi@raspberrypi:~ $ cd /sys/bus/w1/devices/
pi@raspberrypi:/sys/bus/w1/devices $ ls -l
total 0

Mais là bien évidemment, que dalle, nada, pas de sondes 😨😂😢😭… Mais c’est bien normal! parce que j’ai compris ensuite que par défaut, le bus 1wire utilise le port GPIO 4, mais moi je m’étais plutôt connecté au port GPIO 2. Comme j’aime bien dicter ma loi plutôt que de subir celle des autres [mouahhh ahhh ahhh 😈], ben j’ai changé ça dans le fichier /boot/config.txt en ajoutant à la fin du fichier la ligne: dtoverlay=w1-gpio,gpiopin=2

Note: pour obtenir une map des ports GPIO directement depuis le raspberry, on peut utiliser la commande pinout qui va entre autre vous fournir l’affichage (bien pratique) suivant:

Raspberry pinouts

… Et avec le changement du /boot/config.txt on a finalement nos sondes détectées correctement, et on peut lire le fichier « w1_slave » pour obtenir une valeur de température, yess! 👍:

pi@raspberrypi:/sys/bus/w1/devices $ ls -l
 total 0
 lrwxrwxrwx 1 root root 0 juin  25 16:43 28-01191eea6778 -> ../../../devices/w1_bus_master1/28-01191eea6778
 lrwxrwxrwx 1 root root 0 juin  25 16:43 28-01191eea736d -> ../../../devices/w1_bus_master1/28-01191eea736d
 lrwxrwxrwx 1 root root 0 juin  25 16:43 28-02119245fc77 -> ../../../devices/w1_bus_master1/28-02119245fc77
 lrwxrwxrwx 1 root root 0 juin  25 17:01 28-02149245aeeb -> ../../../devices/w1_bus_master1/28-02149245aeeb
 lrwxrwxrwx 1 root root 0 juin  24 18:07 w1_bus_master1 -> ../../../devices/w1_bus_master1
pi@raspberrypi:/sys/bus/w1/devices $ cat 28-01191eea6778/w1_slave
 f1 03 4b 46 7f ff 0c 10 eb : crc=eb YES
 f1 03 4b 46 7f ff 0c 10 eb t=63062

Script python SolarHeat

Une fois les capteurs de température accessibles depuis le raspberry, il était temps de pondre un petit script python que je pourrais intégrer à mon programme « HomeCtrl » (qui gère déjà l’arrosage automatique du potager et l’activation de notre portail), et irait lire les valeurs de température depuis les fichiers indiqués ci-dessus, et faire ses petits calculs pour activer ou pas le circulateur. Et voici la première version que j’ai écrite:

from threading import *
from nv.core.utils import *
from datetime import datetime


class SolarHeatThread(Thread):
    def __init__(self, gpioManager):
        Thread.__init__(self)
        
        self.gman = gpioManager
        self.stopFlag = Event()
        self.start()
        self.configFile = nvGetNervSeedPath("config/homectrl.json")
        self.relayName = "solarPump"

        self.meanTemps = {}

        CHECK(not self.gman.isEnabled(self.relayName), "solarPump pin should not be active here.")
    
    def read_1wire_data(self, devName):
        fname = "/sys/bus/w1/devices/%s/w1_slave" % devName

        with open(fname, 'r') as f:
            lines = f.read().splitlines() 

        return lines

    def read_ds18B20_temp(self, devName):
        lines = self.read_1wire_data(devName)
        # logDEBUG("Read lines for %s: %s" % (devName, lines))

        # Check the CRC is "YES":
        # Note that we should
        els = lines[0].split(" ")
        if els[-1] != "YES":
            logERROR("Invalid CRC.")
            return None
        
        els = lines[1].split(" ")
        # The temperature is available in the last element:
        # we should remove the 't=' prefix:
        tstr = els[-1][2:]

        # Convert to number and devide by 1000:
        return float(tstr)/1000.0

    def run(self):
        logDEBUG('Starting solarheat thread')
        while not self.stopFlag.is_set():
            # Read the config file:
            cfg = nvLoadJSON(self.configFile)['solarheat']

            # Keep the same state as before by default:
            activate = self.gman.isEnabled(self.relayName)

            # Here we should read some temperatures:
            sens = cfg["temp_sensors"]
            initTemp = cfg["initial_temperature"]
            adapt = cfg["adaptation_factor"]
            delay = cfg["delay"]
            deltaStart = cfg["temp_delta_start"]
            deltaEnd = cfg["temp_delta_end"]
            verbose = cfg["verbose"]

            temps = {}
            for name, dev in sens.items():
                tmp = self.read_ds18B20_temp(dev)
                # logDEBUG("%s temp : %s deg." % (name, tmp))
                if tmp is None:
                    logWARN("Could not retrieve temperature from sensor '%s' (%s)" % (name, dev) )
                else:
                    temps[name] = tmp

                    # update the mean:
                    cur = self.meanTemps.get(name, initTemp)
                    self.meanTemps[name] = cur * (1.0 - adapt) + tmp * adapt

            # Check if we should start:
            roofT = self.meanTemps["roof"]
            tankT = self.meanTemps["tank"]

            if not activate and (roofT > (tankT + deltaStart)):
                activate = True
                if verbose:
                    nvSendRocketChatMessage("ℹ Activating solarheat pump: roof: %f deg, tank: %f deg" % (roofT, tankT))
                logDEBUG("Activating solarheat pump.")
            
            if activate and (roofT < (tankT + deltaEnd)):
                activate = False
                if verbose:
                    nvSendRocketChatMessage("ℹ Desactivating solarheat pump: roof: %f deg, tank: %f deg" % (roofT, tankT))
                logDEBUG("Desactivating solarheat pump.")

            # logDEBUG("inflow temp: " % tIn)
            # logDEBUG("outflow temp: " % tOut)

            if cfg['force_activate']:
                logDEBUG("solar pump activation is currently forced.")
                activate = True

            self.gman.enable(self.relayName, activate)
            # self.gman.toggle(self.relayName)
 
            self.stopFlag.wait(delay)

        logDEBUG('solarheat thread ended.')

    def stop(self):
        self.stopFlag.set()
        self.join()

Note personnelle (encore une fois à l’attention de ma « super » mémoire [périmée…]): Je dispose aussi d’une classe GPIOManager responsable des interactions de bas niveau avec les ports GPIO, et j’ai dû ajouter dans cette classe le support pour l’inversion des états logiques des ports, car il semble que sur ma carte de 4 relais, l’activation du relais se fait quand je passe le port de contrôle à une valeur GPIO.LOW (et inversement). Mais bon, on va dire que c’est un détail 😋

Problème de disparition des capteurs

Ce qui était moins un détail par contre, c’est que j’ai vite remarqué que parfois les répertoires représentant les capteurs (28-xxxxxxxx) disparaissaient sans prévenir! 😐 Ce qui du coup bien sûr faisait planter mon petit programme, et du coup stoppait le circulateur et entraînait une montée en flèche de la température dans les panneaux sur le toit (à 100° C environ actuellement 😬) pas vraiment très safe quoi…

Après quelques recherches il semblerait qu’il s’agisse d’un problème connu, et la seule solution présentée consiste à débrancher puis rebrancher les capteurs, ce qui va faire un reset du bus 1wire apparemment, et permettre de « re-découvrir » les capteurs manquants. J’ai donc décidé de modifier un peu mon montage pour rediriger l’alimentation 3.3V vers un relais (normalement fermé) avant d’atteindre la résistance de pull-up du brin de signal 1wire. Et en même temps, j’en ai aussi profité pour switcher le brin d’alimentation des capteurs sur une alimentation 5V en provenance directe du transfo principal. Et puis j’ai fait une mise à jour de mon programme SolarHeat pour qu’il vérifie à chaque fois si le fichier à lire pour un capteur donné existe bel et bien avant d’y accéder. Et si ce fichier est manquant, alors on active le relais de contrôle de l’alimentation du 1wire (ce qui va déconnecter l’alimentation 3.3V) et on le désactive à nouveau après quelques secondes, puis on vérifier encore l’existence du fichier, et on répète si nécessaire [C’est bon, vous avez suivi ? 😁]. Ce qui nous donne:

from threading import *
from nv.core.utils import *
from datetime import datetime


class SolarHeatThread(Thread):
    def __init__(self, gpioManager):
        Thread.__init__(self)
        
        self.gman = gpioManager
        self.stopFlag = Event()
        self.configFile = nvGetNervSeedPath("config/homectrl.json")
        self.relayName = "solarPump"
        self.powerName = "1wirePower"

        self.meanTemps = {}

        CHECK(not self.gman.isEnabled(self.relayName), "solarPump pin should not be active here.")
        CHECK(not self.gman.isEnabled(self.powerName), "1wirePower pin should not be active here.")
        self.start()

    def read_1wire_data(self, devName):
        fname = self.check_device_file(devName)

        # Check if the file exists:
        with open(fname, 'r') as f:
            lines = f.read().splitlines() 

        return lines

    def check_device_file(self, devName):
        fname = "/sys/bus/w1/devices/%s/w1_slave" % devName
        if nvFileExists(fname):
            return fname
        
        # The device is not available:
        logDEBUG("Lost temp sensor %s" % devName)
        nvSendRocketChatMessage("❌ Temp sensor %s not available anymore. Resetting." % devName)
        self.gman.enable(self.powerName, True)
        time.sleep(5.0)
        self.gman.enable(self.powerName, False)
        time.sleep(5.0)

        if nvFileExists(fname):
            logDEBUG("Restored temp sensor %s" % devName)
            return fname

        return self.check_device_file(devName)

    def read_ds18B20_temp(self, devName):
        lines = self.read_1wire_data(devName)
        # logDEBUG("Read lines for %s: %s" % (devName, lines))

        # Check the CRC is "YES":
        # Note that we should
        els = lines[0].split(" ")
        if els[-1] != "YES":
            logERROR("Invalid CRC.")
            return None
        
        els = lines[1].split(" ")
        # The temperature is available in the last element:
        # we should remove the 't=' prefix:
        tstr = els[-1][2:]

        # Convert to number and devide by 1000:
        return float(tstr)/1000.0

    def run(self):
        logDEBUG('Starting solarheat thread')
        while not self.stopFlag.is_set():
            # Read the config file:
            cfg = nvLoadJSON(self.configFile)['solarheat']

            # Keep the same state as before by default:
            activate = self.gman.isEnabled(self.relayName)

            # Here we should read some temperatures:
            sens = cfg["temp_sensors"]
            adapt = cfg["adaptation_factor"]
            delay = cfg["delay"]
            deltaStart = cfg["temp_delta_start"]
            deltaEnd = cfg["temp_delta_end"]
            verbose = cfg["verbose"]

            temps = {}
            for name, dev in sens.items():
                tmp = self.read_ds18B20_temp(dev)
                # logDEBUG("%s temp : %s deg." % (name, tmp))
                if tmp is None:
                    logWARN("Could not retrieve temperature from sensor '%s' (%s)" % (name, dev) )
                else:
                    temps[name] = tmp

                    # update the mean:
                    cur = self.meanTemps.get(name, tmp)
                    self.meanTemps[name] = cur * (1.0 - adapt) + tmp * adapt

            # Check if we should start:
            roofT = self.meanTemps["roof"]
            tankT = self.meanTemps["tank"]

            if not activate and (roofT > (tankT + deltaStart)):
                activate = True
                if verbose:
                    nvSendRocketChatMessage("ℹ Activating solarheat pump: roof: %f deg, tank: %f deg" % (roofT, tankT))
                logDEBUG("Activating solarheat pump.")
            
            if activate and (roofT < (tankT + deltaEnd)):
                activate = False
                if verbose:
                    nvSendRocketChatMessage("ℹ Desactivating solarheat pump: roof: %f deg, tank: %f deg" % (roofT, tankT))
                logDEBUG("Desactivating solarheat pump.")

            # logDEBUG("inflow temp: " % tIn)
            # logDEBUG("outflow temp: " % tOut)

            if cfg['force_activate']:
                logDEBUG("solar pump activation is currently forced.")
                activate = True

            self.gman.enable(self.relayName, activate)
            # self.gman.toggle(self.relayName)
 
            self.stopFlag.wait(delay)

        logDEBUG('solarheat thread ended.')

    def stop(self):
        self.stopFlag.set()
        self.join()

Alors je m’attendais à recevoir très régulièrement des messages du genre « Temp sensor 28-xxxx not available anymore. Resetting. » Mais en fait nan: pas une seule fois jusqu’à maintenant 😂! Depuis ces petits changements, plus de déconnexions intempestives de mes capteurs du tout, yeeaaahh 👍! Ce qui veut dire qu’en fait le souci était vraiment au niveau de l’alimentation des capteurs (vu que j’utilisais la broche 3.3V du raspberry pour cette alimentation dans mon premier montage). Ce qui tient la route, puisque dans certaines discussions sur le sujet j’ai vu que pour de grandes longueurs de câble des capteurs il valait mieux basculer sur du 5V en alim (tout en continuant à utiliser le 3.3V pour le pull-up de la ligne de signal 1-wire).

Enfin bref, quoiqu’il en soit, il semble que maintenant tout fonctionne comme prévu, et mon petit raspberry s’amuse à activer/désactiver le circulateur exactement comme je voulais en m’envoyant un petit message de debug à chaque fois (oui, bon, je vais finir par les désactiver bien sûr 😊):

Messages du programme SolarHeat

Voilà donc une belle conclusion pour cette session de mise à jour 😁!! Bien sûr, il reste encore beaucoup à faire, mais on va s’arrêter là pour cette fois et on se retrouvera pour un prochain article sur le sujet plus tard! @ plus dans le bus les gens! ✌😎😝

Leave a Comment

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