Skip to content

Commit ebc29c2

Browse files
authored
Merge pull request #31 from fboundy/patch
Fix Work Mode select/switch bit conflicts; rename discharge slots
2 parents 088e619 + 34009be commit ebc29c2

8 files changed

Lines changed: 151 additions & 50 deletions

File tree

‎custom_components/solisconnect/sensor_data/hybrid_sensors.py‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3458,18 +3458,19 @@ def _apply_control_metadata() -> None:
34583458
"Backup SOC": "Backup Mode SOC",
34593459
}
34603460

3461-
timed_charge_registers: dict[int, tuple[str, int]] = {}
3461+
# TOU V2 slot fields: 6 charge slots (43708+) and 6 discharge slots (43750+),
3462+
# named "Timed Charge X 1-6" and "Timed Discharge X 1-6" respectively.
3463+
timed_slot_registers: dict[int, tuple[str, str, int]] = {}
34623464
for slot in range(1, 7):
34633465
charge_base = 43708 + ((slot - 1) * 7)
3464-
timed_charge_registers[charge_base] = ("SOC", slot)
3465-
timed_charge_registers[charge_base + 1] = ("Current", slot)
3466-
timed_charge_registers[charge_base + 2] = ("Voltage", slot)
3466+
timed_slot_registers[charge_base] = ("Charge", "SOC", slot)
3467+
timed_slot_registers[charge_base + 1] = ("Charge", "Current", slot)
3468+
timed_slot_registers[charge_base + 2] = ("Charge", "Voltage", slot)
34673469

3468-
discharge_slot = slot + 6
34693470
discharge_base = 43750 + ((slot - 1) * 7)
3470-
timed_charge_registers[discharge_base] = ("SOC", discharge_slot)
3471-
timed_charge_registers[discharge_base + 1] = ("Current", discharge_slot)
3472-
timed_charge_registers[discharge_base + 2] = ("Voltage", discharge_slot)
3471+
timed_slot_registers[discharge_base] = ("Discharge", "SOC", slot)
3472+
timed_slot_registers[discharge_base + 1] = ("Discharge", "Current", slot)
3473+
timed_slot_registers[discharge_base + 2] = ("Discharge", "Voltage", slot)
34733474

34743475
for group in hybrid_sensors:
34753476
for entity in group.get("entities", []):
@@ -3487,10 +3488,10 @@ def _apply_control_metadata() -> None:
34873488
continue
34883489

34893490
register = int(registers[0])
3490-
timed_control = timed_charge_registers.get(register)
3491+
timed_control = timed_slot_registers.get(register)
34913492
if timed_control:
3492-
field, slot = timed_control
3493-
entity["name"] = f"Timed Charge {field} {slot}"
3493+
direction, field, slot = timed_control
3494+
entity["name"] = f"Timed {direction} {field} {slot}"
34943495
entity["control"] = True
34953496

34963497

‎custom_components/solisconnect/sensor_data/select_sensors.py‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,18 @@ def get_select_sensors(inverter_config):
3636
"name": "Work Mode",
3737
"control": True,
3838
"entities": [
39-
# Adheres to RS485_MODBUS ESINV-33000ID Hybrid Inverter V3.2 / Appendix 8
40-
{"bit_position": 0, "name": "Self-Use", "conflicts_with": [0, 6, 11]},
41-
{"bit_position": 0, "name": "Self-Use + TOU", "conflicts_with": [0, 6, 11], "requires": [1]},
42-
{"bit_position": 2, "name": "Off-Grid Operation", "conflicts_with": [0, 1, 2]},
43-
{"bit_position": 6, "name": "Feed-in Priority", "conflicts_with": [0, 6, 11]},
44-
{"bit_position": 6, "name": "Feed-in + TOU", "conflicts_with": [0, 6, 11], "requires": [1]},
45-
{"bit_position": 11, "name": "Peak Shaving", "conflicts_with": [0, 4, 6, 11]},
39+
# Adheres to RS485_MODBUS ESINV-33000ID Hybrid Inverter V3.2 / Appendix 8.
40+
# Each option clears every OTHER mode bit it is incompatible with; the
41+
# previous lists conflicted with their own bit and never cleared the
42+
# Timed Charge bit (1), so switching modes could leave invalid bit
43+
# combos that the inverter rejects/normalises (controls "bounce off"),
44+
# and plain "Self-Use"/"Feed-in" could never turn TOU back off.
45+
{"bit_position": 0, "name": "Self-Use", "conflicts_with": [1, 2, 6, 11]},
46+
{"bit_position": 0, "name": "Self-Use + TOU", "conflicts_with": [2, 6, 11], "requires": [1]},
47+
{"bit_position": 2, "name": "Off-Grid Operation", "conflicts_with": [0, 1, 6, 11]},
48+
{"bit_position": 6, "name": "Feed-in Priority", "conflicts_with": [0, 1, 2, 11]},
49+
{"bit_position": 6, "name": "Feed-in + TOU", "conflicts_with": [0, 2, 11], "requires": [1]},
50+
{"bit_position": 11, "name": "Peak Shaving", "conflicts_with": [0, 1, 2, 4, 6]},
4651
],
4752
},
4853
]

