debian: changelog entry 1.15.2+etm9

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-08-07 07:30:34 +02:00
parent d08cf506ee
commit 99bc606198
2 changed files with 207 additions and 0 deletions

20
debian/changelog vendored
View File

@ -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 <patrick@etm-schurig.eu> 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.

View File

@ -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())