SmartLife / Tuya to mqtt : récupérer ses objets sous Jeedom

J’ai mon chargeur de voiture qui est sous SmartLife/Tuya. Le plugin étant à l’abandon, je me suis fait un script pour intégrer et commander mon chargeur via Jeedom. J’ai passé une dizaine d’heure là dessus avec pas mal de galères surtout pour l’access token.
Alors si ça peut aider d’autres personnes…

Pré-requis avoir le plugin MQTT (ou autre) pour avoir un broker Mosquitto sur son Jeedom.

  • Créer un compte sur https://iot.tuya.com
  • Il faut ensuite y créer un projet : Cloud →Project Management → Create Cloud Project, par exemple dans mons cas « ChargeurVE » en catégorie Smart Home.
  • Lier ton compte Tuya / Smart Life Project Management → Open project → Devices → Link App Account et scanner le QR Code avec l’application Smart Life sur téléphone.
  • Ajouter les Services API de bases : IoT Core (par défaut normalement)
  • Récupérer le Device ID de l’objet dans Cloud → API Explorer.

Ensuite le reste se passe sur le Pi. Mon script n’est pas parfait, la méthodologie peut-être pas non plus, je ne suis pas un dev, mais ça marche !
A vous de le personnaliser, ou de le développer plus si besoin.

  • J’ai installé les librairies nécessaires :
pip install paho-mqtt requests
  • Dans mon répertoire home (mais on peut le faire dans un répertoire Jeedom comme celui du plugin Script/data) j’ai créé un fichier Python : tuya_to_mqtt.py
    Avec ce contenu (je ne vais pas tout commenter mais si besoin je préciserais) :
import time
import hashlib
import hmac
import requests
import threading
import json
import paho.mqtt.client as mqtt
import logging
from logging.handlers import RotatingFileHandler

# Configuration Tuya
CLIENT_ID = "ton_client_id_sur_projet_tuya"
SECRET = "ton_secret_sur_projet_tuya"
BASEURL = "https://openapi.tuyaeu.com" # pour l'Europe
DEVICE_ID = "ton_id_du_device"
# Configuration MQTT
MQTT_BROKER = "localhost"
MQTT_PORT = 1883
MQTT_USER = "ton_user"
MQTT_PASS = "ton_pass"
MQTT_TOPIC = "tuya/chargeurVE" # nom du topic mqtt principal de l'objet
MQTT_TOPIC_CMD = "tuya/chargeurVE/cmd" # nom du topic des commandes
MQTT_TOPIC_ERR = "tuya/chargeurVE/erreur" # nom du topic des erreurs

# Logger avec rotation automatique
file_handler  = RotatingFileHandler(
    "/var/log/tuya_to_mqtt.log", 
    maxBytes=5*1024*1024,  # 5 Mo max par fichier
    backupCount=7  # Garder 7 fichiers de rotation
)
logging.basicConfig(
    level=logging.INFO,
    format="[%(asctime)s.%(msecs)03d][%(levelname)s] %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
    handlers=[file_handler, logging.StreamHandler()]
)

EmptyBodyEncoded = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
last_properties = {}  # Stocke les dernières valeurs pour éviter les publications inutiles
update_thread_active = False

def on_connect(client, userdata, flags, rc, properties=None):
    logging.info(f"📡 Connecté au broker MQTT (code {rc})")
    client.subscribe(MQTT_TOPIC_CMD)

def on_message(client, userdata, msg):
    try:
        payload = json.loads(msg.payload.decode().strip())
        code, value = payload.get("code"), payload.get("value")
        if code and value is not None:
            send_command_to_device(token, code, value)
        else:
            logging.warning(f"⚠️ Commande MQTT mal formée : {payload}")
    except Exception as e:
        logging.error(f"❌ Erreur traitement MQTT cmd : {e}")

mqtt_client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
mqtt_client.username_pw_set(MQTT_USER, MQTT_PASS)
mqtt_client.on_connect = on_connect
mqtt_client.on_message = on_message
mqtt_client.connect(MQTT_BROKER, MQTT_PORT, 60)
mqtt_client.loop_start()
time.sleep(0.2)

