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
249249SOLIS_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
250252SOLIS_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
252254SOLIS_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