Monitorer batteries Pylontech - Autre méthode

Bonjour à tous, j’avais fait il y a quelques temps un programme python pour aller chercher les infos des batteries Pylontech installées chez moi.

Alors ça fonctionne bien mais le paramétrage est pénible. J’ai donc eu dans l’intention pendant assez longtemps d’utiliser le bloc code des scénarios pour voir si on pouvait en tirer qq chose.

Pour mémoire ou si vous n’êtes pas allés lire le post cité plus haut, j’ai un convertisseur série / Ethernet de la marque Hy Fly le HF2221. Peu importe la marque et le modèle car sur le principe il faut un convertisseur qui permette d’interroger via le réseau en TCP (je ne sais pas trop si on dit comme ça).

Voici le code que je vous propose (bon j’avoue que pour certains points Grok m’a un peu filé un coup de main :wink: ):

// Constantes de connexion
const IP = "192.168.1.18";       // Adresse IP du HF2221
const PORT = 8899;               // Port TCP
const TIMEOUT = 10;              // Timeout en secondes

// Constantes de personnalisation des noms
const VIRTUAL_GLOBAL = "Global batteries";
const VIRTUAL_BAT_PREFIX = "batterie ";
const CMD_VOLTAGE = "Tension";
const CMD_CURRENT = "Intensité";
const CMD_TEMP = "Temp";
const CMD_CAPACITY = "Capacité";
const CMD_SOC = "SOC";
const CMD_CYCLES = "Cycles";       // Cycles par batterie (de getpwr)
const CMD_PACK_CYCLES = "Cycles Pack"; // Cycles totaux du pack (de pwr 1)
const CMD_PACK_DISCHARGE_TIME = "Temps Décharge Pack"; // Temps de décharge du pack (de pwr 1)
const CMD_ERROR = "Erreur TimeOut"; // Compteur de timeouts consécutifs
const CMD_COUNT = "Nb Bat";
const CMD_AVG_VOLTAGE = "Tension Moyenne";
const CMD_TOTAL_CURRENT = "Intensité totale";
const CMD_AVG_SOC = "SOC moyen";
const CMD_CELL_VOLTAGE = "U";
const CMD_CELL_TEMP = "T";
const CMD_STATE = "État";
const CMD_SOH = "SOH";

// Fonction pour envoyer une commande et lire la réponse
function sendCommand($socket, string $commande, $scenario): string {
    fwrite($socket, $commande);
    $scenario->setLog("Commande envoyée : " . trim($commande));
    sleep(2);
    stream_set_blocking($socket, false);

    $reponse = '';
    for ($i = 0; $i < 5 && !feof($socket); $i++) {
        $donnees = fread($socket, 2048);
        if ($donnees !== false && strlen($donnees) > 0) {
            $reponse .= $donnees;
            break;
        }
        $scenario->setLog("Tentative $i : rien lu");
        sleep(1);
    }
    return $reponse;
}

// Fonction pour créer ou mettre à jour un équipement virtuel
function updateVirtual(string $nom, array $commandes, $scenario): void {
    $virtual = eqLogic::byLogicalId($nom, 'virtual');
    if (!is_object($virtual)) {
        $virtual = new eqLogic();
        $virtual->setEqType_name('virtual');
        $virtual->setLogicalId($nom);
        $virtual->setName($nom);
        $virtual->setIsEnable(1);
        $virtual->setIsVisible(1);
        $virtual->save();
        $scenario->setLog("Équipement virtuel '$nom' créé");
    }

    foreach ($commandes as $cmd_name => $valeur) {
        $cmd = $virtual->getCmd(null, $cmd_name);
        if (!is_object($cmd)) {
            $cmd = new virtualCmd();
            $cmd->setEqLogic_id($virtual->getId());
            $cmd->setLogicalId($cmd_name);
            $cmd->setName($cmd_name);
            $cmd->setType('info');
            $subtype = is_numeric($valeur) ? 'numeric' : 'string';
            $cmd->setSubType($subtype);
            $cmd->setIsHistorized($subtype === 'numeric' ? 1 : 0);
            switch (true) {
                case strpos($cmd_name, CMD_VOLTAGE) === 0 || strpos($cmd_name, CMD_CELL_VOLTAGE) === 0: $cmd->setUnite('V'); break;
                case strpos($cmd_name, CMD_CURRENT) === 0: $cmd->setUnite('A'); break;
                case strpos($cmd_name, CMD_TEMP) === 0 || strpos($cmd_name, CMD_CELL_TEMP) === 0: $cmd->setUnite('°C'); break;
                case strpos($cmd_name, CMD_SOC) === 0: $cmd->setUnite('%'); break;
                case $cmd_name === CMD_CAPACITY: $cmd->setUnite('Ah'); break;
                case $cmd_name === CMD_PACK_DISCHARGE_TIME: $cmd->setUnite('s'); break;
            }
            $cmd->save();
            $scenario->setLog("Commande '$cmd_name' ajoutée à '$nom'");
        }
        $virtual->checkAndUpdateCmd($cmd_name, $valeur);
    }
}

