Skip to content

Commit 32f6548

Browse files
committed
feat: add BatteryReserve and AllowGridCharging switches
1 parent 86cb468 commit 32f6548

File tree

6 files changed

+265
-44
lines changed

6 files changed

+265
-44
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ All Solis inverters should be supported, although the integration has been teste
7272
The integration provides a user-friendly interface to control your inverter settings. It allows you to:
7373

7474
* ⚡ Control storage modes: "Self-Use", "Feed-In Priority" and "Off-Grid" (hybrid inverters only)
75-
* 🛠️ Access "Battery Reserve" and "Allow Grid Charging" options as Storage Mode attributes (hybrid inverters only)
7675
* ⏱️ Schedule charge and discharge slots (hybrid inverters only)
76+
* 🛠️ "Battery Reserve" and "Allow Grid Charging" switches (hybrid inverters only)
7777
* ⚖️ Set maximum export power
7878
* 🔌 Switch the inverter on or off
7979

custom_components/solis_cloud_control/select.py

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -72,38 +72,26 @@ def current_option(self) -> str | None:
7272

7373
return None
7474

75-
@property
76-
def extra_state_attributes(self) -> dict[str, str]:
77-
value_str = self.coordinator.data.get(self.storage_mode.cid)
78-
value = safe_get_int_value(value_str)
79-
80-
attributes = {}
81-
if value is not None:
82-
battery_reserve = "ON" if value & (1 << self.storage_mode.bit_backup_mode) else "OFF"
83-
allow_grid_charging = "ON" if value & (1 << self.storage_mode.bit_grid_charging) else "OFF"
84-
85-
attributes["battery_reserve"] = battery_reserve
86-
attributes["allow_grid_charging"] = allow_grid_charging
87-
88-
return attributes
89-
9075
async def async_select_option(self, option: str) -> None:
9176
value_str = self.coordinator.data.get(self.storage_mode.cid)
9277
value = safe_get_int_value(value_str)
9378
if value is None:
9479
return
9580

96-
value &= ~(1 << self.storage_mode.bit_self_use)
97-
value &= ~(1 << self.storage_mode.bit_feed_in_priority)
98-
value &= ~(1 << self.storage_mode.bit_off_grid)
81+
# clear the bits for the storage mode options
82+
new_value = value & ~(
83+
(1 << self.storage_mode.bit_self_use)
84+
| (1 << self.storage_mode.bit_feed_in_priority)
85+
| (1 << self.storage_mode.bit_off_grid)
86+
)
9987

10088
if option == self.storage_mode.mode_self_use:
101-
value |= 1 << self.storage_mode.bit_self_use
89+
new_value |= 1 << self.storage_mode.bit_self_use
10290
elif option == self.storage_mode.mode_feed_in_priority:
103-
value |= 1 << self.storage_mode.bit_feed_in_priority
91+
new_value |= 1 << self.storage_mode.bit_feed_in_priority
10492
elif option == self.storage_mode.mode_off_grid:
105-
value |= 1 << self.storage_mode.bit_off_grid
93+
new_value |= 1 << self.storage_mode.bit_off_grid
10694

107-
_LOGGER.info("Setting storage mode to %s (value: %s)", option, value)
95+
_LOGGER.info("Setting storage mode to %s (value: %s)", option, new_value)
10896

109-
await self.coordinator.control(self.storage_mode.cid, str(value))
97+
await self.coordinator.control(self.storage_mode.cid, str(new_value))

custom_components/solis_cloud_control/switch.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
InverterChargeDischargeSlot,
1010
InverterChargeDischargeSlots,
1111
InverterOnOff,
12+
InverterStorageMode, # Added import
1213
)
14+
from custom_components.solis_cloud_control.utils.safe_converters import safe_get_int_value # Added import
1315

1416
from .coordinator import SolisCloudControlCoordinator
1517
from .entity import SolisCloudControlEntity
@@ -69,6 +71,30 @@ async def async_setup_entry(
6971
)
7072
)
7173

74+
if inverter.storage_mode is not None:
75+
entities.append(
76+
BatteryReserveSwitch(
77+
coordinator=coordinator,
78+
entity_description=SwitchEntityDescription(
79+
key="battery_reserve",
80+
name="Battery Reserve",
81+
icon="mdi:battery-heart-outline",
82+
),
83+
storage_mode=inverter.storage_mode,
84+
)
85+
)
86+
entities.append(
87+
AllowGridChargingSwitch(
88+
coordinator=coordinator,
89+
entity_description=SwitchEntityDescription(
90+
key="allow_grid_charging",
91+
name="Allow Grid Charging",
92+
icon="mdi:battery-charging-outline",
93+
),
94+
storage_mode=inverter.storage_mode,
95+
)
96+
)
97+
7298
async_add_entities(entities)
7399

