MaisonPaul – Migration de Node-RED à Python

A l’origine de mon projet, Node-RED était l’outil que j’utilisais en backend et frontend. Les graphiques générés par Node-RED Dashboard se complétaient tout seul avec le reste du code backend.
Notamment, à partir d’une entrée MQTT changeante, les graphiques affichaient les valeurs sur les dernières 24h sans besoin de programmation particulière, car Node-RED gérait tout ça de lui-même en fond. La page web côté frontend (le fameux Dashboard) affichait donc directement les valeurs sur les dernières 24h sans besoin d’aller chercher dans une quelconque base de données.

Seulement maintenant, avec la création de mon application Android, il faut que je reconstruise cette fonction moi-même. J’ai pensé malgré tout à stocker toutes les données depuis le début du projet avec le backend Node-RED, au cas où ce genre d’évolutions arrivait. Mais là, problème : la méthode que j’ai utilisé pour stocker les données n’est pas du tout optimale pour pouvoir récupérer efficacement les données des dernières 24h. En effet, j’ai utilisé une simple écriture dans un fichier texte. J’aimerais donc pouvoir stocker les données dans une base de données SQL dédiée, et par la même occasion, avoir plus de maîtrise sur le code backend côté Raspberry Pi pour gérer mon application.

J’ai donc décider de migrer mon application backend de Node-RED vers Python.

Cette décision est un peu arbitraire, dans le sens où j’aurais pu décider de rester sur Node-RED et utiliser des extensions pour manipuler une base de données, mais j’ai eu envie de développer un peu mes connaissances et mes capacités d’utiliser d’autres langages que je connais pas forcément très bien. J’ai été introduit au langage Python sans vraiment l’approfondir et je n’ai pas vraiment de connaissances dans le contexte du développement web, c’est une bonne occasion.

Le programme Python principal

J’ai décidé de développer le programme Python en utilisant Visual Studio Code. J’ai pu configurer ma Rapsberry Pi pour effectuer le développement à distance : je code depuis un PC, et le programme est exécuté sur la Raspberry Pi sur lequel toutes les données dont j’aurais besoin seront présentes.

Le programme Python doit implémenter les fonctions suivantes pour remplacer les fonctions de Node-RED, c’est-à-dire :
– Récupération des données depuis le broker MQTT
– Placement de ces données horodatées dans une base de données SQL
– Pour les données de température et humidité extérieures, envoi d’une requête HTML, et traitement de la réponse formattée en JSON vers le broker MQTT

Voici le lien du dépôt Github contenant le programme Python (maisonpaul.py) : https://github.com/MrXANA91/MaisonPaul-backend

Le programme sera démarré par Bash, car des données d’authentification ainsi que des indications personnelles de localisation seront nécessaires en arguments. Le code suivant permet de définir les arguments nécessaires :

Cliquez ici pour voir le code

import argparse

# Argument parsing management
parser = argparse.ArgumentParser(description='Python script authentication')
parser.add_argument('--mqttaddress', dest='mqttaddress', type=str, help='IP address of the MQTT broker')
parser.add_argument('--mqttusername', dest='mqttusername', type=str, help='Username to use for MQTT broker authentication')
parser.add_argument('--mqttpwd', dest='mqttpwd', type=str, help='Password to use for MQTT broker authentication')
parser.add_argument('--weatherappid', dest='weatherappid', type=str, help='App ID for OpenWeatherAPI authentication')
parser.add_argument('--weatherapplat', dest='weatherapplat', type=str, help='Latitude for the OpenWeatherAPI request')
parser.add_argument('--weatherapplon', dest='weatherapplon', type=str, help='Longitude for the OpenWeatherAPI request')

args = parser.parse_args()

Le code suivant initialise la base SQL et crée les tables (si elles n’existent pas) qui seront utiliser dans la suite du code.

import sqlite3
import os

print("Initializing SQL database...")
# Chemin vers le répertoire contenant le fichier maisonpaul.db
db_directory = os.path.join(os.path.dirname(__file__), '..', 'db')
# Chemin complet vers le fichier maisonpaul.db
db_path = os.path.join(db_directory, 'maisonpaul.db')
# Connexion à la base de données
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("CREATE TABLE IF NOT EXISTS HumidityTable (id INTEGER PRIMARY KEY AUTOINCREMENT, sensorid VARCHAR(10), humidity REAL, date DATETIME)")
cursor.execute("CREATE TABLE IF NOT EXISTS TemperatureTable (id INTEGER PRIMARY KEY AUTOINCREMENT, sensorid VARCHAR(10), temperature REAL, date DATETIME)")
cursor.execute("CREATE TABLE IF NOT EXISTS ActuatorsTable (id INTEGER PRIMARY KEY AUTOINCREMENT, actuatorid VARCHAR(50), value REAL, action VARCHAR(50), date DATETIME)")
conn.close()
print("SQL database initialized!")