‎custom_components/solisconnect/sensor_data/switch_sensors.py‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ def get_switch_sensors(inverter_config):
3737
"entities": [
3838
# Adheres to RS485_MODBUS ESINV-33000ID Hybrid Inverter V3.2 / Appendix 8
3939
{"bit_position": 0, "name": "Mode - Self Use", "conflicts_with": [2, 6, 11], "control": True},
40-
{"bit_position": 1, "name": "Mode - Timed Charge", "requires_any": [0, 6], "control": True},
40+
# Timed Charge (TOU) only operates alongside Self-Use (0) or Feed-In (6);
41+
# clear Off-Grid/Peak-Shaving first or the fallback Self-Use bit forms a
42+
# combo the firmware rejects and the switch bounces off on the next poll.
43+
{"bit_position": 1, "name": "Mode - Timed Charge", "requires_any": [0, 6], "conflicts_with": [2, 11], "control": True},
4144
{"bit_position": 2, "name": "Mode - Off-Grid", "conflicts_with": [0, 1, 6, 11], "control": True},
4245
{"bit_position": 3, "name": "Mode - Battery Wake Up", "control": True},
4346
{"bit_position": 4, "name": "Mode - Backup", "conflicts_with": [11], "control": True},
@@ -152,12 +155,12 @@ def get_switch_sensors(inverter_config):
152155
{"name": "Timed Charge Enable 4", "bit_position": 3, "control": True},
153156
{"name": "Timed Charge Enable 5", "bit_position": 4, "control": True},
154157
{"name": "Timed Charge Enable 6", "bit_position": 5, "control": True},
155-
{"name": "Timed Charge Enable 7", "bit_position": 6, "control": True},
156-
{"name": "Timed Charge Enable 8", "bit_position": 7, "control": True},
157-
{"name": "Timed Charge Enable 9", "bit_position": 8, "control": True},
158-
{"name": "Timed Charge Enable 10", "bit_position": 9, "control": True},
159-
{"name": "Timed Charge Enable 11", "bit_position": 10, "control": True},
160-
{"name": "Timed Charge Enable 12", "bit_position": 11, "control": True},
158+
{"name": "Timed Discharge Enable 1", "bit_position": 6, "control": True},
159+
{"name": "Timed Discharge Enable 2", "bit_position": 7, "control": True},
160+
{"name": "Timed Discharge Enable 3", "bit_position": 8, "control": True},
161+
{"name": "Timed Discharge Enable 4", "bit_position": 9, "control": True},
162+
{"name": "Timed Discharge Enable 5", "bit_position": 10, "control": True},
163+
{"name": "Timed Discharge Enable 6", "bit_position": 11, "control": True},
161164
],
162165
},
163166
]

