Skip to content

Commit 703c0e3

Browse files
CIclaude
andcommitted
fix(solis): stop trusting SolisCloud's slot metadata, and sweep the probe to a half amp
Issue #5068. The slot current cap was lowered to the smallest sysCommand max that the API-key route returns with each slot register. That metadata is a UI definition for a list of model codes (its productModel), and SolisCloud returns definitions for other models. An S5-EH1P5K-L (model 3104, 5kW, 51.2V pack, 100A in CID 7224/7226) was given: - 60A for its slot currents, from the definition for models 3101/3102 - 62.5A for slot 1 charge, from the definition for models 3111/3140/3145 - 70A for CID 7224, which reads 100A Its slots 2-6 held 100A the whole time. Predbat still capped slot 1 at 60A, about 3.1kW from the battery, and the customer saw exports of around 3.6kW on a 5kW inverter. The metadata is now ignored. The battery-side cap is the register itself. The probe starts from it and measures what the slots really accept, with the rated estimate as the fallback until it has run. The probe sweep now ends with one half-amp step. After 1A up is refused, it tries 0.5A up, so a ceiling such as 62.5A is found exactly rather than rounded down to 62A. Candidates are written as the inverter shows them ("60", "62.5"). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 3c415f5 commit 703c0e3

2 files changed

Lines changed: 70 additions & 47 deletions

File tree