// Vérification du plugin Virtuel
$pluginVirtual = plugin::byId('virtual');
if (!is_object($pluginVirtual) || !$pluginVirtual->isActive()) {
    $scenario->setLog("Erreur : Plugin 'Virtuel' non installé ou désactivé.");
    return;
}
$scenario->setLog("Plugin 'Virtuel' actif");

// Récupérer la valeur actuelle de CMD_ERROR
$virtual_global = eqLogic::byLogicalId(VIRTUAL_GLOBAL, 'virtual');
$cmd_error = is_object($virtual_global) ? $virtual_global->getCmd(null, CMD_ERROR) : null;
$error_flag = is_object($cmd_error) ? (int)$cmd_error->execCmd() : 0;
$had_timeout = false; // Indicateur pour savoir si un timeout a eu lieu dans cette exécution

// Connexion au HF2221
$scenario->setLog("Connexion à " . IP . ":" . PORT);
$socket = @fsockopen(IP, PORT, $errno, $errstr, TIMEOUT);
if (!$socket) {
    $error_flag++;
    $had_timeout = true;
    $scenario->setLog("Erreur de connexion : $errstr ($errno)");
    updateVirtual(VIRTUAL_GLOBAL, [CMD_ERROR => $error_flag], $scenario);
    return;
}
$scenario->setLog("Connexion établie");

// Lecture de l'heure avec "time"
$reponse_time = sendCommand($socket, "time\r\n", $scenario);
$system_time = null;
if (!empty($reponse_time)) {
    if (preg_match('/Ds3231\s+(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/', $reponse_time, $match)) {
        $system_time = mktime($match[4], $match[5], $match[6], $match[2], $match[3], $match[1]);
        $scenario->setLog("Heure système : " . $match[1] . "-" . $match[2] . "-" . $match[3] . " " . $match[4] . ":" . $match[5] . ":" . $match[6]);
    }
} else {
    $error_flag++;
    $had_timeout = true;
    $scenario->setLog("Timeout ou erreur sur 'time'");
}

// Vérification et mise à l'heure si décalage > 1h
$local_time = time();
if ($system_time !== null) {
    $time_diff = abs($local_time - $system_time);
    $scenario->setLog("Décalage horaire : $time_diff secondes");
    if ($time_diff > 3600) {
        $date = getdate($local_time);
        $year = substr($date['year'], -2);
        $month = sprintf("%02d", $date['mon']);
        $day = sprintf("%02d", $date['mday']);
        $hour = sprintf("%02d", $date['hours']);
        $minute = sprintf("%02d", $date['minutes']);
        $second = sprintf("%02d", $date['seconds']);
        $time_command = "time $year $month $day $hour $minute $second\r\n";
        $reponse_set_time = sendCommand($socket, $time_command, $scenario);
        if (empty($reponse_set_time)) {
            $error_flag++;
            $had_timeout = true;
            $scenario->setLog("Timeout sur mise à l'heure");
        } else {
            $scenario->setLog("Mise à l'heure effectuée : $year-$month-$day $hour:$minute:$second");
        }
    } else {
        $scenario->setLog("Pas de mise à l'heure nécessaire (décalage < 1h)");
    }
} else {
    $scenario->setLog("Heure système non lue, pas de mise à l'heure effectuée");
}

// Étape 1 : Récupérer l'état global (pwr)
$reponse_pwr = sendCommand($socket, "pwr\r\n", $scenario);
if (empty($reponse_pwr)) {
    $error_flag++;
    $had_timeout = true;
    $scenario->setLog("Aucune donnée reçue pour 'pwr' (timeout)");
    updateVirtual(VIRTUAL_GLOBAL, [CMD_ERROR => $error_flag], $scenario);
    fclose($socket);
    return;
}
$scenario->setLog("Réponse 'pwr' reçue");

