Ze2MQTT : automatiser sa Dacia Spring en MQTT

Préambule

Ce projet Ze2MQTT s’intègre dans ma stratégie globale de domotisation de la recharge de mon véhicule électrique Dacia Spring.

Mon script Python s’appuie sur le package renault-api.

L’objectif est de récupérer régulièrement le statut du véhicule (batterie, autonomie, kilométrage…) pour historiser ces données, et surtout de les exploiter dans Node-RED, notamment pour estimer le temps de recharge nécessaire.

Pré-requis

Avant de commencer, assure-toi d’avoir :
✔️ Une machine virtuelle (VM) ou un nano-ordinateur (Raspberry Pi, Debian, etc.).
✔️ Un accès SSH et les droits root.
✔️ Docker installé. (article en préparation)

Etape par étape

Les dépendances système (APT)

sudo apt update
sudo apt install -y python3 python3-pip

…python

J’enchaine sur les dépendances Python (PIP)

pip install renault-api renault-api[cli]

Récupération des identifiants Renault

Avant de créer le container Docker pour mon script je lance la commande suivante afin obtenir les KAMEREON_ACCOUNT_ID et VEHICLE_VIN.

Le VIN (Vehicle Identification Number) est également inscrit sur la carte grise et visible dans l’application mobile MyDacia.

renault-api --log status

Voici les réponses obtenu :

KAMEREON_ACCOUNT_ID corresponds à l’ID en face de la ma marque, dans mon cas MYDACIA

Please select a locale [en_US]: fr_FR
Do you want to save the locale to the credential store? [y/N]: y
User: ton_email
Password: ton_mot_de_passe

    ID                                    Type         Vehicles
--  ------------------------------------  ---------  ----------
 1  xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx  MYDACIA             1
 2  xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx  MYRENAULT           0
 3  xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx  SFDC                0

Please select account [2]: 1
Do you want to save the account ID to the credential store? [y/N]: y

    Vin                Registration    Brand    Model
--  -----------------  --------------  -------  -------
 1  XXXXXXXXXXXXXXXXX  XX000XX         DACIA    SPRING

Please select vehicle [1]: 1
Do you want to save the VIN to the credential store? [y/N]: y

charge-mode: The access is forbidden
lock status: {"errors":[{"errorCode":404,"errorMessage":"There is no data for this vin and uid","errorLevel":"error","errorType":"functional"}],"error_reference":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}
res state: The access is forbidden
pressure: {"errors":[{"errorCode":404,"errorMessage":"There is no data for this vin and uid","errorLevel":"error","errorType":"functional"}],"error_reference":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}
----------------  -------------------------
Battery level     72 %
Last updated      2025-03-11 07:51:32
Range estimate    136 km
Plug state        PlugState.UNPLUGGED
Charging state    ChargeState.NOT_IN_CHARGE
Time remaining    165 min
Total mileage     21488.0 km
GPS Latitude      88.07982326
GPS Longitude     16.5960393
GPS last updated  2025-03-11 07:50:11
HVAC status       off
----------------  -------------------------

Création du container Docker

(À venir un article dédié : “Le Dockerfile du Destin” – une image Docker unique pour tous tes bidule2MQTT 😉)

Je crée le répertoire d’installation :

mkdir -p ~/docker/ze2mqtt && cd ~/docker/ze2mqtt

Création d’un fichier docker-compose.yml :

nano docker-compose.yml

Contenu du fichier :

services:
  ze2mqtt:
    build: .
    container_name: ze2mqtt
    restart: unless-stopped
    volumes:
      - ./ze2mqtt.py:/app/ze2mqtt.py
      - ./config.json:/app/config.json

    logging:
      driver: journald
    environment:
      - TZ=Europe/Paris  # Pour que les logs soient à la bonne heure

Création d’un fichier dockerfile :

nano dockerfile

Contenu du fichier :

# Image de base légère avec Python
FROM python:3.11-slim

# Variables d’environnement par défaut (modifiable via docker run ou compose)
ENV PYTHONUNBUFFERED=1

# Dossier de travail dans le conteneur
WORKDIR /app

# Copie des fichiers dans le conteneur
COPY requirements.txt ./

