Skip to content

Commit b92fea4

Browse files
Merge pull request #5314 from springfall2008/fix/solis-ignore-slot-metadata-5068
fix(solis): stop trusting SolisCloud's slot metadata, and sweep the probe to a half amp
2 parents 473184b + 703c0e3 commit b92fea4

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)