@@ -278,7 +278,7 @@ def __init__(self, message, status_code=None, response_code=None):
278278class SolisAPI (ComponentBase , OAuthMixin ):
279279 """Solis Cloud API integration component"""
280280
281- def initialize (self , api_key = None , api_secret = None , inverter_sn = None , automatic = False , base_url = SOLIS_BASE_URL , control_enable = True , auth_method = None , access_token = None , token_expires_at = None , token_hash = None ):
281+ def initialize (self , api_key = None , api_secret = None , inverter_sn = None , automatic = False , base_url = SOLIS_BASE_URL , control_enable = True , auth_method = None , access_token = None , token_expires_at = None , token_hash = None , nominal_voltage = None ):
282282 """Initialise the Solis API component"""
283283 self .api_key = api_key
284284 self .api_secret = api_secret
@@ -295,7 +295,23 @@ def initialize(self, api_key=None, api_secret=None, inverter_sn=None, automatic=
295295 self .base_url = SOLIS_OAUTH_BASE_URL if self .auth_method == "oauth" else base_url
296296 self .automatic = automatic
297297 self .session = None
298- self .nominal_voltage = 48.0 # Default nominal battery voltage
298+ # Fallback used only when an inverter has never reported a live batteryVoltage - matches
299+ # the previous hard-coded assumption (issue #4493). get_nominal_voltage() below is the
300+ # real source of truth once live data is available.
301+ self .nominal_voltage = 48.0
302+ self .nominal_voltage_last_known = {} # {inverter_sn: last measured battery voltage}
303+ # Nominal *pack* voltage (e.g. cell count x nominal cell voltage), used for the capacity
304+ # calculation only - deliberately distinct from the live measured voltage used for
305+ # power/current conversions, since the two are not the same value on an HV battery
306+ # be supplied via apps.yaml (solis_nominal_voltage) if capacity is to be accurate.
307+ try :
308+ configured_pack_v = float (nominal_voltage ) if nominal_voltage is not None else None
309+ except (TypeError , ValueError ):
310+ configured_pack_v = None
311+ if configured_pack_v is not None and configured_pack_v <= 0 :
312+ self .log (f"Warn: Solis API: solis_nominal_voltage must be > 0, got { configured_pack_v } ; ignoring" )
313+ configured_pack_v = None
314+ self .nominal_pack_voltage = configured_pack_v
299315 self .control_enable = control_enable
300316
301317 # Handle inverter SN configuration - can be None, a single string, or a list of strings
@@ -1278,7 +1294,12 @@ async def automatic_config(self):
12781294 self .set_arg_auto ("export_today" , [f"sensor.{ self .prefix } _solis_{ device } _today_export_energy" for device in devices ])
12791295
12801296 # Battery capacity and limits from cached details
1281- # XXX: This is currently broken, user must set manually in apps.yaml
1297+ # The arithmetic bug that made this sensor wrong by ~11x on HV batteries is fixed
1298+ # (issue #4493), but this stays commented out deliberately: without solis_nominal_voltage
1299+ # configured, battery_capacity falls back to the *live* measured voltage, which drifts
1300+ # with charge state - auto-binding soc_max to it would make soc_max itself wobble slightly
1301+ # cycle to cycle, which it never did before (the old 48V constant was wrong but static).
1302+ # User must still set soc_max manually in apps.yaml.
12821303 # self.set_arg("soc_max", [f"sensor.{self.prefix}_solis_{device}_battery_capacity" for device in devices])
12831304
12841305 # Reserve and limits
@@ -1364,6 +1385,39 @@ def _calculate_max_currents(self, inverter_sn):
13641385
13651386 self .log (f"Solis API: Calculated max currents for { inverter_sn } : charge={ max_charge } A, discharge={ max_discharge } A" )
13661387
1388+ def get_nominal_voltage (self , inverter_sn ):
1389+ """
1390+ Return the voltage to use for amp<->watt conversions for this inverter: the live measured
1391+ battery voltage if we have one, otherwise the last value we did measure, otherwise the
1392+ 48V fallback. A hard-coded 48V here was issue #4493 - on a high-voltage battery it made
1393+ every derived power value wrong by roughly the ratio of the real pack voltage to 48V.
1394+ """
1395+ detail = self .inverter_details .get (inverter_sn , {})
1396+ try :
1397+ voltage = float (detail .get ("batteryVoltage" ))
1398+ except (ValueError , TypeError ):
1399+ voltage = None
1400+ if voltage and voltage > 0 :
1401+ self .nominal_voltage_last_known [inverter_sn ] = voltage
1402+ return voltage
1403+ return self .nominal_voltage_last_known .get (inverter_sn , self .nominal_voltage )
1404+
1405+ def get_capacity_voltage (self , inverter_sn ):
1406+ """
1407+ Return the configured nominal pack voltage to use for the battery capacity calculation,
1408+ or None if solis_nominal_voltage isn't set. This is deliberately NOT the same value as
1409+ get_nominal_voltage() (the live measured voltage): capacity should use the nominal pack
1410+ voltage (a fixed physical property, e.g. cell count x nominal cell voltage), and the live
1411+ value drifts with charge state.
1412+
1413+ Note on the None case: DEYE's equivalent (nominal_pack_voltage()/derive_battery_capacity()
1414+ in deye.py) refuses to publish a capacity at all rather than guess, on the basis that a
1415+ wrong soc_max source is worse than none. Solis instead falls back to the live voltage and
1416+ flags the result unreliable (see the caller in publish_entities) so existing installs that
1417+ already had this sensor (using the old, worse, hard-coded 48V) don't lose it outright -
1418+ a different tradeoff than DEYE's for the same underlying problem, worth reviewer scrutiny.
1419+ """
1420+ return self .nominal_pack_voltage
13671421
13681422 async def fetch_entity_data (self , sn ):
13691423 """
@@ -1410,7 +1464,7 @@ async def fetch_entity_data(self, sn):
14101464 if power_state is not None :
14111465 try :
14121466 max_current_amps = self .max_charge_current .get (sn , 100 ) if direction == 'charge' else self .max_discharge_current .get (sn , 100 )
1413- value = max (0 , min (int (float (power_state ) / self .nominal_voltage ), max_current_amps ))
1467+ value = max (0 , min (int (float (power_state ) / self .get_nominal_voltage ( sn ) ), max_current_amps ))
14141468 self .charge_discharge_time_windows [sn ][slot ][f"{ direction } _current" ] = value
14151469 except (ValueError , TypeError ):
14161470 pass
@@ -1882,13 +1936,13 @@ async def publish_entities(self):
18821936 current_value_watts = None
18831937 if current_value_amps is not None :
18841938 try :
1885- current_value_watts = int (float (current_value_amps ) * self .nominal_voltage )
1939+ current_value_watts = int (float (current_value_amps ) * self .get_nominal_voltage ( inverter_sn ) )
18861940 except (ValueError , TypeError ):
18871941 self .log ("Warn: Failed to convert charge current to watts for {} slot {}: {}" .format (inverter_sn , slot_num , current_value_amps )) # Debug log
18881942
18891943 # Use pre-calculated max current (convert to watts)
18901944 max_current_amps = self .max_charge_current .get (inverter_sn , 100 )
1891- max_power_watts = int (max_current_amps * self .nominal_voltage )
1945+ max_power_watts = int (max_current_amps * self .get_nominal_voltage ( inverter_sn ) )
18921946
18931947 self .dashboard_item (
18941948 entity_id ,
@@ -1898,7 +1952,7 @@ async def publish_entities(self):
18981952 "unit_of_measurement" : "W" ,
18991953 "min" : 0 ,
19001954 "max" : max_power_watts ,
1901- "step" : self .nominal_voltage ,
1955+ "step" : self .get_nominal_voltage ( inverter_sn ) ,
19021956 "device_class" : "power" ,
19031957 "icon" : "mdi:flash" ,
19041958 },
@@ -1999,13 +2053,13 @@ async def publish_entities(self):
19992053 current_value_watts = None
20002054 if current_value_amps is not None :
20012055 try :
2002- current_value_watts = int (float (current_value_amps ) * self .nominal_voltage )
2056+ current_value_watts = int (float (current_value_amps ) * self .get_nominal_voltage ( inverter_sn ) )
20032057 except (ValueError , TypeError ):
20042058 self .log ("Warn: Failed to convert discharge current to watts for {} slot {}: {}" .format (inverter_sn , slot_num , current_value_amps )) # Debug log
20052059
20062060 # Use pre-calculated max current (convert to watts)
20072061 max_current_amps = self .max_discharge_current .get (inverter_sn , 100 )
2008- max_power_watts = int (max_current_amps * self .nominal_voltage )
2062+ max_power_watts = int (max_current_amps * self .get_nominal_voltage ( inverter_sn ) )
20092063
20102064 self .dashboard_item (
20112065 entity_id ,
@@ -2015,7 +2069,7 @@ async def publish_entities(self):
20152069 "unit_of_measurement" : "W" ,
20162070 "min" : 0 ,
20172071 "max" : max_power_watts ,
2018- "step" : self .nominal_voltage ,
2072+ "step" : self .get_nominal_voltage ( inverter_sn ) ,
20192073 "device_class" : "power" ,
20202074 "icon" : "mdi:flash" ,
20212075 },
@@ -2129,7 +2183,7 @@ async def publish_entities(self):
21292183 max_charge_power_watts = None
21302184 if max_charge_current_amps is not None :
21312185 try :
2132- max_charge_power_watts = int (float (max_charge_current_amps ) * self .nominal_voltage )
2186+ max_charge_power_watts = int (float (max_charge_current_amps ) * self .get_nominal_voltage ( inverter_sn ) )
21332187 except (ValueError , TypeError ):
21342188 self .log ("Warn: Failed to convert max charge current to watts for {}: {}" .format (inverter_sn , max_charge_current_amps )) # Debug log
21352189
@@ -2140,8 +2194,8 @@ async def publish_entities(self):
21402194 "friendly_name" : f"Solis { inverter_name } Battery Max Charge Power" ,
21412195 "unit_of_measurement" : "W" ,
21422196 "min" : 0 ,
2143- "max" : int (1000 * self .nominal_voltage ),
2144- "step" : self .nominal_voltage ,
2197+ "max" : int (1000 * self .get_nominal_voltage ( inverter_sn ) ),
2198+ "step" : self .get_nominal_voltage ( inverter_sn ) ,
21452199 "device_class" : "power" ,
21462200 "icon" : "mdi:battery-arrow-down-outline" ,
21472201 },
@@ -2155,7 +2209,7 @@ async def publish_entities(self):
21552209 max_discharge_power_watts = None
21562210 if max_discharge_current_amps is not None :
21572211 try :
2158- max_discharge_power_watts = int (float (max_discharge_current_amps ) * self .nominal_voltage )
2212+ max_discharge_power_watts = int (float (max_discharge_current_amps ) * self .get_nominal_voltage ( inverter_sn ) )
21592213 except (ValueError , TypeError ):
21602214 self .log ("Warn: Failed to convert max discharge current to watts for {}: {}" .format (inverter_sn , max_discharge_current_amps )) # Debug log
21612215
@@ -2166,8 +2220,8 @@ async def publish_entities(self):
21662220 "friendly_name" : f"Solis { inverter_name } Battery Max Discharge Power" ,
21672221 "unit_of_measurement" : "W" ,
21682222 "min" : 0 ,
2169- "max" : int (1000 * self .nominal_voltage ),
2170- "step" : self .nominal_voltage ,
2223+ "max" : int (1000 * self .get_nominal_voltage ( inverter_sn ) ),
2224+ "step" : self .get_nominal_voltage ( inverter_sn ) ,
21712225 "device_class" : "power" ,
21722226 "icon" : "mdi:battery-arrow-up-outline" ,
21732227 },
@@ -2239,7 +2293,31 @@ async def publish_entities(self):
22392293 if battery_capacity_ah is not None :
22402294 try :
22412295 battery_capacity_ah = float (battery_capacity_ah )
2242- battery_capacity_kWh = battery_capacity_ah * self .nominal_voltage / 1000.0
2296+ # Must account for parallel batteries (issue #4493) - SOLIS_CID_BATTERY_CAPACITY
2297+ # is the per-battery Ah rating, same as the max charge/discharge current CIDs
2298+ # handled in _calculate_max_currents() above.
2299+ battery_count = self .parallel_battery_count .get (inverter_sn , 1 )
2300+ # Prefer the configured nominal pack voltage. Without it, fall back to the live
2301+ # measured voltage rather than dropping the sensor entirely - existing installs
2302+ # already have this sensor published (albeit with the old, worse, hard-coded 48V
2303+ # figure) and losing it outright without any migration path was judged too
2304+ # disruptive. It's flagged as unreliable via the reliable/voltage_source
2305+ # attributes and a warning instead, matching neither "guess silently" nor "go
2306+ # unavailable silently" - reviewers may want a different tradeoff here.
2307+ capacity_voltage = self .get_capacity_voltage (inverter_sn )
2308+ reliable = capacity_voltage is not None
2309+ if not reliable :
2310+ capacity_voltage = self .get_nominal_voltage (inverter_sn )
2311+ # publish_entities() runs roughly once a minute - only warn once per
2312+ # inverter rather than forever, so this doesn't drown out the log for the
2313+ # many existing installs that haven't set solis_nominal_voltage yet.
2314+ if inverter_sn not in self .capacity_voltage_warned :
2315+ self .capacity_voltage_warned .add (inverter_sn )
2316+ self .log (
2317+ f"Warn: Solis API { inverter_name } battery capacity is estimated from the live measured voltage ({ capacity_voltage } V), which varies with charge state - "
2318+ "set solis_nominal_voltage in apps.yaml for an accurate, stable value (this warning will not repeat)"
2319+ )
2320+ battery_capacity_kWh = battery_capacity_ah * battery_count * capacity_voltage / 1000.0
22432321 entity_id = f"sensor.{ prefix } _solis_{ inverter_sn_lower } _battery_capacity"
22442322 self .dashboard_item (
22452323 entity_id ,
@@ -2249,7 +2327,9 @@ async def publish_entities(self):
22492327 "unit_of_measurement" : "kWh" ,
22502328 "device_class" : "energy" ,
22512329 "state_class" : "measurement" ,
2252- "icon" : "mdi:battery" ,
2330+ "icon" : "mdi:battery" if reliable else "mdi:battery-alert" ,
2331+ "reliable" : reliable ,
2332+ "voltage_source" : "configured (solis_nominal_voltage)" if reliable else "estimated from the live measured voltage - set solis_nominal_voltage in apps.yaml for an accurate, stable value" ,
22532333 },
22542334 app = "solis"
22552335 )
@@ -2501,7 +2581,7 @@ async def number_event(self, entity_id, value):
25012581 return
25022582
25032583 # Convert watts to amps for inverter
2504- amps = int (value / self .nominal_voltage )
2584+ amps = int (value / self .get_nominal_voltage ( inverter_sn ) )
25052585
25062586 # Update charge_discharge_time_windows cache
25072587 if inverter_sn not in self .charge_discharge_time_windows :
@@ -2558,7 +2638,7 @@ async def number_event(self, entity_id, value):
25582638 return
25592639
25602640 # Convert watts to amps for inverter
2561- amps = round (float (value ) / self .nominal_voltage , 1 )
2641+ amps = round (float (value ) / self .get_nominal_voltage ( inverter_sn ) , 1 )
25622642
25632643 # Update charge_discharge_time_windows cache
25642644 if inverter_sn not in self .charge_discharge_time_windows :
@@ -2601,7 +2681,7 @@ async def number_event(self, entity_id, value):
26012681 cid = cid_map [field ]
26022682
26032683 # Convert watts to amps for inverter
2604- amps = round (float (value ) / self .nominal_voltage , 1 )
2684+ amps = round (float (value ) / self .get_nominal_voltage ( inverter_sn ) , 1 )
26052685
26062686 # Write to inverter
26072687 await self .read_and_write_cid (inverter_sn , cid , amps , field_description = f"{ field } to { value_str } W ({ amps } A)" )
0 commit comments