# Installation des dépendances
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
    build-essential \
    libssl-dev \
    libffi-dev \
    python3-dev \
    && rm -rf /var/lib/apt/lists/*

RUN pip install --no-cache-dir -r requirements.txt

# Commande lancée automatiquement
CMD ["python", "ze2mqtt.py"]

Création d’un fichier requirements.txt :

nano requirements.txt

Contenu du fichier :

paho-mqtt
aiohttp
renault-api

Le script de status

Je crée le script ze2mqtt.py celui qui va actualiser le statut de mon VE

nano ze2mqtt.py

Contenu du fichier :

import asyncio
import json
import logging
import os
import signal
import sys
import aiohttp
import paho.mqtt.client as mqtt
from renault_api.renault_client import RenaultClient

# Chargement du fichier de configuration JSON
def load_config():
    with open('./config.json', 'r') as f:
        config = json.load(f)
    return config

config = load_config()

# Configuration
LOG_LEVEL = config.get("log_level", "INFO").upper()

## Conf -> RENAULT
RENAULT_EMAIL = config["renault"]["email"]
RENAULT_PASSWORD = config["renault"]["password"]
RENAULT_LOCALE = config["renault"]["locale"]

## Conf -> MQTT
MQTT_BROKER = config["mqtt"]["broker"]
MQTT_PORT = config["mqtt"]["port"]
MQTT_USERNAME = config["mqtt"].get("username")
MQTT_PASSWORD = config["mqtt"].get("password")
MQTT_PREFIX = config["mqtt"]["prefix_topic"]
INTERVAL = config["interval"]["basic_info_period"]

# Configuration du logger
logging.basicConfig(level=getattr(logging, LOG_LEVEL, logging.INFO),
                    format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger("Ze2MQTT")

# Initialisation MQTT
mqtt_client = None
def init_mqtt():
    client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
    if MQTT_USERNAME:
        client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
    client.on_connect = on_connect
    client.on_message = lambda client, userdata, msg: asyncio.run(on_message(client, userdata, msg))
    client.connect(MQTT_BROKER, MQTT_PORT, 60)
    client.loop_start()
    return client

# Publication MQTT
def publish(client, topic, payload, retain=False):
    client.publish(topic, json.dumps(payload), retain=retain)
    logger.debug(f"Publié: {topic} -> {payload}")

# Fonction appelée en cas de connexion au broker MQTT
def on_connect(client, userdata, flags, reason_code, properties):
    if reason_code == 0:
        logger.info("Connecté au broker MQTT")
        for vehicle_config in config["vehicle"]:
            vin = vehicle_config["vin"]
            topic = f"{MQTT_PREFIX}/{vin}/command"
            client.subscribe(topic)
            logger.info(f"Souscription au topic de commande : {topic}")
    else:
        logger.error(f"Erreur de connexion MQTT : {reason_code}")

# Construction du topic spécifique au VE
def build_topic(vehicle_config):
    vin = vehicle_config["vin"]
    name = vehicle_config.get("friendly_name", vin)
    return f"{MQTT_PREFIX}/{vin}/{name}"

# Fonction appelée lors de la réception d'un message sur le topic de commande
async def on_message(client, userdata, msg):
    try:
        topic_parts = msg.topic.split('/')
        if len(topic_parts) < 3:
            logger.warning(f"Topic invalide: {msg.topic}")
            return

        vin = topic_parts[1]
        command = msg.payload.decode().strip().lower()
        logger.info(f"Commande MQTT reçue pour {vin} : {command}")

        # On cherche la config du bon véhicule
        vehicle_config = next((v for v in config["vehicle"] if v["vin"] == vin), None)
        if vehicle_config is None:
            logger.error(f"Aucun véhicule trouvé pour VIN: {vin}")
            return

        if command == "update":
            logger.info(f"Mise à jour forcée demandée pour {vin}")
            data = await fetch_renault_data(vehicle_config)
            topic = build_topic(vehicle_config)
            publish(mqtt_client, topic, json.loads(data))
        elif command == "set_charge_start":
            logger.info(f"Commande de démarrage de charge reçue pour {vin}")
            await set_charge(vehicle_config, start=True)
        elif command == "set_charge_stop":
            logger.info(f"Commande d'arrêt de charge reçue pour {vin}")
            await set_charge(vehicle_config, start=False)
        else:
            logger.warning(f"Commande inconnue: {command}")

    except Exception as e:
        logger.error(f"Erreur dans le callback MQTT: {e}")

async def fetch_renault_data(vehicle_config):
    async with aiohttp.ClientSession() as websession:
        client = RenaultClient(websession=websession, locale=RENAULT_LOCALE)
        await client.session.login(RENAULT_EMAIL, RENAULT_PASSWORD)

        account = await client.get_api_account(vehicle_config["kamereon_account_id"])
        vehicle = await account.get_api_vehicle(vehicle_config["vin"])
        
        battery_status = await vehicle.get_battery_status()
        cockpit_info = await vehicle.get_cockpit()

        data = {
            "battery-status": {
                "timestamp": battery_status.timestamp,
                "batteryLevel": battery_status.batteryLevel,
                "batteryAutonomy": battery_status.batteryAutonomy,
                "plugStatus": battery_status.plugStatus,
                "chargingStatus": battery_status.chargingStatus,
                "chargingRemainingTime": battery_status.chargingRemainingTime
            },
            "cockpit": {
                "totalMileage": cockpit_info.totalMileage
            }
        }
        return data

async def set_charge(vehicle_config, start=True):
    try:
        async with aiohttp.ClientSession() as websession:
            client = RenaultClient(websession=websession, locale=RENAULT_LOCALE)
            await client.session.login(RENAULT_EMAIL, RENAULT_PASSWORD)

            account = await client.get_api_account(vehicle_config["kamereon_account_id"])
            vehicle = await account.get_api_vehicle(vehicle_config["vin"])

            if start:
                await vehicle.set_charge_start()
                logger.info(f"Charge démarrée pour {vehicle_config['vin']}")
            else:
                await vehicle.set_charge_stop()
                logger.info(f"Charge arrêtée pour {vehicle_config['vin']}")
    except Exception as e:
        logger.error(f"Erreur lors du {'démarrage' if start else 'stop'} de la charge : {e}")

async def main_loop():
    global mqtt_client
    mqtt_client = init_mqtt()
    logger.info("Démarrage du daemon Ze2MQTT")

    while True:
        for vehicle_config in config["vehicle"]:
            try:
                vin = vehicle_config["vin"]
                kam_id = vehicle_config["kamereon_account_id"]
                name = vehicle_config.get("friendly_name", vin)
                topic = build_topic(vehicle_config)

                data = await fetch_renault_data(vehicle_config)
                publish(mqtt_client, topic, data)

            except Exception as e:
                logger.error(f"Erreur pour le véhicule {vehicle_config['vin']} : {e}")

        await asyncio.sleep(INTERVAL)

# Gestion des signaux pour arrêter proprement le daemon
def signal_handler(sig, frame):
    logger.info("Arrêt du daemon Ze2MQTT")
    mqtt_client.loop_stop()
    mqtt_client.disconnect()
    sys.exit(0)

signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)

if __name__ == "__main__":
    try:
        asyncio.run(main_loop())
    except KeyboardInterrupt:
        signal_handler(signal.SIGINT, None)

Le fichier de configuration

Je crée ensuite le fichier de configuration config.json qui permet d’ajuster les véhicules, les paramètres MQTT et le niveau de log.

nano config.json

Contenu du fichier :

{
  "log_level": "WARNING",
  "renault": {
    "email": "ton_email@example.com",
    "password": "ton_mot_de_passe",
    "locale": "fr_FR",
    "country": "FR"
  },
  "mqtt": {
    "prefix_topic": "RenaultZE",
    "broker": "localhost",
    "port": 1883,
    "username": "mon_utilisateur",
    "password": "mon_mot_de_passe"
  },
  "interval": {
    "basic_info_period": 3600
  },
  "vehicle": [
    {
      "kamereon_account_id": "47a88a18-5084-4598-9664-1dc9d4737a02",
      "vin": "UU1DBG003MU027470",
      "friendly_name": "Spring"
    }
  ]
}

Et côté journald, tu règles une limite avec :

mkdir -p /etc/systemd/journald.conf.d
nano /etc/systemd/journald.conf.d/ze2mqtt.conf

Contenu du fichier :

[Journal]
SystemMaxUse=100M
SystemMaxFileSize=10M
MaxRetentionSec=1month

Puis :

sudo systemctl restart systemd-journald

Compilation du container

La structure doit ressembler à ça :

.
├── config.json
├── docker-compose.yml
├── dockerfile
├── requirements.txt
└── ze2mqtt.py

Maintenant que tous fichiers sont présent dans mon dossier d’installation je lance la compilation :

docker compose build

Puis exécute :

docker compose up -d

Si tu as besoin de modifier le fichier config.json, il te suffit de relancer le container :

docker compose restart ze2mqtt

Et pour consulter le journal (il existe d’autre méthode) :

journalctl CONTAINER_NAME=ze2mqtt -f

Conclusion

Voici une solution robuste RenaultZE2MQTT, reposant sur un script Python exécuté dans un container Docker, utilisant la bibliothèque renault-api. Elle interroge automatiquement une ou plusieurs voitures Renault/Dacia et transmet les statuts vers un broker MQTT.

Elle inclut également un système d’écoute du topic {MQTT_PREFIX}/{VIN}/command avec la commande update pour forcer une actualisation à la demande. Les commandes set_charge_start ou set_charge_stop quand à elles vont démarrer ou arrêter un cycle de charge.

Par exemple, via une automatisation sur mon téléphone, je déclenche cette commande lorsque je passe en mode de concentration « Conduite », ou encore lorsque je me connecte/déconnecte en Bluetooth au système audio du véhicule.
👉 Cela me permet d’obtenir des statistiques plus précises sur mes trajets et ma consommation.

Sources :
https://github.com/hacf-fr/renault-api

One thought on “Ze2MQTT : automatiser sa Dacia Spring en MQTT

Laisser un commentaire

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