2929
3030_LOGGER = logging .getLogger (__name__ )
3131
32- # Fallback ceiling for editable entities whose definition declares no "max" and whose
33- # unit gives us nothing to derive one from.
34- DEFAULT_MAX_VALUE = 3000
32+ # Raw value range per register width, used to derive the *protocol ceiling*: the largest
33+ # value a register can physically carry. This is the generic fallback for definitions that
34+ # declare no "max" — it is never wrong-low, and unlike a guess it needs no per-model
35+ # knowledge. Deriving a ceiling from the inverter's AC rating instead is what produced
36+ # issues #438 and #455.
37+ DATA_TYPE_RAW_RANGES = {
38+ DataType .U16 .value : (0 , 65535 ),
39+ DataType .S16 .value : (- 32768 , 32767 ),
40+ DataType .U32 .value : (0 , 4294967295 ),
41+ DataType .U32_LE .value : (0 , 4294967295 ),
42+ DataType .S32 .value : (- 2147483648 , 2147483647 ),
43+ DataType .S32_LE .value : (- 2147483648 , 2147483647 ),
44+ }
45+
46+ # Definitions opt into the inverter's rated output with "max_source": "inverter_rating".
47+ # Only for registers the AC rating genuinely governs — the remote-control dispatch and
48+ # battery-power setpoints. Never for grid-side registers (#438) and never inferred from
49+ # the unit, which is what made the old derivation retarget registers it never considered.
50+ MAX_SOURCE_INVERTER_RATING = "inverter_rating"
3551
3652# Battery-current setpoints, mapped to the BMS mirror register that publishes the real
3753# ceiling for each. The mirrors are what the battery itself reports, so they beat
3854# anything derivable from the inverter's rating: an LV bank at 51.2 V or two packs in
3955# parallel legitimately sits far above any fixed literal, and an install whose BMS
4056# reports less must not be widened past it.
57+ #
58+ # The TOU slot and time-charging currents belong here for the same reason the four
59+ # originals do (#455): a per-slot charge current cannot sensibly exceed what the battery
60+ # accepts, and their old literals (135 A / 300 A) sat below both a 15 kW LV bank's ~293 A
61+ # and the 580 A a parallel pair reports (#351).
4162BATTERY_CURRENT_MIRROR_REGISTERS = {
42- 43012 : 33206 , # Max Charge Current <- Battery Max Charge Current Mirror
43- 43117 : 33206 , # Battery Max Charge Current <- Battery Max Charge Current Mirror
44- 43013 : 33207 , # Max Discharge Current <- Battery Max Discharge Current Mirror
45- 43118 : 33207 , # Battery Max Discharge Current <- Battery Max Discharge Current Mirror
63+ # --- charge -> Battery Max Charge Current Mirror ---
64+ 43012 : 33206 , # Max Charge Current
65+ 43117 : 33206 , # Battery Max Charge Current
66+ 43141 : 33206 , # Time-Charging Charge Current
67+ 43709 : 33206 , # Grid TOU Charge battery current (Slot 1)
68+ 43716 : 33206 , # Grid TOU Charge battery current (Slot 2)
69+ 43723 : 33206 , # Grid TOU Charge battery current (Slot 3)
70+ 43730 : 33206 , # Grid TOU Charge battery current (Slot 4)
71+ 43737 : 33206 , # Grid TOU Charge battery current (Slot 5)
72+ 43744 : 33206 , # Grid TOU Charge battery current (Slot 6)
73+ # --- discharge -> Battery Max Discharge Current Mirror ---
74+ 43013 : 33207 , # Max Discharge Current
75+ 43118 : 33207 , # Battery Max Discharge Current
76+ 43142 : 33207 , # Time-Charging Discharge Current
77+ 43751 : 33207 , # Grid TOU Discharge battery current (Slot 1)
78+ 43758 : 33207 , # Grid TOU Discharge battery current (Slot 2)
79+ 43765 : 33207 , # Grid TOU Discharge battery current (Slot 3)
80+ 43772 : 33207 , # Grid TOU Discharge battery current (Slot 4)
81+ 43779 : 33207 , # Grid TOU Discharge battery current (Slot 5)
82+ 43786 : 33207 , # Grid TOU Discharge battery current (Slot 6)
4683}
4784
4885# The mirrors are U16 on a 0.1 A scale, same as the setpoints they bound.
4986BATTERY_CURRENT_MIRROR_MULTIPLIER = 0.1
5087
51- # Floor for a battery-current setpoint whose ceiling had to be derived: never advertise
52- # less than the value that shipped as the literal, so a small rating can't strand an
53- # install below what it used to be able to set.
54- BATTERY_CURRENT_FLOOR = 200
88+ # Battery voltage setpoints. The protocol gives one range for LV banks and a much wider
89+ # one for HV: "Range:40—48 ... HV Series- Default:120; Range 100-999" (ESINV-33000ID, the
90+ # 33208-33211 read-side equivalents). The shipped literals cover the LV case only, which
91+ # leaves an HV owner — a 480 V pack is ordinary — unable to set these at all.
92+ HV_BATTERY_VOLTAGE_REGISTERS = {
93+ 43016 , # Floating Charge Voltage
94+ 43017 , # Equalizing Charge Voltage
95+ 43020 , # Overdischarge Voltage
96+ 43021 , # Forcecharge Voltage
97+ 43710 , 43717 , 43724 , 43731 , 43738 , 43745 , # Grid TOU Charge cut off voltage, slots 1-6
98+ 43752 , 43759 , 43766 , 43773 , 43780 , 43787 , # Grid TOU Discharge cut off voltage, slots 1-6
99+ }
100+
101+ # The upper bound of that HV range. The lower bound (100 V) is deliberately not applied:
102+ # an inverter reporting a value below it would land outside its own entity's range.
103+ HV_BATTERY_VOLTAGE_MAX = 999
55104
56105
57106class SolisBaseSensor :
@@ -77,6 +126,7 @@ def __init__(
77126 category : Category = None ,
78127 min_value : int | None = None ,
79128 max_value : int | None = None ,
129+ max_source : str | None = None ,
80130 identification = None ,
81131 poll_speed = PollSpeed .NORMAL ,
82132 data_type : str | None = None ,
@@ -110,10 +160,12 @@ def __init__(
110160 self .unit_of_measurement = unit_of_measurement
111161 self .hidden = hidden
112162 self .state_class = state_class
113- self .adjust_max (max_value )
163+ # min before max: a derived ceiling on a signed register mirrors itself onto the
164+ # floor, so adjust_max needs the declared min already in place.
165+ self .min_value = min_value
166+ self .adjust_max (max_value , max_source )
114167 self .step = self .get_step (step )
115168 self .enabled = enabled
116- self .min_value = min_value
117169 self .poll_speed = poll_speed
118170 self .category = category
119171 self .identification = identification
@@ -131,6 +183,12 @@ def dynamic_adjustments(self):
131183 self .min_value = 0
132184 self .step = 0.1 if self .step is None else min (self .step , 0.1 )
133185
186+ # The declared voltage maxima describe a 48 V bank. On HV the protocol allows
187+ # 100-999 V, and a 480 V pack is ordinary — leave the LV literal in place and
188+ # an HV owner cannot set these at all.
189+ if _any_in (self .registrars , HV_BATTERY_VOLTAGE_REGISTERS ):
190+ self .max_value = HV_BATTERY_VOLTAGE_MAX
191+
134192 # RHI/RAI models: 1 <--> 1W (range: 0–30000)
135193 if inv_model in {"RHI-1P" , "RHI-3P" , "RAI-3K-48ES-5G" } and 43074 in self .registrars :
136194 self .multiplier = 1
@@ -141,38 +199,69 @@ def dynamic_adjustments(self):
141199 if _any_in (self .registrars , s6_registers ):
142200 self .multiplier = 0.01
143201
144- def adjust_max (self , max_default ):
145- """Derive a sensible max for entities whose definition didn't declare one.
202+ def adjust_max (self , max_default , max_source = None ):
203+ """Resolve the static ceiling by authority, never by guessing from the unit.
204+
205+ In order:
146206
147- A declared ``"max"`` always wins. Deriving it from the inverter rating is only
148- a fallback for definitions that omit it: several registers are *grid*
149- constraints rather than inverter-output constraints — the export limit at
150- 43074/43291 being the obvious case — so clamping them to the inverter's rated
151- wattage caps users below what their grid connection allows (issue #438).
207+ 1. A declared ``"max"`` — a real protocol or datasheet constraint, audited.
208+ 2. ``"max_source": "inverter_rating"`` — the inverter's rated output, for the
209+ registers it genuinely governs. Opted into per register: inferring it from the
210+ unit is what capped the export limit at 43074 to the AC rating (#438), and
211+ what put a 44 V battery bus behind every ampere setpoint (#455).
212+ 3. The protocol ceiling — what the register can physically carry.
152213
153- This only sets the *static* ceiling. Battery-current setpoints prefer the BMS
154- mirror when one has been polled — see the ``max_value`` property .
214+ Rank 0 sits above all of these but is resolved live rather than here: a
215+ battery-current setpoint prefers its BMS mirror once polled, see ``max_value``.
155216 """
156217 if max_default is not None :
157218 self .max_value = max_default
158219 return
159220
160- try :
161- new_max = DEFAULT_MAX_VALUE
162- if self .unit_of_measurement == UnitOfElectricCurrent .AMPERE :
163- new_max = round ((self .controller .inverter_config .wattage_chosen / 44 ) / 10 ) * 20
164- elif self .unit_of_measurement == UnitOfPower .WATT :
165- new_max = self .controller .inverter_config .wattage_chosen
166- elif self .unit_of_measurement == UnitOfPower .KILO_WATT :
167- new_max = self .controller .inverter_config .wattage_chosen / 1000
168- _LOGGER .debug (f"max value for { self .registrars } with UOM { self .unit_of_measurement } derived as { new_max } (no max declared)" )
169- self .max_value = new_max
170- except Exception as e :
171- _LOGGER .error ("❌ Dynamic UOM set failed, wanted = %s : %s" , self .controller .inverter_config .wattage_chosen , e )
172- self .max_value = DEFAULT_MAX_VALUE
173-
174- if self .battery_current_mirror_register is not None and self ._max_value < BATTERY_CURRENT_FLOOR :
175- self ._max_value = BATTERY_CURRENT_FLOOR
221+ derived = None
222+ if max_source == MAX_SOURCE_INVERTER_RATING :
223+ derived = self ._inverter_rating_max ()
224+
225+ if derived is None :
226+ derived = self .protocol_max
227+
228+ self .max_value = derived
229+ _LOGGER .debug (
230+ "max for %s resolved to %s (no declared max; source=%s)" ,
231+ self .registrars ,
232+ derived ,
233+ max_source or "protocol" ,
234+ )
235+
236+ # A signed dispatch register is symmetric about zero: raising only the ceiling
237+ # would leave 43128/43133/43134 able to import further than they can export.
238+ if self .min_value is not None and self .min_value < 0 :
239+ self .min_value = - derived
240+
241+ def _inverter_rating_max (self ) -> float | None :
242+ """The inverter's rated output in this entity's unit, or None if unusable."""
243+ rating = getattr (self .controller .inverter_config , "wattage_chosen" , None )
244+ if not isinstance (rating , (int , float )) or isinstance (rating , bool ) or rating <= 0 :
245+ return None
246+ if self .unit_of_measurement == UnitOfPower .KILO_WATT :
247+ return rating / 1000
248+ return rating
249+
250+ @property
251+ def protocol_raw_range (self ) -> tuple [int , int ]:
252+ """The raw (min, max) this register can carry, from its width."""
253+ data_type = self .data_type
254+ if data_type not in DATA_TYPE_RAW_RANGES :
255+ # Mirror what _convert_raw_value assumes when nothing is declared: a pair of
256+ # registers decodes as signed 32-bit, a lone one as unsigned 16-bit.
257+ data_type = DataType .S32 .value if len (self .registrars ) > 1 else DataType .U16 .value
258+ return DATA_TYPE_RAW_RANGES [data_type ]
259+
260+ @property
261+ def protocol_max (self ) -> float :
262+ """The largest value this register can physically hold, in its own unit."""
263+ # multiplier 0 is treated as 1 on the decode path; keep the two consistent.
264+ return self .protocol_raw_range [1 ] * (self .multiplier or 1 )
176265
177266 @property
178267 def battery_current_mirror_register (self ) -> int | None :
@@ -332,6 +421,7 @@ def __init__(self, hass, definition, controller, identification=None):
332421 hidden = entity .get ("hidden" , False ),
333422 editable = entity .get ("editable" , False ),
334423 max_value = entity .get ("max" , None ),
424+ max_source = entity .get ("max_source" , None ),
335425 min_value = entity .get ("min" , 0 ),
336426 step = entity .get ("step" , None ),
337427 identification = identification ,
0 commit comments