74100

@@ -148,3 +174,83 @@ def _calculate_old_value(self) -> str:
148174
value |= 1 << bit_position
149175

150176
return str(value)
177+
178+
179+
class BatteryReserveSwitch(SolisCloudControlEntity, SwitchEntity):
180+
def __init__(
181+
self,
182+
coordinator: SolisCloudControlCoordinator,
183+
entity_description: SwitchEntityDescription,
184+
storage_mode: InverterStorageMode,
185+
) -> None:
186+
super().__init__(coordinator, entity_description, storage_mode.cid)
187+
self.storage_mode = storage_mode
188+
189+
@property
190+
def is_on(self) -> bool | None:
191+
value_str = self.coordinator.data.get(self.storage_mode.cid)
192+
value = safe_get_int_value(value_str)
193+
if value is None:
194+
return None
195+
196+
return bool(value & (1 << self.storage_mode.bit_backup_mode))
197+
198+
async def async_turn_on(self, **kwargs: any) -> None: # noqa: ARG002
199+
await self._async_set_bit(True)
200+
201+
async def async_turn_off(self, **kwargs: any) -> None: # noqa: ARG002
202+
await self._async_set_bit(False)
203+
204+
async def _async_set_bit(self, state: bool) -> None:
205+
value_str = self.coordinator.data.get(self.storage_mode.cid)
206+
value = safe_get_int_value(value_str)
207+
if value is None:
208+
return
209+
210+
if state:
211+
value |= 1 << self.storage_mode.bit_backup_mode
212+
else:
213+
value &= ~(1 << self.storage_mode.bit_backup_mode)
214+
215+
_LOGGER.info("Setting battery reserve to %s (value: %s)", state, value)
216+
await self.coordinator.control(self.storage_mode.cid, str(value))
217+
218+
219+
class AllowGridChargingSwitch(SolisCloudControlEntity, SwitchEntity):
220+
def __init__(
221+
self,
222+
coordinator: SolisCloudControlCoordinator,
223+
entity_description: SwitchEntityDescription,
224+
storage_mode: InverterStorageMode,
225+
) -> None:
226+
super().__init__(coordinator, entity_description, storage_mode.cid)
227+
self.storage_mode = storage_mode
228+
229+
@property
230+
def is_on(self) -> bool | None:
231+
value_str = self.coordinator.data.get(self.storage_mode.cid)
232+
value = safe_get_int_value(value_str)
233+
if value is None:
234+
return None
235+
236+
return bool(value & (1 << self.storage_mode.bit_grid_charging))
237+
238+
async def async_turn_on(self, **kwargs: any) -> None: # noqa: ARG002
239+
await self._async_set_bit(True)
240+
241+
async def async_turn_off(self, **kwargs: any) -> None: # noqa: ARG002
242+
await self._async_set_bit(False)
243+
244+
async def _async_set_bit(self, state: bool) -> None:
245+
value_str = self.coordinator.data.get(self.storage_mode.cid)
246+
value = safe_get_int_value(value_str)
247+
if value is None:
248+
return
249+
250+
if state:
251+
value |= 1 << self.storage_mode.bit_grid_charging
252+
else:
253+
value &= ~(1 << self.storage_mode.bit_grid_charging)
254+
255+
_LOGGER.info("Setting allow grid charging to %s (value: %s)", state, value)
256+
await self.coordinator.control(self.storage_mode.cid, str(value))