# Fonction pour générer la signature Tuya
def generate_signature(url, access_token=None):
    timestamp = str(int(time.time() * 1000))
    string_to_sign = f"{CLIENT_ID}{timestamp}GET\n{EmptyBodyEncoded}\n\n{url}"
    if access_token:
        string_to_sign = f"{CLIENT_ID}{access_token}{timestamp}GET\n{EmptyBodyEncoded}\n\n{url}"
    sign = hmac.new(SECRET.encode(), string_to_sign.encode(), hashlib.sha256).hexdigest().upper()
    return timestamp, sign

# Obtenir un token d'accès
def get_access_token():
    url = "/v1.0/token?grant_type=1"
    retry_count = 3 # Nombre de tentatives avant alerte
    for attempt in range(retry_count):
        timestamp, sign = generate_signature(url)
        headers = {
            "sign_method": "HMAC-SHA256",
            "client_id": CLIENT_ID,
            "t": timestamp,
            "Content-Type": "application/json",
            "sign": sign,
        }
        try:
            logging.info(f"🔄 Récupération du token (tentative {attempt + 1}/{retry_count})...")
            response = requests.get(BASEURL + url, headers=headers, timeout=5)
            data = response.json()
            if data.get("success"):
                result = data.get("result", {})
                token = result.get("access_token")
                if token:
                    logging.info(f"✅ Nouveau token : {token}")
                    return token
                else:
                    error_msg = "❌ Réponse valide mais token manquant"
                    logging.critical(error_msg)
                    mqtt_client.publish(MQTT_TOPIC_ERR, json.dumps({"erreur": error_msg}))
                    return None
            else:
                error_msg = f"Erreur token inconnue : {data.get('msg', 'Non spécifié')} (code {error_code})"
                logging.warning(f"⚠️ {error_msg}")
                mqtt_client.publish(MQTT_TOPIC_ERR, json.dumps({"erreur": error_msg}))
                return None

        except requests.RequestException as e:
            logging.warning(f"❌ Erreur réseau (tentative token {attempt + 1}/{retry_count}) : {e}")
            if attempt < retry_count - 1:
                time.sleep(5 * (attempt + 1))
                continue
            error_msg = f"❌ Erreur réseau persistante token : {e}"
            mqtt_client.publish(MQTT_TOPIC_ERR, json.dumps({"erreur": error_msg}))
            logging.critical(error_msg)
            return None
    # Échec après toutes les tentatives
    error_msg = f"❌ Échec token après {retry_count} tentatives. Vérifier la console Tuya IOT."
    logging.critical(error_msg)
    mqtt_client.publish(f"{MQTT_TOPIC_BASE}/erreur", json.dumps({"erreur": error_msg}))
    return None

# Obtenir les propriétés d'un appareil
def get_device_properties(access_token):
    url = f"/v2.0/cloud/thing/{DEVICE_ID}/shadow/properties"
    timestamp, sign = generate_signature(url, access_token)
    headers = {
        "sign_method": "HMAC-SHA256",
        "client_id": CLIENT_ID,
        "t": timestamp,
        "mode": "cors",
        "Content-Type": "application/json",
        "sign": sign,
        "access_token": access_token
    }
    try:
        response = requests.get(BASEURL + url, headers=headers, timeout=5)
        return response.json()
    except requests.RequestException as e:
        logging.error(f"❌ Erreur réseau sur récupération des propriétés : {e}")
        return {}