// Parser les données de "pwr"
$lignes_pwr = array_filter(explode("\n", $reponse_pwr), 'strlen');
$batteries_installees = 0;
$total_volt = $total_curr = $total_soc = 0;

foreach ($lignes_pwr as $ligne) {
    $valeurs = array_filter(explode(" ", preg_replace('/\s+/', ' ', trim($ligne))), 'strlen');
    if (count($valeurs) >= 15 && is_numeric($valeurs[0]) && $valeurs[0] >= 1 && $valeurs[0] <= 8 && $valeurs[8] !== "Absent") {
        $id = (int)$valeurs[0];
        $batteries_installees = max($batteries_installees, $id);
        $volt = $valeurs[1] == "-" ? 0 : $valeurs[1] / 1000;
        $curr = $valeurs[2] == "-" ? 0 : $valeurs[2] / 1000;
        $temp = $valeurs[3] == "-" ? 0 : $valeurs[3] / 1000;
        $soc = (float)rtrim($valeurs[12], '%');

        updateVirtual(VIRTUAL_GLOBAL, [
            CMD_VOLTAGE . $id => $volt,
            CMD_CURRENT . $id => $curr,
            CMD_SOC . $id => $soc,
            CMD_TEMP . $id => $temp,
        ], $scenario);

        $total_volt += $volt;
        $total_curr += $curr;
        $total_soc += $soc;
    }
}

$scenario->setLog("Batteries détectées : $batteries_installees");
if ($batteries_installees > 0) {
    $moyenne_volt = $total_volt / $batteries_installees;
    $moyenne_soc = $total_soc / $batteries_installees;
    updateVirtual(VIRTUAL_GLOBAL, [
        CMD_AVG_VOLTAGE => $moyenne_volt,
        CMD_TOTAL_CURRENT => $total_curr,
        CMD_AVG_SOC => $moyenne_soc,
        CMD_COUNT => $batteries_installees,
        CMD_ERROR => $error_flag, // Toujours mis à jour ici
    ], $scenario);
    $scenario->setLog(sprintf("Global : %.2f V, %.2f A, %.1f%%, Erreur: %d", $moyenne_volt, $total_curr, $moyenne_soc, $error_flag));
}

// Variables pour stocker les données globales du pack (de pwr 1)
$pack_cycles = 0;
$pack_discharge_time = 0;

