From 99bc6061985a5b40f1cac35013c74cacf9d1388f Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 7 Aug 2026 07:30:34 +0200 Subject: [PATCH] debian: changelog entry 1.15.2+etm9 Co-Authored-By: Claude Opus 5 (1M context) --- debian/changelog | 20 ++++ sunspec/tools/read-float-order.py | 187 ++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 sunspec/tools/read-float-order.py diff --git a/debian/changelog b/debian/changelog index 2778197..2811bec 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,23 @@ +etm-powersync-plugins-modbus (1.15.2+etm9) trixie; urgency=medium + + * sunspec: read Fronius float32 points big endian, the way the hardware + emits them. byteOrder() returned ByteOrderLittleEndian on the grounds that + "the SunSpec default is assumed"; that conflated the default of the + specification with libnymea-sunspec's own, which is the opposite. Every + float32 of the Fronius personality was therefore read with its two + registers swapped. Measured on a Symo, read only: the model 213 meter on + unit 200 serves Hz = 4248 0000, which is 50.0000 big endian and 2.3777e-41 + swapped. Reproduced on the bench: a meter importing 2.7 kW reported as + exporting 107 MW, with the arbiter switching every load on, and the thing + still reporting itself connected with no Modbus error. The meter sign fix + only becomes observable once this is right. solarEdge already forces big + endian and the generic vendor keeps its user parameter; neither changes. + * sunspec: add tools/read-float-order.py, a read-only probe that decodes Hz + and PhVphA both ways so the word order can be settled by measurement + rather than assumption. + + -- Patrick Schurig Fri, 07 Aug 2026 07:00:00 +0200 + etm-powersync-plugins-modbus (1.15.2+etm8) trixie; urgency=medium * sunspec: ask the connection thing directly instead of resolving it by id. diff --git a/sunspec/tools/read-float-order.py b/sunspec/tools/read-float-order.py new file mode 100644 index 0000000..7da351a --- /dev/null +++ b/sunspec/tools/read-float-order.py @@ -0,0 +1,187 @@ +#!/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, spec SunSpec — LE PLUGIN LIT CES MODÈLES DE TRAVERS") + 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, le plugin lit ces modèles de") + print("travers, car libnymea-sunspec décode en mot faible d'abord par défaut.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())