debian: changelog entry 1.15.2+etm9
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d08cf506ee
commit
99bc606198
20
debian/changelog
vendored
20
debian/changelog
vendored
@ -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.
|
||||
|
||||
187
sunspec/tools/read-float-order.py
Normal file
187
sunspec/tools/read-float-order.py
Normal 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())
|
||||
Loading…
x
Reference in New Issue
Block a user