Patrick Schurig c459ba2f9b sunspec: antidote triphasé et outil de relevé du modèle 124
L'antidote AC-16 triphasé verrouille le chemin réellement emprunté par le
matériel terrain, reproduit sur le simulateur en profil Symo (onduleur
modèle 103, compteur modèle 203) : compteur à -500/-100/-100 W, que le
plugin etm6 rapportait en +500/+100/+100.

tools/read-model124.py relève en lecture seule le bloc storage d'un
onduleur, sans aucune dépendance à installer. Il sert à lever D-6 : le côté
« batterie présente » du discriminateur reste inféré faute d'un GEN24 équipé.
Sur le simulateur il montre pourquoi ni le SoC ni les consignes ne peuvent
servir de discriminateur : le bloc annonce un SoC de 50 %, un statut Holding
et WChaMax à 10 kW alors qu'InBatV et StorAval sont nuls.

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

168 lines
5.7 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""Read-only dump of the SunSpec storage block (model 124) of an inverter.
Answers one question: does this inverter have a battery physically installed?
See sunspec/DIVERGENCES.md D-6 — the "battery present" side of the presence
discriminator is still inferred and needs a reading from real hardware.
Standard library only: no pymodbus, no pysunspec2, nothing to install.
It only ever issues function code 3 (read holding registers). It never writes.
python3 read-model124.py 192.168.1.50
python3 read-model124.py fronius.local --unit 1
Report back the InBatV, StorAval and ChaSt lines.
"""
import argparse
import socket
import struct
import sys
SUNS = (0x5375, 0x6E53) # "SunS"
# Model 124, in order, from the SunSpec storage model definition.
# (name, offset, kind, scale-factor name or None)
POINTS = [
("WChaMax", 0, "u16", "WChaMax_SF"),
("WChaGra", 1, "u16", "WChaDisChaGra_SF"),
("WDisChaGra", 2, "u16", "WChaDisChaGra_SF"),
("StorCtl_Mod", 3, "u16", None),
("VAChaMax", 4, "u16", "VAChaMax_SF"),
("MinRsvPct", 5, "u16", "MinRsvPct_SF"),
("ChaState", 6, "u16", "ChaState_SF"),
("StorAval", 7, "u16", "StorAval_SF"),
("InBatV", 8, "u16", "InBatV_SF"),
("ChaSt", 9, "u16", None),
("OutWRte", 10, "i16", "InOutWRte_SF"),
("InWRte", 11, "i16", "InOutWRte_SF"),
("ChaGriSet", 15, "u16", None),
]
SCALE_FACTORS = {
"WChaMax_SF": 16, "WChaDisChaGra_SF": 17, "VAChaMax_SF": 18,
"MinRsvPct_SF": 19, "ChaState_SF": 20, "StorAval_SF": 21,
"InBatV_SF": 22, "InOutWRte_SF": 23,
}
CHA_ST = {1: "Off", 2: "Empty", 3: "Discharging", 4: "Charging",
5: "Full", 6: "Holding", 7: "Testing"}
NOT_IMPLEMENTED_U16 = 0xFFFF
NOT_IMPLEMENTED_I16 = 0x8000
def read_holding(sock, unit, addr, count):
req = struct.pack(">HHHBBHH", 1, 0, 6, unit, 3, addr, count)
sock.sendall(req)
hdr = b""
while len(hdr) < 8:
chunk = sock.recv(8 - len(hdr))
if not chunk:
raise IOError("connection closed while reading header")
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("connection closed while reading body")
body += chunk
if fc & 0x80:
raise IOError("modbus exception %d reading %d registers at %d"
% (body[0], count, addr))
return list(struct.unpack(">%dH" % (body[0] // 2), body[1:1 + body[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 find_model(sock, unit, base, wanted):
addr = base + 2
chain = []
while addr < base + 5000:
mid, mlen = read_holding(sock, unit, addr, 2)
if mid == 0xFFFF:
return None, chain
chain.append(mid)
if mid == wanted:
return addr + 2, chain
addr += 2 + mlen
return None, chain
def scaled(raw, kind, exponent):
if kind == "i16" and raw > 0x7FFF:
raw -= 0x10000
if exponent is None:
return float(raw)
if exponent > 0x7FFF:
exponent -= 0x10000
return raw * (10.0 ** exponent)
def main():
ap = argparse.ArgumentParser(description="Read-only dump of SunSpec model 124.")
ap.add_argument("host")
ap.add_argument("--port", type=int, default=502)
ap.add_argument("--unit", type=int, default=1)
args = ap.parse_args()
sock = socket.create_connection((args.host, args.port), timeout=10)
try:
base = find_base(sock, args.unit)
if base is None:
print("No SunSpec 'SunS' marker at 40000, 50000 or 0 on unit %d." % args.unit)
return 2
print("SunSpec base register: %d (unit %d)" % (base, args.unit))
data, chain = find_model(sock, args.unit, base, 124)
print("Models present: %s" % ", ".join(str(m) for m in chain))
if data is None:
print("\nNo model 124 on this inverter: it publishes no storage block at all.")
return 0
regs = read_holding(sock, args.unit, data, 24)
finally:
sock.close()
print("\nModel 124 raw block at %d:" % data)
print(" " + " ".join("%04X" % r for r in regs))
print("\n%-14s %10s %s" % ("point", "value", "raw"))
for name, off, kind, sf in POINTS:
raw = regs[off]
sentinel = NOT_IMPLEMENTED_I16 if kind == "i16" else NOT_IMPLEMENTED_U16
if raw == sentinel:
print("%-14s %10s 0x%04X" % (name, "n/impl", raw))
continue
value = scaled(raw, kind, regs[SCALE_FACTORS[sf]] if sf else None)
extra = " (%s)" % CHA_ST.get(raw, "?") if name == "ChaSt" else ""
print("%-14s %10.3f 0x%04X%s" % (name, value, raw, extra))
in_bat_v = scaled(regs[8], "u16", regs[SCALE_FACTORS["InBatV_SF"]])
stor_aval = scaled(regs[7], "u16", regs[SCALE_FACTORS["StorAval_SF"]])
print("\n--- what this decides -------------------------------------------")
print("InBatV = %.3f V" % in_bat_v)
print("StorAval = %.3f Ah" % stor_aval)
if in_bat_v > 0 or stor_aval > 0:
print("=> reads as BATTERY PRESENT")
else:
print("=> reads as NO BATTERY")
print("\nOn an inverter that really does have a battery, both being zero would")
print("mean the discriminator is wrong and would hide a real battery. That is")
print("exactly the case to report back.")
return 0
if __name__ == "__main__":
sys.exit(main())