Patrick Schurig 366d332bf4 sunspec: rendre le verdict de read-float-order.py indépendant de la version
Le message affirmait « le plugin lit de travers », vrai avant 1.15.2+etm9 et
faux depuis. L'outil sert à mesurer un APPAREIL, pas à juger une version : il
dit maintenant ce que lirait un lecteur gardant le défaut de la bibliothèque,
et rappelle depuis quelle version le vendor Fronius force big endian.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 07:44:10 +02:00

190 lines
7.3 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""Quel ordre de mots un onduleur SunSpec émet-il pour ses float32 ?
La spécification SunSpec dit big endian, mot de poids fort en premier. Mais
libnymea-sunspec décode par défaut `ByteOrderLittleEndian`, c'est à dire
`registers.at(1)` PUIS `registers.at(0)` — mot de poids faible d'abord
(sunspecdatapoint.cpp, convertToUInt32). Le vendor Fronius du plugin garde ce
défaut. Si l'appareil émet en big endian, le plugin lit des dénormaux (~1e-41)
et aucune correction de SIGNE n'y changera quoi que ce soit.
Ce script tranche la question par la mesure, sur deux points dont la valeur
attendue est connue d'avance et non ambiguë :
- Hz : ~50 Hz
- PhVphA : ~230..240 V
Un ordre donne ces valeurs, l'autre donne des dénormaux. Il n'y a pas de doute
possible entre les deux.
LECTURE SEULE : ce script n'émet QUE des fonctions Modbus 3 (read holding
registers). Aucune écriture, aucun code fonction d'écriture n'est implémenté.
python3 read-float-order.py 192.168.3.230
python3 read-float-order.py 192.168.3.230 --inverter-unit 1 --meter-unit 200
Bibliothèque standard uniquement : rien à installer.
"""
import argparse
import socket
import struct
import sys
SUNS = (0x5375, 0x6E53)
# Offsets comptés depuis le registre ID du modèle (les données commencent à +2).
# Chaque point float32 occupe DEUX registres, d'où le pas de 2.
INV_F = {"W": 22, "Hz": 24, "PhVphA": 16, "PhVphB": 18, "PhVphC": 20, "DCW": 38}
MET_F = {"W": 28, "WphA": 30, "WphB": 32, "WphC": 34,
"Hz": 26, "PhV": 10, "PhVphA": 12, "PhVphB": 14, "PhVphC": 16}
INVERTER_FLOAT = (111, 112, 113)
METER_FLOAT = (211, 212, 213, 214)
INVERTER_INT = (101, 102, 103)
METER_INT = (201, 202, 203, 204)
def read_holding(sock, unit, addr, count):
"""Fonction 3, et rien d'autre."""
sock.sendall(struct.pack(">HHHBBHH", 1, 0, 6, unit, 3, addr, count))
hdr = b""
while len(hdr) < 8:
chunk = sock.recv(8 - len(hdr))
if not chunk:
raise IOError("connexion fermée (en-tête)")
hdr += chunk
_, _, length, _, fc = struct.unpack(">HHHBB", hdr)
body = b""
while len(body) < length - 2:
chunk = sock.recv(length - 2 - len(body))
if not chunk:
raise IOError("connexion fermée (corps)")
body += chunk
if fc & 0x80:
raise IOError("exception modbus %d en lisant %d registres à %d"
% (body[0], count, addr))
return list(struct.unpack(">%dH" % (body[0] // 2), body[1:1 + body[0]]))
def decode(r0, r1, low_word_first):
hi, lo = (r1, r0) if low_word_first else (r0, r1)
return struct.unpack(">f", struct.pack(">HH", hi, lo))[0]
def find_base(sock, unit):
for base in (40000, 50000, 0):
try:
if tuple(read_holding(sock, unit, base, 2)) == SUNS:
return base
except IOError:
continue
return None
def walk(sock, unit, base):
"""(model_id, adresse du registre ID, longueur) pour toute la chaîne."""
out, addr = [], base + 2
while addr < base + 5000:
mid, mlen = read_holding(sock, unit, addr, 2)
if mid == 0xFFFF:
return out
out.append((mid, addr, mlen))
addr += 2 + mlen
return out
def verdict(hz_low, hz_big):
"""Quel ordre donne une fréquence réseau plausible ?"""
def plausible(v):
return 45.0 <= v <= 65.0
if plausible(hz_low) and not plausible(hz_big):
return "MOT DE POIDS FAIBLE D'ABORD (ce que le plugin lit déjà)"
if plausible(hz_big) and not plausible(hz_low):
return ("BIG ENDIAN, conforme à la spec SunSpec — un plugin qui garde le "
"défaut de libnymea-sunspec lira des dénormaux")
if hz_low == 0.0 and hz_big == 0.0:
return "INDÉCIDABLE : registres à zéro, aucun ordre ne se distingue"
return "INDÉCIDABLE : aucune des deux lectures ne donne 45..65 Hz"
def probe(host, port, unit, wanted_float, wanted_int, table, points, role):
print("=" * 74)
print("%s — unité %d" % (role, unit))
print("=" * 74)
sock = socket.create_connection((host, port), timeout=10)
try:
base = find_base(sock, unit)
if base is None:
print(" aucun marqueur « SunS » : rien à cette unité.\n")
return
chain = walk(sock, unit, base)
print(" base %d · chaîne : %s" % (
base, ", ".join("%d@%d(L=%d)" % (m, a, l) for m, a, l in chain)))
found = [c for c in chain if c[0] in wanted_float]
if not found:
ints = [c for c in chain if c[0] in wanted_int]
if ints:
print(" modèle %d : ENTIER+SF, pas de float32 — la question de "
"l'ordre des mots ne se pose pas ici.\n" % ints[0][0])
else:
print(" aucun modèle attendu dans cette chaîne.\n")
return
mid, id_addr, mlen = found[0]
print(" modèle float %d, registre ID %d, longueur %d\n" % (mid, id_addr, mlen))
print(" %-8s %-9s %-14s %14s %16s %s" % (
"point", "registre", "brut (hex)", "mot faible 1er", "big endian", "lecture"))
print(" " + "-" * 86)
hz = None
for name in points:
off = table[name]
addr = id_addr + off
r0, r1 = read_holding(sock, unit, addr, 2)
low, big = decode(r0, r1, True), decode(r0, r1, False)
if name == "Hz":
hz = (low, big)
note = "nul — n'apprend rien" if (r0 == 0 and r1 == 0) else ""
print(" %-8s %-9d %-14s %14.4f %16.4e %s" % (
name, addr, "%04X %04X" % (r0, r1), low, big, note))
print()
if hz:
print(" >>> %s" % verdict(*hz))
print()
finally:
sock.close()
def main():
ap = argparse.ArgumentParser(
description="Ordre des mots des float32 SunSpec — LECTURE SEULE.")
ap.add_argument("host")
ap.add_argument("--port", type=int, default=502)
ap.add_argument("--inverter-unit", type=int, default=1)
ap.add_argument("--meter-unit", type=int, default=200)
args = ap.parse_args()
# Le COMPTEUR d'abord : c'est lui qui tranche. Il mesure le réseau en
# permanence, donc ses registres portent des valeurs même onduleur en veille.
# Un onduleur au repos sert un bloc à zéro, et zéro se décode en 0.0 dans les
# DEUX ordres : ça n'apprend rien.
probe(args.host, args.port, args.meter_unit, METER_FLOAT, METER_INT,
MET_F, ("Hz", "PhVphA", "PhVphB", "PhVphC", "W", "WphA"),
"COMPTEUR — mesure décisive")
probe(args.host, args.port, args.inverter_unit, INVERTER_FLOAT, INVERTER_INT,
INV_F, ("Hz", "PhVphA", "PhVphB", "PhVphC", "W"),
"ONDULEUR — confirmation, nul si en veille")
print("Lecture : la colonne qui donne ~50 Hz et ~230..240 V est l'ordre que")
print("l'appareil émet. L'autre affiche des dénormaux (~1e-41) : c'est la")
print("signature d'une mauvaise interprétation, pas une vraie mesure.")
print()
print("Si « big endian » est la bonne colonne, tout lecteur qui garde le défaut")
print("de libnymea-sunspec (mot faible d'abord) lira ces modèles de travers.")
print("Le vendor Fronius du plugin force big endian depuis 1.15.2+etm9.")
return 0
if __name__ == "__main__":
sys.exit(main())