‎custom_components/solisconnect/sensor_data/time_sensors.py‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,28 +25,28 @@ def get_time_sensors(inverter_config):
2525
[
2626
{"name": "Timed Charge Start 1", "register": 43711, "enabled": True, "control": True},
2727
{"name": "Timed Charge End 1", "register": 43713, "enabled": True, "control": True},
28-
{"name": "Timed Charge Start 7", "register": 43753, "enabled": True, "control": True},
29-
{"name": "Timed Charge End 7", "register": 43755, "enabled": True, "control": True},
28+
{"name": "Timed Discharge Start 1", "register": 43753, "enabled": True, "control": True},
29+
{"name": "Timed Discharge End 1", "register": 43755, "enabled": True, "control": True},
3030
{"name": "Timed Charge Start 2", "register": 43718, "enabled": True, "control": True},
3131
{"name": "Timed Charge End 2", "register": 43720, "enabled": True, "control": True},
32-
{"name": "Timed Charge Start 8", "register": 43760, "enabled": True, "control": True},
33-
{"name": "Timed Charge End 8", "register": 43762, "enabled": True, "control": True},
32+
{"name": "Timed Discharge Start 2", "register": 43760, "enabled": True, "control": True},
33+
{"name": "Timed Discharge End 2", "register": 43762, "enabled": True, "control": True},
3434
{"name": "Timed Charge Start 3", "register": 43725, "enabled": True, "control": True},
3535
{"name": "Timed Charge End 3", "register": 43727, "enabled": True, "control": True},
36-
{"name": "Timed Charge Start 9", "register": 43767, "enabled": True, "control": True},
37-
{"name": "Timed Charge End 9", "register": 43769, "enabled": True, "control": True},
36+
{"name": "Timed Discharge Start 3", "register": 43767, "enabled": True, "control": True},
37+
{"name": "Timed Discharge End 3", "register": 43769, "enabled": True, "control": True},
3838
{"name": "Timed Charge Start 4", "register": 43732, "enabled": True, "control": True},
3939
{"name": "Timed Charge End 4", "register": 43734, "enabled": True, "control": True},
40-
{"name": "Timed Charge Start 10", "register": 43774, "enabled": True, "control": True},
41-
{"name": "Timed Charge End 10", "register": 43776, "enabled": True, "control": True},
40+
{"name": "Timed Discharge Start 4", "register": 43774, "enabled": True, "control": True},
41+
{"name": "Timed Discharge End 4", "register": 43776, "enabled": True, "control": True},
4242
{"name": "Timed Charge Start 5", "register": 43739, "enabled": True, "control": True},
4343
{"name": "Timed Charge End 5", "register": 43741, "enabled": True, "control": True},
44-
{"name": "Timed Charge Start 11", "register": 43781, "enabled": True, "control": True},
45-
{"name": "Timed Charge End 11", "register": 43783, "enabled": True, "control": True},
44+
{"name": "Timed Discharge Start 5", "register": 43781, "enabled": True, "control": True},
45+
{"name": "Timed Discharge End 5", "register": 43783, "enabled": True, "control": True},
4646
{"name": "Timed Charge Start 6", "register": 43746, "enabled": True, "control": True},
4747
{"name": "Timed Charge End 6", "register": 43748, "enabled": True, "control": True},
48-
{"name": "Timed Charge Start 12", "register": 43788, "enabled": True, "control": True},
49-
{"name": "Timed Charge End 12", "register": 43790, "enabled": True, "control": True},
48+
{"name": "Timed Discharge Start 6", "register": 43788, "enabled": True, "control": True},
49+
{"name": "Timed Discharge End 6", "register": 43790, "enabled": True, "control": True},
5050
]
5151
)
5252

‎custom_components/solisconnect/sensors/solis_select_entity.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ async def async_select_option(self, option: str) -> None:
6868
break
6969
else:
7070
await self.set_register_bit(on_value, bit_position, conflicts_with, requires)
71+
self.async_write_ha_state()
72+
break
7173

7274
@property
7375
def device_info(self):

‎tests/test_ac_coupling.py‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -139,27 +139,28 @@ def test_timed_charge_enable_switch_names():
139139

140140
names = [entity["name"] for entity in _switch_entities(config, 43707)]
141141

142-
assert names == [f"Timed Charge Enable {slot}" for slot in range(1, 13)]
142+
# Bits 0-5 are the 6 charge slots, bits 6-11 the 6 discharge slots.
143+
assert names == [f"Timed Charge Enable {slot}" for slot in range(1, 7)] + [f"Timed Discharge Enable {slot}" for slot in range(1, 7)]
143144

144145

145-
def test_timed_charge_number_names_are_normalized():
146+
def test_timed_slot_number_names_are_normalized():
146147
from custom_components.solisconnect.sensor_data.hybrid_sensors import hybrid_sensors
147148