Des fonctions sont définies pour pouvoir formatter les données reçues par le broker MQTT et les rentrer dans la table adaptée dans la base de données.

Cliquez ici pour voir le code

Chacune des fonctions AddEntryToActuatorsTable, AddEntryToTemperatureTable et AddEntryToHumidityTable correspond à leur table associé (explicitée dans leur nom). On a créé également les fonctions execute_sql pour l’exécution même de la requête SQL et une fonction getFormattedTime pour pouvoir afficher la date de l’entrée sur la console.

import time
from datetime import datetime

def execute_sql(sql, params):
    print("Connecting to database...")
    conn = None
    try:
        conn = sqlite3.connect(db_path)
        cursor = conn.cursor()
        cursor.execute(sql, params)
        print(f"SQL Request : {sql}")
        print("Executing SQL request...")
        conn.commit()
    except sqlite3.Error as e:
        print(f"An error occurred: {e}")
    finally:
        if conn is not None:
            conn.close()
        print("Done!")

def getFormattedTime(timestamp):
    # Conversion du timestamp en datetime
    dt_object = datetime.utcfromtimestamp(int(float(timestamp)))
    # Formatage de l'objet datetime pour l'afficher comme une chaîne de caractères
    formatted_time = dt_object.strftime('%Y-%m-%d %H:%M:%S')
    return formatted_time

def AddEntryToActuatorsTable(actuatorid, value, action):
    print(f"New entry from {getFormattedTime(time.time())} to actuators table : {actuatorid}, {value}, {action}")
    sql = "INSERT INTO ActuatorsTable (actuatorid, value, action, date) VALUES (?, ?, ?, datetime('now', 'UTC'))"
    params = (actuatorid, value, action)
    execute_sql(sql, params)

def AddEntryToTemperatureTable(sensorid, temperature):
    print(f"New entry from {getFormattedTime(time.time())} to temperature table : {sensorid}, {temperature}")
    sql = "INSERT INTO TemperatureTable (sensorid, temperature, date) VALUES (?, ?, datetime('now', 'UTC'))"
    params = (sensorid, temperature)
    execute_sql(sql, params)

def AddEntryToHumidityTable(sensorid, humidity):
    print(f"New entry from {getFormattedTime(time.time())} to humidity table : {sensorid}, {humidity}")
    sql = "INSERT INTO HumidityTable (sensorid, humidity, date) VALUES (?, ?, datetime('now', 'UTC'))"
    params = (sensorid, humidity)
    execute_sql(sql, params)

Le code suivant permet de se connecter au broker MQTT et de s’abonner aux différents topics du broker, tels que par exemple mainroom/heater1 pour la valeur du chauffage de la pièce principale, station1/temperature et station1/humidity pour les valeurs de température de d’humidité de la pièce principale ou encore local-current-weather qui stocke entre autres les valeurs de température et d’humidité à l’extérieur. La fonction on_message est appelée lorsqu’un message est reçu. On parcourt manuellement quel topic a été déclenché et on exécute une des trois fonctions précédemment définie :

Cliquez ici pour voir le code

import paho.mqtt.client as mqtt
import json

# Client MQTT creation
client = mqtt.Client("MaisonPaul-backend-python")

# Connection to the MQTT broker
print("Connecting to the MQTT broker...")
client.username_pw_set(args.mqttusername, args.mqttpwd)
client.connect(args.mqttaddress, port=1883)
print("Connected!")

# Topic subscription
print("Subscribing to MQTT topics...")
client.subscribe("mainroom/heater1")
client.subscribe("mainroom/heater2")
client.subscribe("bedroom/heater")
client.subscribe("bedroom/heater-mode")
client.subscribe("watercloset/heater")
client.subscribe("watercloset/heater-mode")
client.subscribe("station1/temperature")
client.subscribe("station1/humidity")
client.subscribe("station2/temperature")
client.subscribe("station2/humidity")
client.subscribe("local-current-weather")
print("Subscription complete!")