‎apps/predbat/solis.py‎

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,8 @@
247247
# Slot current ceiling probe (issue #5187): how far each refused probe steps down, and the lowest
248248
# current worth trying - refusing everything down to it is not a ceiling, so the probe gives up
249249
SOLIS_SLOT_PROBE_STEP_DOWN_AMPS = 5
250+
# The finest step tried once 1A up has been refused - some ceilings are on a half amp (62.5A is seen)
251+
SOLIS_SLOT_PROBE_FINE_STEP_AMPS = 0.5
250252
SOLIS_SLOT_PROBE_MIN_AMPS = 10
251253
# A learned ceiling is semi-static: checked again once it is this old, keeping the old one until a new probe succeeds
252254
SOLIS_SLOT_PROBE_REFRESH_SECONDS = 24 * 60 * 60
@@ -1194,49 +1196,40 @@ def slot_current_limits(self, inverter_sn, include_probed=True):
11941196
11951197
The cap starts from the battery limit register (CID 7224 charge, 7226 discharge - read as is,
11961198
not multiplied by the pack count, and SOLIS_SLOT_CURRENT_DEFAULT_AMPS when it has not been
1197-
read) and is lowered to the smallest sysCommand max SolisCloud advertises across the six slot
1198-
CIDs - slot 1 advertising more than the others is not accepted. That is the battery-side cap.
1199+
read). That is the battery-side cap.
1200+
1201+
The sysCommand min/max SolisCloud returns with each slot register is deliberately not used
1202+
(issue #5068). It is a UI definition for a list of model codes (its productModel), and the
1203+
cloud hands back one for other models: an S5-EH1P5K-L (model 3104) was given the 60A limit
1204+
of models 3101/3102 while its slots 2-6 held 100A, which held its exports to about 3.6kW.
1205+
The probe measures what the slots really accept instead.
11991206
12001207
Then, once ensure_slot_current_probed() has measured what the slots really accept, the cap is
12011208
held to that ceiling. Until then it is held to the rated current instead - an estimate that
12021209
can be too high (a 3.6kW inverter rated for 75A keeps only 60A, issue #5187) or too low (an
12031210
8kW hybrid rated for 156A takes 180A, charging from PV), so a measured ceiling replaces it.
12041211
12051212
Returns a dict: for each of "charge" and "discharge", "<direction>_register" (the raw register
1206-
value, None when not read), "<direction>_base" (the starting point), "<direction>_metadata"
1207-
(the smallest advertised max, None when none was returned), "<direction>_battery" (the
1213+
value, None when not read), "<direction>_base" (the starting point), "<direction>_battery" (the
12081214
battery-side cap), "<direction>_probed" (the measured ceiling, None when not probed) and
12091215
"<direction>" (the cap); plus "rated" (None when the size is unknown) and "rated_capped"
12101216
(whether the rated current lowered a cap). include_probed=False leaves the measured ceiling
12111217
out, which is what the probe itself starts from.
12121218
"""
12131219
values = self.cached_values.get(inverter_sn, {})
1214-
infos = self.cached_infos.get(inverter_sn, {})
12151220
limits = {}
1216-
for direction, register_cid, slot_cids in (("charge", SOLIS_CID_BATTERY_MAX_CHARGE_CURRENT, SOLIS_CID_CHARGE_CURRENT), ("discharge", SOLIS_CID_BATTERY_MAX_DISCHARGE_CURRENT, SOLIS_CID_DISCHARGE_CURRENT)):
1221+
for direction, register_cid in (("charge", SOLIS_CID_BATTERY_MAX_CHARGE_CURRENT), ("discharge", SOLIS_CID_BATTERY_MAX_DISCHARGE_CURRENT)):
12171222
# This feeds publish_entities() as well as the write path, so an unreadable register is
12181223
# treated as not read rather than raising and stopping everything from being published
12191224
register = values.get(register_cid)
12201225
try:
12211226
base = float(register) if register is not None else SOLIS_SLOT_CURRENT_DEFAULT_AMPS
12221227
except (TypeError, ValueError):
12231228
register, base = None, SOLIS_SLOT_CURRENT_DEFAULT_AMPS
1224-
cap = base
1225-
advertised = []
1226-
for cid in slot_cids:
1227-
command = infos.get(cid, {}).get("sysCommand")
1228-
try:
1229-
slot_max = float(command.get("max")) if isinstance(command, dict) and command.get("max") is not None else None
1230-
except (TypeError, ValueError):
1231-
slot_max = None
1232-
if slot_max is not None:
1233-
advertised.append(slot_max)
1234-
cap = min(slot_max, cap)
12351229
limits[direction + "_register"] = register
12361230
limits[direction + "_base"] = base
1237-
limits[direction + "_metadata"] = min(advertised) if advertised else None
1238-
limits[direction + "_battery"] = cap
1239-
limits[direction] = cap
1231+
limits[direction + "_battery"] = base
1232+
limits[direction] = base
12401233

12411234
rated = self.get_rated_current(inverter_sn)
12421235
limits["rated"] = rated
@@ -1262,19 +1255,15 @@ def describe_slot_current_limits(self, inverter_sn, limits):
12621255
for direction in ("charge", "discharge"):
12631256
cap = limits[direction]
12641257
register = limits[direction + "_register"]
1265-
metadata = limits[direction + "_metadata"]
12661258
probed = limits.get(direction + "_probed")
12671259
# The lowest input wins; name it, most specific first when two agree
12681260
if probed is not None and cap == probed:
12691261
source = "probed ceiling"
12701262
elif limits["rated_capped"] and cap == limits["rated"]:
12711263
source = "rated current"
1272-
elif metadata is not None and cap == metadata:
1273-
source = "slot metadata"
12741264
else:
12751265
source = "register" if register is not None else "default"
12761266
inputs = [f"register {register}A" if register is not None else f"register not read, default {limits[direction + '_base']}A"]
1277-
inputs.append(f"slot metadata max {metadata}A" if metadata is not None else "no slot metadata max")
12781267
if probed is not None:
12791268
inputs.append(f"probed max {probed}A")
12801269
parts.append(f"{direction} {cap}A from the {source} ({', '.join(inputs)})")
@@ -1387,7 +1376,9 @@ async def probe_slot_write(self, inverter_sn, cid, value, field_description):
13871376
old_value, _ = await self.read_cid(inverter_sn, cid)
13881377
if cid_value_matches(old_value, value):
13891378
return True
1390-
if not await self.write_cid(inverter_sn, cid, str(value), old_value=old_value, field_description=field_description):
1379+
# A candidate current is written as the inverter shows it: "60", or "62.5" on a half amp
1380+
written = f"{value:g}" if isinstance(value, float) else str(value)
1381+
if not await self.write_cid(inverter_sn, cid, written, old_value=old_value, field_description=field_description):
13911382
return None
13921383
read_back, _ = await self.read_cid(inverter_sn, cid)
13931384
if not cid_value_matches(read_back, value):
@@ -1418,8 +1409,9 @@ async def probe_slot_current_ceiling(self, inverter_sn, direction, slot, start,
14181409
Writes start first - the battery-side cap on a first probe, the known ceiling on a re-check.
14191410
If it is refused, steps down SOLIS_SLOT_PROBE_STEP_DOWN_AMPS at a time until one is kept;
14201411
then steps up 1A at a time until one is refused or upper (the battery-side cap, never
1421-
exceeded) is reached, so the result is the exact ceiling in whole amps. A first probe whose
1422-
start is kept is done in one write, and a re-check that finds the ceiling unchanged in two.
1412+
exceeded) is reached, and finally tries SOLIS_SLOT_PROBE_FINE_STEP_AMPS above that, so a
1413+
ceiling on a half amp (62.5A) is found too. A first probe whose start is kept is done in one
1414+
write, and a re-check that finds the ceiling unchanged in three.
14231415
Returns None - nothing learned - if any write gets no verdict, or if every current down to
14241416
SOLIS_SLOT_PROBE_MIN_AMPS is refused, which is not a ceiling any real inverter has.
14251417
"""
@@ -1432,11 +1424,11 @@ async def probe_slot_current_ceiling(self, inverter_sn, direction, slot, start,
14321424

14331425
async def attempt(amps):
14341426
"""Write one candidate current."""
1435-
return await self.probe_slot_write(inverter_sn, cid, amps, f"probe {direction} slot {slot} current {amps}A")
1427+
return await self.probe_slot_write(inverter_sn, cid, amps, f"probe {direction} slot {slot} current {amps:g}A")
14361428

14371429
try:
1438-
upper = int(upper)
1439-
amps = min(int(start), upper)
1430+
upper = float(upper)
1431+
amps = min(float(start), upper)
14401432
verdict = await attempt(amps)
14411433
if verdict is None:
14421434
return None
@@ -1447,7 +1439,7 @@ async def attempt(amps):
14471439
while not verdict:
14481440
amps -= SOLIS_SLOT_PROBE_STEP_DOWN_AMPS
14491441
if amps < SOLIS_SLOT_PROBE_MIN_AMPS:
1450-
self.log(f"Warn: Solis API: {inverter_sn} refused every {direction} slot current down to {amps + SOLIS_SLOT_PROBE_STEP_DOWN_AMPS}A - not a real ceiling, nothing learned")
1442+
self.log(f"Warn: Solis API: {inverter_sn} refused every {direction} slot current down to {amps + SOLIS_SLOT_PROBE_STEP_DOWN_AMPS:g}A - not a real ceiling, nothing learned")
14511443
return None
14521444
verdict = await attempt(amps)
14531445
if verdict is None:
@@ -1460,9 +1452,18 @@ async def attempt(amps):
14601452
if verdict is None:
14611453
return None
14621454
if not verdict:
1455+
refused = amps + 1
14631456
break
14641457
amps += 1
1465-
return float(amps)
1458+
# Some ceilings sit on a half amp, so one step finer before settling
1459+
fine = amps + SOLIS_SLOT_PROBE_FINE_STEP_AMPS
1460+
if fine < refused and fine <= upper:
1461+
verdict = await attempt(fine)
1462+
if verdict is None:
1463+
return None
1464+
if verdict:
1465+
amps = fine
1466+
return amps
14661467
finally:
14671468
if await self.probe_slot_write(inverter_sn, cid, original, f"restore {direction} slot {slot} current") is not True:
14681469
self.log(f"Warn: Solis API: Could not restore {direction} slot {slot} current on {inverter_sn} to {original} after probing it")

0 commit comments

Comments
 (0)