tests/test_init.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ async def test_async_setup_entry(hass, mock_api_client, mock_config_entry, any_i
6262
assert platform_counts[Platform.NUMBER] == 26
6363
assert platform_counts[Platform.SELECT] == 1
6464
assert platform_counts[Platform.SENSOR] == 7
65-
assert platform_counts[Platform.SWITCH] == 13
65+
assert platform_counts[Platform.SWITCH] == 15
6666
assert platform_counts[Platform.TEXT] == 13
6767

6868

tests/test_select.py

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,24 +42,6 @@ async def test_current_option(self, storage_mode_entity, value, expected_mode):
4242
storage_mode_entity.coordinator.data = {storage_mode_entity.storage_mode.cid: value}
4343
assert storage_mode_entity.current_option == expected_mode
4444

45-
async def test_extra_state_attributes_on(self, storage_mode_entity):
46-
value = (1 << InverterStorageMode.bit_backup_mode) | (1 << InverterStorageMode.bit_grid_charging)
47-
storage_mode_entity.coordinator.data = {storage_mode_entity.storage_mode.cid: str(value)}
48-
49-
assert storage_mode_entity.extra_state_attributes == {
50-
"battery_reserve": "ON",
51-
"allow_grid_charging": "ON",
52-
}
53-
54-
async def test_extra_state_attributes_off(self, storage_mode_entity):
55-
value = 0
56-
storage_mode_entity.coordinator.data = {storage_mode_entity.storage_mode.cid: str(value)}
57-
58-
assert storage_mode_entity.extra_state_attributes == {
59-
"battery_reserve": "OFF",
60-
"allow_grid_charging": "OFF",
61-
}
62-
6345
@pytest.mark.parametrize(
6446
("option", "expected_value"),
6547
[
@@ -75,6 +57,32 @@ async def test_async_select_option(self, storage_mode_entity, option, expected_v
7557
storage_mode_entity.storage_mode.cid, expected_value
7658
)
7759

60+
@pytest.mark.parametrize(
61+
"mode,bit",
62+
[
63+
(InverterStorageMode.mode_self_use, InverterStorageMode.bit_self_use),
64+
(InverterStorageMode.mode_feed_in_priority, InverterStorageMode.bit_feed_in_priority),
65+
(InverterStorageMode.mode_off_grid, InverterStorageMode.bit_off_grid),
66+
],
67+
)
68+
async def test_async_select_option_preserves_other_bits(self, storage_mode_entity, mode, bit):
69+
other_bits = 0b10111010
70+
71+
# set initial value with storage mode bits cleared
72+
initial_value = other_bits & ~(
73+
(1 << InverterStorageMode.bit_self_use)
74+
| (1 << InverterStorageMode.bit_feed_in_priority)
75+
| (1 << InverterStorageMode.bit_off_grid)
76+
)
77+
storage_mode_entity.coordinator.data = {storage_mode_entity.storage_mode.cid: initial_value}
78+
79+
await storage_mode_entity.async_select_option(mode)
80+
81+
expected_value = initial_value | (1 << bit)
82+
storage_mode_entity.coordinator.control.assert_awaited_once_with(
83+
storage_mode_entity.storage_mode.cid, str(expected_value)
84+
)
85+
7886
@pytest.mark.parametrize(
7987
"initial_value",
8088
[

tests/test_switch.py

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import pytest
22
from homeassistant.components.switch import SwitchEntityDescription
33

4-
from custom_components.solis_cloud_control.switch import OnOffSwitch, SlotSwitch
4+
from custom_components.solis_cloud_control.switch import (
5+
AllowGridChargingSwitch,
6+
BatteryReserveSwitch,
7+
OnOffSwitch,
8+
SlotSwitch,
9+
)
510

611

712
@pytest.fixture
@@ -82,3 +87,117 @@ async def test_turn_off(self, slot_switch):
8287
slot_switch.coordinator.data = {slot_switch.charge_discharge_slot.switch_cid: "1"}
8388
await slot_switch.async_turn_off()
8489
slot_switch.coordinator.control.assert_awaited_once_with(slot_switch.charge_discharge_slot.switch_cid, "0", "1")
90+
91+
92+
@pytest.fixture
93+
def battery_reserve_switch(mock_coordinator, any_inverter):
94+
return BatteryReserveSwitch(
95+
coordinator=mock_coordinator,
96+
entity_description=SwitchEntityDescription(
97+
key="battery_reserve",
98+
name="Battery Reserve",
99+
icon="mdi:battery-heart-outline",
100+
),
101+
storage_mode=any_inverter.storage_mode,
102+
)
103+
104+
105+
class TestBatteryReserveSwitch:
106+
def test_is_on_when_none(self, battery_reserve_switch):
107+
battery_reserve_switch.coordinator.data = {battery_reserve_switch.storage_mode.cid: None}
108+
assert battery_reserve_switch.is_on is None
109+
110+
def test_is_on_when_on(self, battery_reserve_switch):
111+
bit = battery_reserve_switch.storage_mode.bit_backup_mode
112+
battery_reserve_switch.coordinator.data = {battery_reserve_switch.storage_mode.cid: str(1 << bit)}
113+
assert battery_reserve_switch.is_on is True
114+
115+
def test_is_on_when_off(self, battery_reserve_switch):
116+
battery_reserve_switch.coordinator.data = {battery_reserve_switch.storage_mode.cid: str(0)}
117+
assert battery_reserve_switch.is_on is False
118+
119+
async def test_turn_on(self, battery_reserve_switch):
120+
bit = battery_reserve_switch.storage_mode.bit_backup_mode
121+
battery_reserve_switch.coordinator.data = {battery_reserve_switch.storage_mode.cid: str(0)}
122+
await battery_reserve_switch.async_turn_on()
123+
battery_reserve_switch.coordinator.control.assert_awaited_once_with(
124+
battery_reserve_switch.storage_mode.cid, str(1 << bit)
125+
)
126+
127+
async def test_turn_off(self, battery_reserve_switch):
128+
bit = battery_reserve_switch.storage_mode.bit_backup_mode
129+
battery_reserve_switch.coordinator.data = {battery_reserve_switch.storage_mode.cid: str(1 << bit)}
130+
await battery_reserve_switch.async_turn_off()
131+
battery_reserve_switch.coordinator.control.assert_awaited_once_with(
132+
battery_reserve_switch.storage_mode.cid, str(0)
133+
)
134+
135+
@pytest.mark.parametrize(
136+
"initial_value",
137+
[
138+
"not a number",
139+
None,
140+
],
141+
)
142+
async def test_async_turn_on_off_invalid_initial(self, battery_reserve_switch, initial_value):
143+
battery_reserve_switch.coordinator.data = {battery_reserve_switch.storage_mode.cid: initial_value}
144+
await battery_reserve_switch.async_turn_on()
145+
await battery_reserve_switch.async_turn_off()
146+
battery_reserve_switch.coordinator.control.assert_not_awaited()
147+
148+
149+
@pytest.fixture
150+
def allow_grid_charging_switch(mock_coordinator, any_inverter):
151+
return AllowGridChargingSwitch(
152+
coordinator=mock_coordinator,
153+
entity_description=SwitchEntityDescription(
154+
key="allow_grid_charging",
155+
name="Allow Grid Charging",
156+
icon="mdi:battery-charging-outline",
157+
),
158+
storage_mode=any_inverter.storage_mode,
159+
)
160+
161+
162+
class TestAllowGridChargingSwitch:
163+
def test_is_on_when_none(self, allow_grid_charging_switch):
164+
allow_grid_charging_switch.coordinator.data = {allow_grid_charging_switch.storage_mode.cid: None}
165+
assert allow_grid_charging_switch.is_on is None
166+
167+
def test_is_on_when_on(self, allow_grid_charging_switch):
168+
bit = allow_grid_charging_switch.storage_mode.bit_grid_charging
169+
allow_grid_charging_switch.coordinator.data = {allow_grid_charging_switch.storage_mode.cid: str(1 << bit)}
170+
assert allow_grid_charging_switch.is_on is True
171+
172+
def test_is_on_when_off(self, allow_grid_charging_switch):
173+
allow_grid_charging_switch.coordinator.data = {allow_grid_charging_switch.storage_mode.cid: str(0)}
174+
assert allow_grid_charging_switch.is_on is False
175+
176+
async def test_turn_on(self, allow_grid_charging_switch):
177+
bit = allow_grid_charging_switch.storage_mode.bit_grid_charging
178+
allow_grid_charging_switch.coordinator.data = {allow_grid_charging_switch.storage_mode.cid: str(0)}
179+
await allow_grid_charging_switch.async_turn_on()
180+
allow_grid_charging_switch.coordinator.control.assert_awaited_once_with(
181+
allow_grid_charging_switch.storage_mode.cid, str(1 << bit)
182+
)
183+
184+
async def test_turn_off(self, allow_grid_charging_switch):
185+
bit = allow_grid_charging_switch.storage_mode.bit_grid_charging
186+
allow_grid_charging_switch.coordinator.data = {allow_grid_charging_switch.storage_mode.cid: str(1 << bit)}
187+
await allow_grid_charging_switch.async_turn_off()
188+
allow_grid_charging_switch.coordinator.control.assert_awaited_once_with(
189+
allow_grid_charging_switch.storage_mode.cid, str(0)
190+
)
191+
192+
@pytest.mark.parametrize(
193+
"initial_value",
194+
[
195+
"not a number",
196+
None,
197+
],
198+
)
199+
async def test_async_turn_on_off_invalid_initial(self, allow_grid_charging_switch, initial_value):
200+
allow_grid_charging_switch.coordinator.data = {allow_grid_charging_switch.storage_mode.cid: initial_value}
201+
await allow_grid_charging_switch.async_turn_on()
202+
await allow_grid_charging_switch.async_turn_off()
203+
allow_grid_charging_switch.coordinator.control.assert_not_awaited()

0 commit comments

Comments
 (0)