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>
168 lines
5.7 KiB
Python
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())
|