# Callback fonction for received messages
def on_message(client, userdata, msg):
    print(str(msg.topic), " MQTT topic just got : ", str(msg.payload))

    if str(msg.topic) == "mainroom/heater1":
        AddEntryToActuatorsTable(str(msg.topic), msg.payload, "")
    elif str(msg.topic) == "mainroom/heater2":
        AddEntryToActuatorsTable(str(msg.topic), msg.payload, "")
    elif str(msg.topic) == "bedroom/heater":
        AddEntryToActuatorsTable(str(msg.topic), msg.payload, "")
    elif str(msg.topic) == "watercloset/heater":
        AddEntryToActuatorsTable(str(msg.topic), msg.payload, "")
    elif str(msg.topic) == "bedroom/heater-mode":
        AddEntryToActuatorsTable(str(msg.topic), 0, str(msg.payload, 'utf-8'))
    elif str(msg.topic) == "watercloset/heater-mode":
        AddEntryToActuatorsTable(str(msg.topic), 0, str(msg.payload, 'utf-8'))
    elif str(msg.topic) == "station1/temperature":
        AddEntryToTemperatureTable(str(msg.topic), msg.payload)
    elif str(msg.topic) == "station1/humidity":
        AddEntryToHumidityTable(str(msg.topic), msg.payload)
    elif str(msg.topic) == "station2/temperature":
        AddEntryToTemperatureTable(str(msg.topic), msg.payload)
    elif str(msg.topic) == "station2/humidity":
        AddEntryToHumidityTable(str(msg.topic), msg.payload)
    elif str(msg.topic) == "local-current-weather":
        weather_json = json.loads(str(msg.payload, 'utf-8'))
        AddEntryToTemperatureTable("outdoor", weather_json["main"]["temp"])
        AddEntryToHumidityTable("outdoor", weather_json["main"]["humidity"])
    else:
        print("MQTT topic not recognized")

# Registering callback function
client.on_message = on_message

# MQTT client loop
client.loop_start()

Le code suivant permet de récupérer, toutes les deux minutes, les informations depuis l’API de OpenWeather pour récupérer les informations de température et d’humidité venant de l’extérieur. Cette fonction background_request est exécutée dans un thread qui peut être stoppé à tout moment avec la variable globale stop_thread :

Cliquez ici pour voir le code

import requests
import time
import threading

stop_thread = False
def background_request():
    global stop_thread
    while not stop_thread:
        # Définition l'URL de la requête
        url = "https://api.openweathermap.org/data/2.5/weather?lat="+args.weatherapplat+"&lon="+args.weatherapplon+"&appid="+args.weatherappid+"&units=metric&lang=fr"

        # Execution de la requête HTTP
        response = requests.get(url)

        # Vérification du succès de la requête
        if response.status_code == 200:
            # Chargez la réponse sous forme d'objet JSON
            response_txt = response.text
            print("(Thread) Received from HTTP request : {}".format(response_txt))
            print("(Thread) Publishing to local-current-weather MQTT topic...")
            client.publish("local-current-weather", response_txt, 2, True)
            print("(Thread) Done! (Going to sleep for 2 minutes)")
        else:
            print("(Thread) Failed HTTP request. Error code :", response.status_code)

        # 2 minutes d'attente avant de répéter la boucle
        for x in range(120):
            time.sleep(1)
            if stop_thread:
                break

# Création un thread de fond pour exécuter la fonction de requête en arrière-plan
thread = threading.Thread(target=background_request)

# Démarrage du thread
thread.start()

Ce programme Python permet donc de remplacer les fonctions de Node-RED dont l’implémentation était expliquée dans un précédent article. On peut maintenant tester le programme et vérifier qu’il fonctionne correctement :

L’initialisation de tous les éléments s’effectue avec succès et on réussit à récupèrer les données déjà présents dans le broker MQTT
Après approximativement 2 minutes, les capteurs envoient leurs informations et la requête HTML s’exécute également.

Et avec un visualiseur de base de données SQL (voir DB Browser for SQLite), on s’assure d’avoir accès aux données que le programme prétend avoir écrit avec succès :

Les entrées se font en UTC, donc les entrées dans la base ont 2 heures de retard.
On retrouve bien sur les trois dernières lignes les informations de la deuxième image.

Le script de migration vers la base de données Python

Maintenant que l’on sait que notre programme Python pourra prendre le relais, on doit maintenant basculer toutes les données récupérées jusqu’à présent (qui sont actuellement stockées dans plusieurs fichiers texte) vers la base de données. On va créer un script Python pour automatiser la procédure :
– Ouverture de chacun des fichiers texte
– Pour chaque entrée (c’est-à-dire chaque ligne), on récupère les différentes informations (temps, valeurs, etc.)
– On formatte ces valeurs et on envoie dans les tables prévues