# Fonction pour envoyer une commande
def send_command_to_device(access_token, code, value):
    url = f"/v1.0/devices/{DEVICE_ID}/commands"
    body = json.dumps({"commands": [{"code": code, "value": value}]}).encode("utf-8")
    body_hash = hashlib.sha256(body).hexdigest()
    timestamp = str(int(time.time() * 1000))
    string_to_sign = f"{CLIENT_ID}{access_token}{timestamp}POST\n{body_hash}\n\n{url}"
    sign = hmac.new(SECRET.encode(), string_to_sign.encode(), hashlib.sha256).hexdigest().upper()
    headers = {
        "sign_method": "HMAC-SHA256",
        "client_id": CLIENT_ID,
        "t": timestamp,
        "Content-Type": "application/json",
        "sign": sign,
        "access_token": access_token
    }
    try:
        response = requests.post(BASEURL + url, headers=headers, data=body, timeout=5).json()
        if response.get("code") == 1010:
            logging.info("🔄 Token expiré, renouvellement, commande en attente...")
            token = get_access_token()
            if token:
                logging.info(f"✅ Nouveau token : {token}, renvoi de la commande...")
                return send_command_to_device(token, code, value)
            error_message = {"erreur": "❌ Impossible de récupérer un nouveau token, commande annulée"}
            mqtt_client.publish(MQTT_TOPIC_ERR, json.dumps(error_message))
            logging.error(f"[{DEVICE_ID}] {error_message['erreur']}")
            return None
        logging.info(f"📤 Commande envoyée ({code} = {value}) -> Réponse : {response}")
        # Rafraîchir les propriétés après commande sans interrompre la boucle principale
        time.sleep(1)
        threading.Thread(target=update_device_state, args=(access_token,)).start()
        return access_token
    except requests.RequestException as e:
        error_message = {"erreur": f"❌ Erreur réseau lors de l'envoi de la commande : {e}"}
        mqtt_client.publish(MQTT_TOPIC_ERR, json.dumps(error_message))
        logging.error(f"[{DEVICE_ID}] {error_message['erreur']}")
    return None

# Fonction pour rafraîchir l'état et publier dans MQTT
def update_device_state(access_token):
    global update_thread_active
    if update_thread_active:
        return
    update_thread_active = True
    try:
        device_properties = get_device_properties(access_token)
        if device_properties.get("success"):
            properties = device_properties.get("result", {}).get("properties", [])
            logging.info(f"🔄 Mise à jour après commande : [\n" + ",\n".join(json.dumps(item) for item in properties) + "\n]")
            mqtt_client.publish(MQTT_TOPIC, json.dumps(properties))
        else:
            logging.warning("⚠️ Impossible de rafraîchir après la commande.")
    finally:
        update_thread_active = False

# Exécution principale
if __name__ == "__main__":
    token = get_access_token()
    token_creation_time = time.time()  # Timestamp du token
    interval = 60  # relever les propriétés toutes les minutes par défaut
    while True:
        if token is None:
            logging.warning("Impossible d'obtenir un token. Nouvelle essai dans 3 minutes...") # ne pas surcharger l'API si un problème critique de token
            time.sleep(180)
            token = get_access_token()
            if token:
                token_creation_time = time.time()
            continue

        token_age = time.time() - token_creation_time        # Vérifie si le token a dépassé sa durée de vie
        if token_age >= 7200:
            logging.info(f"⏰ Token a {token_age:.0f}s, considéré comme expiré (>= 7200s), renouvellement...")
            token = get_access_token()
            if token:
                token_creation_time = time.time()
            continue

        device_properties = get_device_properties(token)
        if device_properties.get("msg") == "token invalid":
            logging.info("⚠️ Token invalide détecté, renouvellement...")
            token = get_access_token()
            if token:
                token_creation_time = time.time()
            continue

        if device_properties.get("success"):
            properties = device_properties.get("result", {}).get("properties", [])
            properties_dict = {item["code"]: item["value"] for item in properties}
            if properties_dict != last_properties:
                logging.info("📡 Nouvelles propriétés récupérées : [\n" + ",\n".join(json.dumps(item) for item in properties) + "\n]")
                mqtt_client.publish(MQTT_TOPIC, json.dumps(properties))
                last_properties = properties_dict  # Mise en mémoire des valeurs
            else:
                logging.info("⚡ Aucune modification, pas d'envoi MQTT.")

        else:
            logging.error(f"⚠️ Erreur de récupération des propriétés : {device_properties.get('msg')}")

        logging.info(f"⏳ Attente de {interval} secondes")
        time.sleep(interval)

Il ne vous reste plus qu’à lancer le script et voir si ça marche :

python3 tuya_to_mqtt.py

