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>
190 lines
7.3 KiB
Python
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())
|