// Étape 2 : Détails par batterie (pwr X + getpwr X)
for ($i = 1; $i <= $batteries_installees; $i++) {
    // Récupérer pwr X
    $reponse_pwr = sendCommand($socket, "pwr $i\r\n", $scenario);
    if (empty($reponse_pwr)) {
        $error_flag++;
        $had_timeout = true;
        $scenario->setLog("Aucune donnée pour 'pwr $i' (timeout)");
        continue;
    }
    $scenario->setLog("Réponse 'pwr $i' reçue");

    $lignes_pwr = array_filter(explode("\n", $reponse_pwr), 'strlen');
    $commandes = [];

    foreach ($lignes_pwr as $ligne) {
        $ligne = trim($ligne);
        if (preg_match('/Voltage\s*:\s*(\d+)\s*mV/', $ligne, $match)) {
            $commandes[CMD_VOLTAGE] = $match[1] / 1000;
        } elseif (preg_match('/Current\s*:\s*(-?\d+)\s*mA/', $ligne, $match)) {
            $commandes[CMD_CURRENT] = $match[1] / 1000;
        } elseif (preg_match('/Temperature\s*:\s*(\d+)\s*mC/', $ligne, $match)) {
            $commandes[CMD_TEMP] = $match[1] / 1000;
        } elseif (preg_match('/Coulomb\s*:\s*(\d+)\s*%/', $ligne, $match)) {
            $commandes[CMD_SOC] = (int)$match[1];
        } elseif (preg_match('/Total Coulomb\s*:\s*(\d+)\s*mAH/', $ligne, $match)) {
            $commandes[CMD_CAPACITY] = $match[1] / 1000;
        } elseif (preg_match('/Charge Times\s*:\s*(\d+)/', $ligne, $match)) {
            $pack_cycles = (int)$match[1];
        } elseif (preg_match('/Basic Status\s*:\s*(\w+)/', $ligne, $match)) {
            $commandes[CMD_STATE] = $match[1];
        } elseif (preg_match('/Discharge Sec\.\s*:\s*(\d+)\s*s/', $ligne, $match)) {
            $pack_discharge_time = (int)$match[1];
        } elseif (preg_match('/Soh\. Status\s*:\s*(\w+)/', $ligne, $match)) {
            $commandes[CMD_SOH] = $match[1];
        }
    }

    // Récupérer getpwr X pour les cellules et cycles individuels
    $reponse_getpwr = sendCommand($socket, "getpwr $i\r\n", $scenario);
    if (!empty($reponse_getpwr)) {
        $lignes_getpwr = array_filter(explode("\n", $reponse_getpwr), 'strlen');
        $elements = 0;
        $parsed = false;

        foreach ($lignes_getpwr as $ligne) {
            $valeurs = array_filter(explode("#", trim($ligne)), 'strlen');
            if (count($valeurs) >= 8 && !$parsed) {
                $commandes[CMD_VOLTAGE] = $valeurs[0] / 1000;
                $commandes[CMD_CURRENT] = $valeurs[1] / 1000;
                $commandes[CMD_TEMP] = $valeurs[2] / 1000;
                $parsed = true;
            } elseif (count($valeurs) >= 4 && $parsed && $elements < 15) {
                $elements++;
                $commandes[CMD_CELL_VOLTAGE . $elements] = $valeurs[0] / 1000;
                $commandes[CMD_CELL_TEMP . $elements] = $valeurs[1] / 1000;
                if ($elements <= 3) {
                    $scenario->setLog(sprintf("Batterie %d - Élément %d : %.3f V, %.1f °C", $i, $elements, $commandes[CMD_CELL_VOLTAGE . $elements], $commandes[CMD_CELL_TEMP . $elements]));
                }
            } elseif (count($valeurs) == 1 && is_numeric($valeurs[0]) && $valeurs[0] > 0 && $elements == 15) {
                $commandes[CMD_CYCLES] = (int)$valeurs[0];
            }
        }

        if ($elements < 15) {
            $scenario->setLog("Batterie $i - Attention : $elements éléments détectés (15 attendus)");
        }
    } else {
        $error_flag++;
        $had_timeout = true;
        $scenario->setLog("Aucune donnée pour 'getpwr $i' (timeout)");
    }

    // Log des données principales
    if (!empty($commandes[CMD_VOLTAGE])) {
        $scenario->setLog(sprintf("Batterie %d : %.3f V, %.3f A, %.1f °C, %.2f Ah, %d%%, %s, SOH: %s, Cycles: %d",
            $i, $commandes[CMD_VOLTAGE], $commandes[CMD_CURRENT], $commandes[CMD_TEMP],
            $commandes[CMD_CAPACITY], $commandes[CMD_SOC], $commandes[CMD_STATE],
            $commandes[CMD_SOH], $commandes[CMD_CYCLES] ?? 0));
    }

    updateVirtual(VIRTUAL_BAT_PREFIX . $i, $commandes, $scenario);
}

// Ajouter les données globales du pack à Global batteries
if ($batteries_installees > 0) {
    // Si pas de timeout dans cette exécution, réinitialiser à 0
    $error_flag = $had_timeout ? $error_flag : 0;
    $global_data = [CMD_ERROR => $error_flag]; // Toujours inclus
    if ($pack_cycles > 0) $global_data[CMD_PACK_CYCLES] = $pack_cycles;
    if ($pack_discharge_time > 0) $global_data[CMD_PACK_DISCHARGE_TIME] = $pack_discharge_time;
    updateVirtual(VIRTUAL_GLOBAL, $global_data, $scenario);
    $scenario->setLog("Pack : Cycles = $pack_cycles, Temps Décharge = $pack_discharge_time s");
} else {
    // Si aucune batterie détectée mais connexion OK, forcer Erreur TimeOut à être mis à jour
    updateVirtual(VIRTUAL_GLOBAL, [CMD_ERROR => $error_flag], $scenario);
}

// Fermeture
fclose($socket);
$scenario->setLog("Connexion fermée");

Alors il faut donc créer un scénario, programmer son exécution, ajouter un bloc code et y coller ce que vous avez ci dessus.

Au début du code vous trouverez toutes les constantes vous permettant d’adapter le résultat à ce que vous souhaitez.