Si tout est ok, il devrait y avoir une remontée dans le plugin mqtt de Jeedom soit en auto-découverte soit en l’ajoutant à la main avec le bon topic racine : MQTT_TOPIC et en cochant Activer l’analyse des valeurs pour récupérer les commandes simplement.
image

Pour le reste c’est comme tout équipement mqtt, vous pouvez l’afficher sur Jeedom mais pour l’instant vous ne recevez l’info que si vous avez lancé le script, on va donc automatiser ça.

Avec un Raspberry Pi sous Debian, on va commencer par créer le fichier log et lui donner les droits qu’il faut

sudo touch /var/log/tuya_to_mqtt.log
sudo chown ton_username:ton_username/var/log/tuya_to_mqtt.log
sudo chmod 644 /var/log/tuya_to_mqtt.log

et on va utiliser systemd pour exécuter le script en tant que service.

sudo nano /etc/systemd/system/tuya_to_mqtt.service

on écrit

[Unit]
Description=Tuya to MQTT bridge
After=network.target

[Service]
ExecStart=/usr/bin/python3 /chemin/du/script/tuya_to_mqtt.py
Restart=always
User=pi
WorkingDirectory=/répertoire/du/script
StandardOutput=append:/var/log/tuya_to_mqtt.log
StandardError=append:/var/log/tuya_to_mqtt.log

[Install]
WantedBy=multi-user.target

on active

sudo systemctl daemon-reload
sudo systemctl enable tuya_to_mqtt
sudo systemctl start tuya_to_mqtt

On teste

sudo systemctl status tuya_to_mqtt

Si tout est bon, le statut doit être « active (running) »

Voilà le script est en place et se lancera à chaque reboot ou si plantage !

Dans la partie Jeedom, pour l’équipement on peut envoyer des commandes, pour cela on lui ajoute une commande de type action vers le topic MQTT_TOPIC_CMD :


Ici c’est un slider pour modifier l’ampérage du chargeur, le code est :