Le lien du dépôt Github (le script Python migration.py) : https://github.com/MrXANA91/MaisonPaul-backend

La fonction main va construire les chemins vers les différents fichiers, puis pour chaque fichier, va lancer les fonctions extract_Actuators_entries et extract_TemperatureOrHumiditity_entries correspondantes au fichier.

Cliquez ici pour voir le code

def main():
    olddbs_directory = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'Temperatures-logs')
    
    # Fichier contenant tous les changements des infos radiateurs
    old_actuators = os.path.join(olddbs_directory, 'heat-controls-history.txt')

    # Noms des capteurs et leurs fichiers associés
    sensors = {
        "outdoor": ["temp-outdoor.txt", "humidity-outdoor.txt"],
        "station1": ["temp-station1.txt", "humidity-station1.txt"],
        "station2": ["temp-station2.txt", "humidity-station2.txt"],
        "station3": ["temp-station3.txt", "humidity-station3.txt"]
    }

    # Exécution de la fonction pour les actuators
    extract_Actuators_entries(old_actuators)

    # Exécution des fonctions pour les capteurs
    for sensor, files in sensors.items():
        for file in files:
            old_file = os.path.join(olddbs_directory, file)
            extract_TemperatureOrHumiditity_entries(old_file, sensor)

    return

La fonction extract_TemperatureOrHumiditity_entries sert à rentrer les informations de température ou d’humidité dans la table SQL TemperatureTable ou HumidityTable suivant la nature des données indiquées, et la fonction extract_Actuators_entries permet de récupérer les informations relatifs aux différents radiateurs et les stocker dans la table ActuatorsTable. La logique est différente et est spécifique pour cette table, d’où la fonction séparée.

Cliquez ici pour voir le code

# Lecture des fichiers
def extract_TemperatureOrHumiditity_entries(file_path, nameid):
    with open(file_path, 'r') as f:
        for line in f:
            # split the line into timestamp and value
            timestamp, value = line.strip().split()

            # convert value to float
            value = float(value)

            # add entry to appropriate table
            if 'temp' in file_path:
                AddDatedEntryToTemperatureTable(timestamp, nameid, value)
            else:
                AddDatedEntryToHumidityTable(timestamp, nameid, value)
        print("File {} finished!".format(str(file_path)))

def extract_Actuators_entries(file_path):
    with open(file_path, 'r') as f:
        for line in f:
            # Supprimer les espaces de début et de fin
            line = line.strip()

            # Diviser la ligne en deux parties : timestamp et le reste
            timestamp, rest = line.split(" ; ")

            # Diviser le reste en deux parties : id_actuator et action/number
            actuatorid, value = rest.split("=")

            if str(actuatorid) == "mainroom/heater1":
                AddDatedEntryToActuatorsTable(timestamp, actuatorid, value, "")
            elif str(actuatorid) == "mainroom/heater2":
                AddDatedEntryToActuatorsTable(timestamp, actuatorid, value, "")
            elif str(actuatorid) == "bedroom/heater":
                AddDatedEntryToActuatorsTable(timestamp, actuatorid, value, "")
            elif str(actuatorid) == "watercloset/heater":
                AddDatedEntryToActuatorsTable(timestamp, actuatorid, value, "")
            elif str(actuatorid) == "bedroom/heater-mode":
                AddDatedEntryToActuatorsTable(timestamp, actuatorid, 0, str(value))
            elif str(actuatorid) == "watercloset/heater-mode":
                AddDatedEntryToActuatorsTable(timestamp, actuatorid, 0, str(value))
            else:
                print("Unknown actuatorid : {}={}".format(str(actuatorid), str(value)))
        print("File {} finished!".format(str(file_path)))

Les deux fonctions précédentes font appel à des fonctions AddDatedEntry... qui ressemblent aux fonctions AddEntry... du fichier maisonpaul.py. La différence se situe dans le fait que l’indication de temps est spécifié dans ces fonctions, alors que AddEntry... utilise la fonction datetime('now') intégrée de la requête SQL. Ces fonctions utilisent les fonctions execute_sql et getFormattedTime définie dans maisonpaul.py.

Cliquez ici pour voir le code

# Fonctions d'écriture dans la base de données
def AddDatedEntryToTemperatureTable(timestamp, sensorid, temperature):
    print(f"New entry from {getFormattedTime(timestamp)} to temperature table : {sensorid}, {temperature}")
    sql = "INSERT INTO TemperatureTable (sensorid, temperature, date) VALUES (?, ?, datetime(?, 'unixepoch', 'UTC'))"
    params = (sensorid, temperature, timestamp)
    execute_sql(sql, params)