Il vous faudra à minima paramétrer l’IP de votre convertisseur et le port TCP qu’il utilise comme serveur vis à vis de la connexion série. Le reste sert pour les appellations des batterie, des tensions, … A vous de voir

A la 1ère utilisation le code va créer un virtuel reprenant le global (appelé « Global Batterie » dans mon exemple) et autant de batteries que celles qui sont installées. Dans chaque virtuel il créera les commandes retrouvées lors de sa scrutation. Voici par exemple chez moi où j’ai 4 batteries installées le virtuel « Global Batterie »:

Et en exemple la première batterie:

La commande « Dernière mise à jour » je l’ai rajoutée à la main, elle ne figure pas dans le code.

L’heure des batteries est testée et si la différence est supérieure à 1h alors la mise à jour est faite.

Le code teste et comptabilise les « time out » car sur mon hf2221 j’ai remarqué que parfois il se bloque et il faut l’éteindre et le rallumer pour que ça reparte. J’ai donc installé un sonoff R2 hacké pour le commander à OFF si le time out et >2 (attention si vous faites ça car à la 1ère ré interrogation des batterie il y a 4 time out généré et je n’ai pas cherché à les enlever, il faut donc en tenir compte dans votre scénario éventuel) mais ça c’est chacun qui voit :wink:

Bon code :wink:

PS: si vous aviez déjà utilisé mon précédent code en python il faut que vous repartiez avec des noms de virtuels différents. Comme le nouveau code se base sur les logicalId qui n’ont pas forcément été crées cela fait des erreurs et je n’ai pas réussi facilement à m’en sortir j’ai donc laissé tomber l’idée de récupérer les anciens virtuels. Si vous avez des historiques à conserver vous pouvez toujours les transférer après.

4 « J'aime »

Bonjour et merci pour ce partage.

Comme la version précédente, ça fonctionne parfaitement bien.
Un jolie code, bien commenter, un vrai régal !

1 « J'aime »

Bonsoir.
Merci pour se super bloc scénario, ca marche super bien.
J’utilisais l’ancienne méthode via le script, suite à l’ajout de 2 batteries supplémentaires, il n’a j’aimais voulu redémarré.
Et je suis tombé au grès de mes recherches sur votre dernier bloc code pour la relève des pylontech.
Comme mon HF était déjà configuré, j’ai mis quelques minutes à mettre tout ca en route.
j’ai quand même un petit souci de temps en temps, j’ai souvent des time out, et je ne sais pas pourquoi.
Avez vous une idées.
Je joint le log.
Merci d’avance .
bonne soirée.

Chez moi c’est le hf qui parfois se bloque et je l’ai donc alimenté via un relais que je coupe et rallume lorsque le hf se bloque

Bonsoir.
Merci pour votre réponse, c’est se que j’avais déjà fait la dernière fois sur votre conseille.
Le HF est bien en ligne mais ne répond pas aux commandes du bloc code.
Bonne soirée.

Et donc l’extinction puis allumage ne marche pas ?

Bonsoir oui c’est ca, ca ne marche pas vu que le hf est toujours en ligne.
quand je passe une commande via putty ca a l’air de marché.
je vous joint un log un peux étrange à mon avis.


Merci est bonne soirée

Tu as 7 batteries ?

Bonjour.
oui 7 batteries Le HF en bien en ligne.
Accès au HF OK via l’ip .
Pas accès via putty, je ne tombe pas sur le promt pylon.
Depuis ce matin rien ne remonte.
Bone journée

si tu ne peux pas y accéder par putty alors ça ne doit pas être un problème du script. C’est étonnant car sur ta copie d’écran des logs on voit des infos remonter des 2 premières batteries et à la 5ème tentative pour la 2ème batterie. Tu accèdes à ton HF par son adresse ip?

Bonsoir.
j’ai refait quelques essais, le ping du HF et ok mais je ne peux pas le joindre par l’ip.
Via putty j’arrive sur le prompt mais la commande pwr ne répond pas.
j’ai l’impression que le HF me pose problème vu qu’il est pas joignable en permanence.
Merci d’avance.

Tout les commandes du HF ou de PUTTY répondent aléatoirement .

là je vais avoir du mal à t’aider, c’est un problème sur ton installation et je ne sais pas trop quoi te dire…

Bonsoir oui effectivement j’ai le HF qui est mort il ne répond plus du tout.

Au moins tu es fixé