{code: "charge_cur_set", "value": #slider#}

attention à bien retirer les " autour de code, #slider# récupère la valeur. Il ne reste plus qu’à bouger le slider et vérifier le résultat ! :wink:
On peut aussi optimiser la boucle de répétition While pour ne pas surcharger l’API et pour que le script interroge plus ou moins souvent suivant l’utilisation de l’objet.

Voilà, je crois avoir tout dit ! Si besoin je répondrais (à mon petit niveau).

1 « J'aime »

Hello super idée. As-tu le lien de ton câble ?

C’est ce modèle pas cher : Junsun-Chargeur EV Type 2 pour Tesla, Boîtier Mural IP65, 1Phase, 7kW, 32A 24A 20A, 3,5 kW, 16A 10A 8A 6A wallbox chargeur voiture électrique type 2 chargeur rapide voiture chargeur tesla - AliExpress 34

J’ai fait quelques modifications, notamment pour mieux gérer le token et les logs (script à jour dans le 1er message)

J’ai choisi de gérer mon script en dehors de Jeedom mais on pourrait faire autrement (en faisant un plugin par exemple).
Cela dit histoire de pouvoir lire le log dans le menu Analyse/log de Jeedom (mais pas le vider, mais supprimer le lien oui) sans avoir à aller sur son Pi, on peut faire un lien symbolique vers le répertoire log de Jeedom :

sudo ln -s /var/log/tuya_to_mqtt.log /var/www/html/log/tuya_to_mqtt

Si on veut éviter la suppression du lien ln quand on clique sur « tout supprimer » dans les logs Jeedom alors il faut un cron qui le recréé toutes les x minutes.

sudo crontab -e
* * * * * [ ! -L /var/www/html/log/tuya_to_mqtt.log ] && ln -s /var/log/tuya_to_mqtt.log /var/www/html/log/tuya_to_mqtt.log

(ou via scenario Jeedom)

Bonjour,
C’est super ça! Je suis impressionné. Jusqu’à quelle distance fonctionne le wifi du chargeur? (mon garage est à 10-12m de la box.

J’ai un peu plus (18m) et malgré un signal faible, qu’il me répète à chaque ouverture de l’appli, ça marche.
Après tout dépend des murs aussi. Si avec ton téléphone tu captes ne serait-ce qu’un peu ta box à la prise alors c’est bon, le wifi du chargeur est meilleur que mon Xiaomi.
Si d’autres vont vers un chargeur de ce type il faut savoir qu’il ne donne pas la conso, ou très mal, j’ai donc ajouté un calcul de conso dans le script.

A ajouté en début de script par exemple après « update_thread_active = False »

last_update_time = time.time() # Stockage du time pour les consommations
total_Wh = 0.0
low_power_count = 0
low_power_threshold = 5  # Nombre d'itérations avant réinitialisation
def load_consumption():
    global total_Wh
    try:
        with open("/home/ton_user/tuya_to_mqtt_conso", "r") as file:
            total_Wh = float(file.read().strip())
            logging.info(f"🔄 Consommation chargée : {total_Wh}")
    except (FileNotFoundError, ValueError):
        logging.warning("⚠️ Fichier consommation introuvable ou corrompu, réinitialisation.")
        total_Wh = 0.0
def save_consumption():
    global total_Wh
    if not isinstance(total_Wh, dict):
        logging.error("❌ Erreur : total_Wh n'est pas un dictionnaire !")
        return
    try:
        with open("/home/ton_user/tuya_to_mqtt_conso", "w") as file:
            file.write(str(total_Wh))
            logging.info(f"💾 Sauvegarde consommation : {total_Wh}Wh")
    except Exception as e:
        logging.error(f"❌ Erreur lors de la sauvegarde de la consommation : {e}")

Et des modifs dans l’exécution principale :

if __name__ == "__main__":
    load_consumption() # ajout : pour la conso
    token = get_access_token()
(suite du code...)
        if device_properties.get("success"):
            properties = device_properties.get("result", {}).get("properties", [])

            # ajout : Calcul de la consommation énergétique avec delta temps et adaptation de interval suivant utilisation
            power_total = next((item["value"] for item in properties if item["code"] == "power_total"), 0)
            work_state = next((item["value"] for item in properties if item["code"] == "work_state"), "")
            delta_time = time.time() - last_update_time  # Temps écoulé depuis la dernière mesure
            last_update_time = time.time()    # Mise à jour du timestamp pour cet appareil
            if power_total > 50:
                interval = 30
                low_power_count = 0  # Réinitialisation du compteur si la charge reprend
                consumed_Wh = power_total * (delta_time / 3600)
                if consumed_Wh > 0:
                    total_Wh += consumed_Wh
                    save_consumption()
                    logging.info(f"⚡ Consommation : {consumed_Wh:.2f}Wh sur {delta_time:.1f}s, Total : {total_Wh:.2f}Wh")
            elif work_state == "charger_charging":
                interval = 30
            elif work_state == "charger_wait":
                interval = 60
            else:
                interval = 300

            if power_total < 50 and total_Wh > 0: # Vérification si la puissance reste faible trop longtemps
                low_power_count += 1
                if low_power_count >= low_power_threshold:
                    logging.info("🔄 Fin de session de charge détectée, réinitialisation de la consommation.")
                    low_power_count = 0
                    total_Wh = 0.0
                    save_consumption()
            # Ajout de la consommation au properties avant envoi MQTT
            properties.append({"code": "session_Wh", "value": round(total_Wh, 2)})
            #ajout : fin
(suite du code...)

:wink:

Bonjour,
Merci de la réponse. Je vais essayer de me lancer. Pour bien comprendre, sur le 1er message du post, comment créer ce fichier python tuya_to_mqtt.py dans le repertoire home (du pi?) .
J’ai vu que certains utilisent le plugin wifilightV2 pour communiquer avec ce genre de borne connecté, est ce que ce ne serait pas plus simple?

Tout ce que j’ai écrit comme commandes se passe en se connectant via SSH sur ton Pi.
Pour le plugin wifilightV2 aucune idée de ses fonctionnalités, il est payant et un peu cher pour si peu de choses donc je n’ai pas testé.