148149
entities = {int(entity["register"][0]): entity for group in hybrid_sensors for entity in group.get("entities", []) if entity.get("register")}
149150

150151
assert entities[43708]["name"] == "Timed Charge SOC 1"
151152
assert entities[43709]["name"] == "Timed Charge Current 1"
152153
assert entities[43710]["name"] == "Timed Charge Voltage 1"
153-
assert entities[43750]["name"] == "Timed Charge SOC 7"
154-
assert entities[43751]["name"] == "Timed Charge Current 7"
155-
assert entities[43752]["name"] == "Timed Charge Voltage 7"
156-
assert entities[43785]["name"] == "Timed Charge SOC 12"
157-
assert entities[43786]["name"] == "Timed Charge Current 12"
158-
assert entities[43787]["name"] == "Timed Charge Voltage 12"
154+
assert entities[43750]["name"] == "Timed Discharge SOC 1"
155+
assert entities[43751]["name"] == "Timed Discharge Current 1"
156+
assert entities[43752]["name"] == "Timed Discharge Voltage 1"
157+
assert entities[43785]["name"] == "Timed Discharge SOC 6"
158+
assert entities[43786]["name"] == "Timed Discharge Current 6"
159+
assert entities[43787]["name"] == "Timed Discharge Voltage 6"
159160
assert entities[43024]["name"] == "Backup Mode SOC"
160161

161162

162-
def test_timed_charge_time_names_are_normalized():
163+
def test_timed_slot_time_names_are_normalized():
163164
from custom_components.solisconnect.sensor_data.time_sensors import get_time_sensors
164165

165166
template = next(inv for inv in SOLIS_INVERTERS if inv.model == "S6-EH1P")
@@ -168,10 +169,10 @@ def test_timed_charge_time_names_are_normalized():
168169

169170
assert entities[43711]["name"] == "Timed Charge Start 1"
170171
assert entities[43713]["name"] == "Timed Charge End 1"
171-
assert entities[43753]["name"] == "Timed Charge Start 7"
172-
assert entities[43755]["name"] == "Timed Charge End 7"
173-
assert entities[43788]["name"] == "Timed Charge Start 12"
174-
assert entities[43790]["name"] == "Timed Charge End 12"
172+
assert entities[43753]["name"] == "Timed Discharge Start 1"
173+
assert entities[43755]["name"] == "Timed Discharge End 1"
174+
assert entities[43788]["name"] == "Timed Discharge Start 6"
175+
assert entities[43790]["name"] == "Timed Discharge End 6"
175176

176177

177178
def test_parallel_feature_disabled_by_default():

