Skip to content

Commit 34009be

Browse files
committed
Fix Work Mode select/switch bit conflicts and rename discharge slots
The Work Mode select options each conflicted with their own bit, never cleared the Timed Charge bit, and left stale mode bits from the previous mode - so plain modes couldn't turn TOU off and cross-mode transitions could write invalid combos the firmware rejects, which reads back as controls bouncing off on the next poll. Each option now clears exactly the other mode bits it is incompatible with per the official Appendix 8 map. Mode - Timed Charge gains conflicts_with [2, 11]: toggling it from Off-Grid or Peak Shaving force-sets the fallback Self-Use bit, which those modes forbid - the firmware rejected the combo and the switch bounced off. The select's bit-position branch now pushes its state after a write (the on_value branch already did). Also renames the six discharge slots that were mislabelled as "Timed Charge ... 7-12" to "Timed Discharge ... 1-6" (enable switches, start/end times, SOC/current/voltage numbers). Names only; unique_ids are register-derived so nothing migrates.
1 parent 088e619 commit 34009be

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)