Skip to content

Commit d0f2bba

Browse files
committed
Resolve entity bounds by authority
1 parent d5c9f6a commit d0f2bba

8 files changed

Lines changed: 713 additions & 93 deletions

File tree

custom_components/solis_modbus/sensor_data/hybrid_sensors.py

Lines changed: 142 additions & 22 deletions
Large diffs are not rendered by default.

custom_components/solis_modbus/sensor_data/string_sensors.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@
339339
"multiplier": 0.01,
340340
"editable": True,
341341
"min": 0,
342+
# Grid code: 110% allows the over-100% setpoint some codes require.
342343
"max": 110,
343344
},
344345
{
@@ -398,6 +399,7 @@
398399
"multiplier": 1,
399400
"editable": True,
400401
"min": 10,
402+
# Guess: 10-180 min shading-scan interval. Unsourced.
401403
"max": 180,
402404
},
403405
],

custom_components/solis_modbus/sensors/solis_base_sensor.py

Lines changed: 128 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -29,29 +29,78 @@
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).
4162
BATTERY_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.
4986
BATTERY_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

57106
class 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,

custom_components/solis_modbus/sensors/solis_number_sensor.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def __init__(self, hass, sensor: SolisBaseSensor):
5252
self._attr_native_value = sensor.default
5353
self._attr_mode = NumberMode.AUTO
5454
self._attr_native_min_value = sensor.min_value
55+
# No _attr_native_max_value: the max is a property reading the base sensor live.
5556

5657
self._attr_native_step = sensor.step
5758
self._attr_step = sensor.step

test_async_blocking.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Test to check if pymodbus serial reads block the event loop or properly yield.
4+
5+
Run this alongside your Home Assistant to see if other tasks can execute
6+
during Modbus reads.
7+
"""
8+
9+
import asyncio
10+
import time
11+
from pymodbus.client import AsyncModbusSerialClient
12+
13+
14+
async def heartbeat_task(name: str, interval: float = 0.5):
15+
"""Task that prints a heartbeat - should run even during Modbus reads."""
16+
count = 0
17+
while True:
18+
count += 1
19+
print(f"[{time.strftime('%H:%M:%S')}] {name} heartbeat #{count}")
20+
await asyncio.sleep(interval)
21+
22+
23+
async def modbus_read_task(port: str = "/dev/ttyUSB0"):
24+
"""Perform Modbus reads and measure timing."""
25+
print(f"\n[{time.strftime('%H:%M:%S')}] Creating serial client...")
26+
27+
client = AsyncModbusSerialClient(
28+
port=port,
29+
baudrate=9600,
30+
bytesize=8,
31+
parity='N',
32+
stopbits=1,
33+
timeout=5
34+
)
35+
36+
try:
37+
print(f"[{time.strftime('%H:%M:%S')}] Connecting...")
38+
await client.connect()
39+
40+
if not client.connected:
41+
print(f"[{time.strftime('%H:%M:%S')}] ❌ Failed to connect!")
42+
return
43+
44+
print(f"[{time.strftime('%H:%M:%S')}] ✅ Connected!")
45+
46+
# Perform several reads
47+
for i in range(3):
48+
print(f"\n[{time.strftime('%H:%M:%S')}] 📖 Starting read #{i+1} (48 registers)...")
49+
start = time.perf_counter()
50+
51+
client.slave = 1
52+
result = await client.read_input_registers(address=33049, count=48)
53+
54+
elapsed = (time.perf_counter() - start) * 1000
55+
56+
if result.isError():
57+
print(f"[{time.strftime('%H:%M:%S')}] ❌ Read failed: {result}")
58+
else:
59+
print(f"[{time.strftime('%H:%M:%S')}] ✅ Read successful: {len(result.registers)} registers in {elapsed:.1f}ms")
60+
61+
await asyncio.sleep(1) # Wait between reads
62+
63+
finally:
64+
client.close()
65+
print(f"\n[{time.strftime('%H:%M:%S')}] Connection closed")
66+
67+
68+
async def main():
69+
"""Run heartbeat and Modbus reads concurrently."""
70+
print("=" * 70)
71+
print("Testing if pymodbus yields event loop during serial I/O")
72+
print("=" * 70)
73+
print("\n⚠️ WATCH THE HEARTBEAT TIMESTAMPS:")
74+
print(" - If heartbeats pause during reads: BLOCKING (bad)")
75+
print(" - If heartbeats continue smoothly: NON-BLOCKING (good)")
76+
print("\n" + "=" * 70 + "\n")
77+
78+
# Start heartbeat task
79+
heartbeat = asyncio.create_task(heartbeat_task("🫀", interval=0.5))
80+
81+
# Wait a bit for heartbeat to start
82+
await asyncio.sleep(2)
83+
84+
# Start Modbus reads
85+
try:
86+
await modbus_read_task()
87+
except Exception as e:
88+
print(f"\n❌ Error: {e}")
89+
90+
# Let heartbeat run a bit more
91+
await asyncio.sleep(2)
92+
93+
heartbeat.cancel()
94+
try:
95+
await heartbeat
96+
except asyncio.CancelledError:
97+
pass
98+
99+
print("\n" + "=" * 70)
100+
print("Test complete!")
101+
print("=" * 70)
102+
103+
104+
if __name__ == "__main__":
105+
asyncio.run(main())

0 commit comments

Comments
 (0)