‎tests/test_solis_select_entity.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,3 +246,65 @@ async def test_companion_writes_skipped_when_cache_empty(mock_hass, mock_control
246246
await entity.async_select_option("Solis RC Force Battery Charge")
247247

248248
mock_controller.async_write_holding_register.assert_awaited_once_with(primary_register, 1)
249+
250+
251+
def _work_mode_definition():
252+
from custom_components.solisconnect.data.solis_config import SOLIS_INVERTERS, InverterOptions
253+
from custom_components.solisconnect.sensor_data.select_sensors import get_select_sensors
254+
255+
template = next(inv for inv in SOLIS_INVERTERS if inv.model == "S6-EH1P")
256+
config = template.clone_with_options(InverterOptions(), "S2_WL_ST")
257+
return next(group for group in get_select_sensors(config) if group["name"] == "Work Mode")
258+
259+
260+
def _apply_option_bits(current: int, option: dict) -> int:
261+
"""Mirror SolisSelectEntity.set_register_bit's bit arithmetic for one option."""
262+
value = current
263+
for bit in option.get("conflicts_with", []):
264+
value = set_bit(value, bit, False)
265+
for bit in option.get("requires", []):
266+
value = set_bit(value, bit, True)
267+
return set_bit(value, option["bit_position"], True)
268+
269+
270+
def _resolve_option(register_value: int, options: list[dict]) -> str | None:
271+
"""Mirror SolisSelectEntity.current_option's resolution logic."""
272+
sorted_options = sorted(options, key=lambda e: len(e.get("requires", [])) if "requires" in e else 0, reverse=True)
273+
for e in sorted_options:
274+
bit_position = e.get("bit_position")
275+
requires = e.get("requires")
276+
if bit_position is not None and (register_value >> bit_position) & 1:
277+
if requires:
278+
if all((register_value >> rbit) & 1 for rbit in requires):
279+
return e["name"]
280+
else:
281+
return e["name"]
282+
return None
283+
284+
285+
@pytest.mark.parametrize("start_bits", [0b1, 0b11, 0b100, 0b1000010, 0b100000000001, 0b100011])
286+
def test_work_mode_options_round_trip_from_any_mode(start_bits):
287+
"""Every Work Mode option must land on a register state that resolves back to itself,
288+
regardless of the mode the inverter was in before (the old definitions left stale mode
289+
bits set, e.g. picking "Self-Use" never cleared TOU so it read back as "Self-Use + TOU",
290+
and cross-mode transitions produced combos the firmware rejects)."""
291+
definition = _work_mode_definition()
292+
options = definition["entities"]
293+
294+
for option in options:
295+
result = _apply_option_bits(start_bits, option)
296+
assert _resolve_option(result, options) == option["name"], (
297+
f"Selecting {option['name']!r} from {start_bits:#014b} produced {result:#014b}, which resolves to {_resolve_option(result, options)!r}"
298+
)
299+
300+
301+
def test_work_mode_plain_options_clear_tou_bit():
302+
"""Picking a mode without "+ TOU" must actually turn the Timed Charge bit off."""
303+
definition = _work_mode_definition()
304+
by_name = {e["name"]: e for e in definition["entities"]}
305+
306+
tou_on = 0b11 # Self-Use + TOU active
307+
assert (_apply_option_bits(tou_on, by_name["Self-Use"]) >> 1) & 1 == 0
308+
assert (_apply_option_bits(tou_on, by_name["Feed-in Priority"]) >> 1) & 1 == 0
309+
assert (_apply_option_bits(tou_on, by_name["Peak Shaving"]) >> 1) & 1 == 0
310+
assert (_apply_option_bits(tou_on, by_name["Off-Grid Operation"]) >> 1) & 1 == 0

‎tests/test_switch_conflicts_requires.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,33 @@ async def test_requires_tou(mock_hass, controller):
7575
controller.async_write_holding_register.assert_awaited_once_with(43110, expected)
7676

7777

78+
@pytest.mark.asyncio
79+
async def test_timed_charge_switch_clears_incompatible_modes(mock_hass, controller):
80+
"""Turning on Mode - Timed Charge while in Peak Shaving must clear bit 11 and fall
81+
back to Self-Use, not write an invalid combo the firmware rejects (the bounce)."""
82+
from custom_components.solisconnect.data.solis_config import SOLIS_INVERTERS, InverterOptions
83+
from custom_components.solisconnect.sensor_data.switch_sensors import get_switch_sensors
84+
85+
template = next(inv for inv in SOLIS_INVERTERS if inv.model == "S6-EH1P")
86+
config = template.clone_with_options(InverterOptions(), "S2_WL_ST")
87+
entity_def = next(
88+
entity for group in get_switch_sensors(config) if group.get("register") == 43110 for entity in group["entities"] if entity["bit_position"] == 1
89+
)
90+
entity_def = {**entity_def, "register": 43110, "write_register": None}
91+
92+
initial = set_bit(0, 11, True) # Peak Shaving active, no Self-Use/Feed-In
93+
94+
with (
95+
patch("custom_components.solisconnect.sensors.solis_binary_sensor.cache_get", return_value=initial),
96+
patch("custom_components.solisconnect.sensors.solis_binary_sensor.cache_save"),
97+
):
98+
entity = SolisBinaryEntity(mock_hass, controller, entity_def)
99+
await entity.set_register_bit(True)
100+
101+
expected = set_bit(set_bit(0, 0, True), 1, True) # Peak Shaving cleared, Self-Use + TOU set
102+
controller.async_write_holding_register.assert_awaited_once_with(43110, expected)
103+
104+
78105
@pytest.mark.asyncio
79106
async def test_conflicts_and_requires_combined(mock_hass, controller):
80107
entity_def = {

0 commit comments

Comments
 (0)