def AddDatedEntryToHumidityTable(timestamp, sensorid, humidity):
    print(f"New entry from {getFormattedTime(timestamp)} to humidity table : {sensorid}, {humidity}")
    sql = "INSERT INTO HumidityTable (sensorid, humidity, date) VALUES (?, ?, datetime(?, 'unixepoch', 'UTC'))"
    params = (sensorid, humidity, timestamp)
    execute_sql(sql, params)

def AddDatedEntryToActuatorsTable(timestamp, actuatorid, value, action):
    print(f"New entry from {getFormattedTime(timestamp)} to actuators table : {actuatorid}, {value}, {action}")
    sql = "INSERT INTO ActuatorsTable (actuatorid, value, action, date) VALUES (?, ?, ?, datetime(?, 'unixepoch', 'UTC'))"
    params = (actuatorid, value, action, timestamp)
    execute_sql(sql, params)

De la même façon que pour le programme principal, on a pu vérifier que le programme fonctionne à partir de la console et de DB Browser.

Protocole de migration et scripts Bash

Voici le protocole qui a été suivi pour la procédure de migration :

– Désactivation des fonctions Node-RED manuellement
– Execution du script bash pour démarrer le programme Python principal MaisonPaul
– Monitoring du fonctionnement pendant quelques minutes
Si tout se passe bien :
– Mise en place de l’exécution automatique du programme lors du redémarrage de la Raspberry Pi par crontab
– Exécution du programme Python de migration
– Sauvegarde des fichiers textes de l’ancienne base de données

Au cas où il y aurait un raté et que l’on soit obligé de tout restaurer, les fonctions Node-RED sont désactivées sans désinstallation complète. On gardera Node-RED au cas où on souhaiterait ajouter des fonctionnalités, ou au cas où on ait besoin de Node-RED pour d’autres projets.

Des difficultés pendant la migration… mais une migration réussie !

Les premières heures de la migration ont été un peu chaotique. La première étape de lancement du script et d’extinction de Node-RED se sont bien passés, mais une erreur sur les timing des entrées ont été vues seulement quatre heures après le lancement du script. Les quatre heures de données ont été être corrigées, ce qui a causé des problèmes sur la base et sur le script Python qui s’est arrêté pendant 20 minutes.

Entre temps, des tests sur le script de migration avaient également affecté la base de données. Les corrections pour supprimer les données se sont effectuées avant succès.

Est ensuite venue l’étape de migration des entrées existantes dans les fichiers vers la base de données. Cela a pris malheureusement beaucoup plus de temps que prévu, car un soucis inconnu jusqu’à lors s’est présenté :

Des lectures simultanées sur la base de données ne pose pas de problème pour SQLite, c’est-à-dire que plusieurs instances peuvent envoyer en même temps des requêtes SQL pour de la lecture sans problème. Par contre, lorsqu’il s’agit de l’écriture, ce n’est pas la même histoire : la base de donnée est verrouillée le temps de l’écriture. Il a fallu donc modifier le code pour que la migration puisse se faire sans pour autant faire planter les éventuelles mises à jour en temps réelle qui ne peuvent pas être interrompues !

Le code du programme principal et du programme de migration ont donc été modifié pour inclure des détections d’erreur et un nombre de ‘retry’ : en cas de problème lors de l’écriture, le programme pourra retenter un certain nombre de fois jusqu’à ce que l’action réussisse.

Du coup, au lieu d’être rapide, la migration a pris trois jours complets pour terminer. Voilà la durée prise par chaque fichier pour être importé :

  • heat controls history (historique des contrôles des radiateurs) : 4 minutes
  • température pièce principale : 6 heures et 39 minutes
  • humidité pièce principale : 6 heures et 43 minutes
  • température chambre : 6h37
  • humidité chambre : 6h36
  • température salle d’eau* : 1h49
  • humidité salle d’eau* : 1h49
  • température extérieure : 6h40
  • humidité extérieure : 6h37

* le capteur de la salle de bain a été retiré pendant l’expérience, car les données n’étaient pas différentes de la chambre.

Conclusion

La migration vers le programme Python est maintenant terminée, et toutes les données précédemment stockées dans les fichiers ont été rapatriées dans la base de données qui contient maintenant toutes les informations dont nous avons besoin !

La mission suivante va être de faire en sorte que l’application Android puisse envoyer des requêtes au programme Python pour récupérer les informations de la base de données et afficher des courbes !

Laisser un commentaire

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

Retour en haut