Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,13 @@ All Solis inverters should be supported, although the integration has been teste

The integration provides a user-friendly interface to control your inverter settings. It allows you to:

- ⚡ Control storage modes: "Self-Use", "Feed-In Priority" and "Off-Grid" 🟢
- ⚡ Control storage modes: "Self-Use", "Feed-In Priority", "Off-Grid" and "Grid Peak-Shaving" 🟢
- ⏱️ Schedule charge and discharge slots 🟢
- Switch the inverter on or off 🟢 ⚪️
- Set the inverter time 🟢 ⚪️
- Toggle "Battery Reserve" 🟢
- Toggle "Allow Grid Charging" 🟢
- Toggle "Allow Export" (in "Self-Use" storage mode) 🟢
- Toggle "Grid Peak Shaving" (in "Self-Use" and "Feed-In Priority" storage modes) 🟢
- Set maximum output power 🟢
- Set maximum export power 🟢
- Control various Battery State of Charge (SOC) levels 🟢
Expand Down
21 changes: 12 additions & 9 deletions custom_components/solis_cloud_control/domain/storage_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,32 +27,41 @@ def is_feed_in_priority(self) -> bool:
def is_off_grid(self) -> bool:
return (self.mode & (1 << self.BIT_OFF_GRID)) != 0

def is_peak_shaving(self) -> bool:
return (self.mode & (1 << self.BIT_PEAK_SHAVING)) != 0

def is_battery_reserve_enabled(self) -> bool:
return (self.mode & (1 << self.BIT_BACKUP_MODE)) != 0

def is_allow_grid_charging(self) -> bool:
return (self.mode & (1 << self.BIT_GRID_CHARGING)) != 0

def is_peak_shaving(self) -> bool:
return (self.mode & (1 << self.BIT_PEAK_SHAVING)) != 0

def is_tou_mode(self) -> bool:
return (self.mode & (1 << self.BIT_TOU_MODE)) != 0

def set_self_use(self) -> None:
self.mode |= 1 << self.BIT_SELF_USE
self.mode &= ~(1 << self.BIT_FEED_IN_PRIORITY)
self.mode &= ~(1 << self.BIT_OFF_GRID)
self.mode &= ~(1 << self.BIT_PEAK_SHAVING)

def set_feed_in_priority(self) -> None:
self.mode |= 1 << self.BIT_FEED_IN_PRIORITY
self.mode &= ~(1 << self.BIT_SELF_USE)
self.mode &= ~(1 << self.BIT_OFF_GRID)
self.mode &= ~(1 << self.BIT_PEAK_SHAVING)

def set_off_grid(self) -> None:
self.mode |= 1 << self.BIT_OFF_GRID
self.mode &= ~(1 << self.BIT_SELF_USE)
self.mode &= ~(1 << self.BIT_FEED_IN_PRIORITY)
self.mode &= ~(1 << self.BIT_PEAK_SHAVING)

def set_peak_shaving(self) -> None:
self.mode |= 1 << self.BIT_PEAK_SHAVING
self.mode &= ~(1 << self.BIT_SELF_USE)
self.mode &= ~(1 << self.BIT_FEED_IN_PRIORITY)
self.mode &= ~(1 << self.BIT_OFF_GRID)

def enable_battery_reserve(self) -> None:
self.mode |= 1 << self.BIT_BACKUP_MODE
Expand All @@ -66,12 +75,6 @@ def enable_allow_grid_charging(self) -> None:
def disable_allow_grid_charging(self) -> None:
self.mode &= ~(1 << self.BIT_GRID_CHARGING)

def enable_peak_shaving(self) -> None:
self.mode |= 1 << self.BIT_PEAK_SHAVING

def disable_peak_shaving(self) -> None:
self.mode &= ~(1 << self.BIT_PEAK_SHAVING)

def enable_tou_mode(self) -> None:
self.mode |= 1 << self.BIT_TOU_MODE

Expand Down
6 changes: 6 additions & 0 deletions custom_components/solis_cloud_control/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class StorageModeSelect(SolisCloudControlEntity, SelectEntity):
MODE_SELF_USE: str = "Self-Use"
MODE_FEED_IN_PRIORITY: str = "Feed-In Priority"
MODE_OFF_GRID: str = "Off-Grid"
MODE_GRID_PEAK_SHAVING: str = "Grid Peak-Shaving"

def __init__(
self,
Expand All @@ -56,6 +57,7 @@ def __init__(
self.MODE_SELF_USE,
self.MODE_FEED_IN_PRIORITY,
self.MODE_OFF_GRID,
self.MODE_GRID_PEAK_SHAVING,
]

self.inverter_storage_mode = inverter_storage_mode
Expand All @@ -75,6 +77,8 @@ def current_option(self) -> str | None:
return self.MODE_FEED_IN_PRIORITY
elif storage_mode.is_off_grid():
return self.MODE_OFF_GRID
elif storage_mode.is_peak_shaving():
return self.MODE_GRID_PEAK_SHAVING

return None

Expand All @@ -92,6 +96,8 @@ async def async_select_option(self, option: str) -> None:
storage_mode.set_feed_in_priority()
elif option == self.MODE_OFF_GRID:
storage_mode.set_off_grid()
elif option == self.MODE_GRID_PEAK_SHAVING:
storage_mode.set_peak_shaving()

value_str = storage_mode.to_value()

Expand Down
76 changes: 0 additions & 76 deletions custom_components/solis_cloud_control/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,17 +109,6 @@ async def async_setup_entry(
inverter_storage_mode=inverter.storage_mode,
)
)
entities.append(
GridPeakShavingSwitch(
coordinator=coordinator,
entity_description=SwitchEntityDescription(
key="grid_peak_shaving",
name="Grid Peak Shaving",
icon="mdi:chart-bell-curve",
),
inverter_storage_mode=inverter.storage_mode,
)
)

if inverter.storage_mode is not None and not inverter.info.is_tou_v2_enabled:
entities.append(
Expand Down Expand Up @@ -403,71 +392,6 @@ async def async_turn_off(self, **kwargs) -> None: # noqa: ANN003, ARG002
await self.coordinator.control(self.inverter_storage_mode.cid, value_str)


class GridPeakShavingSwitch(SolisCloudControlEntity, SwitchEntity):
def __init__(
self,
coordinator: SolisCloudControlCoordinator,
entity_description: SwitchEntityDescription,
inverter_storage_mode: InverterStorageMode,
) -> None:
super().__init__(coordinator, entity_description, inverter_storage_mode.cid)
self.inverter_storage_mode = inverter_storage_mode

@property
def is_on(self) -> bool | None:
current_value = self.coordinator.data.get(self.inverter_storage_mode.cid)

storage_mode = StorageMode.create(current_value)
if storage_mode is None:
_LOGGER.warning("Invalid '%s' storage mode: '%s'", self.name, current_value)
return None

return storage_mode.is_peak_shaving()

@property
def available(self) -> bool:
if not super().available:
return False

storage_mode_value = self.coordinator.data.get(self.inverter_storage_mode.cid)
storage_mode = StorageMode.create(storage_mode_value)
if storage_mode is None:
_LOGGER.warning("Invalid '%s' storage mode: '%s'", self.name, storage_mode_value)
return False

return storage_mode.is_self_use() or storage_mode.is_feed_in_priority()

async def async_turn_on(self, **kwargs) -> None: # noqa: ANN003, ARG002
current_value = self.coordinator.data.get(self.inverter_storage_mode.cid)

storage_mode = StorageMode.create(current_value)
if storage_mode is None:
_LOGGER.warning("Invalid '%s' storage mode: '%s'", self.name, current_value)
return None

storage_mode.enable_peak_shaving()

value_str = storage_mode.to_value()

_LOGGER.info("Turn on '%s' (value: %s)", self.name, value_str)
await self.coordinator.control(self.inverter_storage_mode.cid, value_str)

async def async_turn_off(self, **kwargs) -> None: # noqa: ANN003, ARG002
current_value = self.coordinator.data.get(self.inverter_storage_mode.cid)

storage_mode = StorageMode.create(current_value)
if storage_mode is None:
_LOGGER.warning("Invalid '%s' storage mode: '%s'", self.name, current_value)
return None

storage_mode.disable_peak_shaving()

value_str = storage_mode.to_value()

_LOGGER.info("Turn off '%s' (value: %s)", self.name, value_str)
await self.coordinator.control(self.inverter_storage_mode.cid, value_str)


class TimeOfUseSwitch(SolisCloudControlEntity, SwitchEntity):
def __init__(
self,
Expand Down
34 changes: 17 additions & 17 deletions tests/domain/test_storage_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ def test_is_off_grid(self):

assert storage_mode.is_off_grid() is True

def test_is_peak_shaving(self):
storage_mode = StorageMode(2048)

assert storage_mode.is_peak_shaving() is True

def test_is_battery_reserve_enabled(self):
storage_mode = StorageMode(16)

Expand All @@ -47,11 +52,6 @@ def test_is_allow_grid_charging(self):

assert storage_mode.is_allow_grid_charging() is True

def test_is_peak_shaving(self):
storage_mode = StorageMode(2048)

assert storage_mode.is_peak_shaving() is True

def test_is_tou_mode(self):
storage_mode = StorageMode(2)

Expand All @@ -64,6 +64,7 @@ def test_set_self_use(self, storage_mode):
assert storage_mode.is_self_use() is True
assert storage_mode.is_feed_in_priority() is False
assert storage_mode.is_off_grid() is False
assert storage_mode.is_peak_shaving() is False

@pytest.mark.parametrize("storage_mode", [STORAGE_MODE_0x0000, STORAGE_MODE_0xFFFF])
def test_set_feed_in_priority(self, storage_mode):
Expand All @@ -72,6 +73,7 @@ def test_set_feed_in_priority(self, storage_mode):
assert storage_mode.is_feed_in_priority() is True
assert storage_mode.is_self_use() is False
assert storage_mode.is_off_grid() is False
assert storage_mode.is_peak_shaving() is False

@pytest.mark.parametrize("storage_mode", [STORAGE_MODE_0x0000, STORAGE_MODE_0xFFFF])
def test_set_off_grid(self, storage_mode):
Expand All @@ -80,6 +82,16 @@ def test_set_off_grid(self, storage_mode):
assert storage_mode.is_off_grid() is True
assert storage_mode.is_self_use() is False
assert storage_mode.is_feed_in_priority() is False
assert storage_mode.is_peak_shaving() is False

@pytest.mark.parametrize("storage_mode", [STORAGE_MODE_0x0000, STORAGE_MODE_0xFFFF])
def test_set_peak_shaving(self, storage_mode):
storage_mode.set_peak_shaving()

assert storage_mode.is_peak_shaving() is True
assert storage_mode.is_self_use() is False
assert storage_mode.is_feed_in_priority() is False
assert storage_mode.is_off_grid() is False

@pytest.mark.parametrize("storage_mode", [STORAGE_MODE_0x0000, STORAGE_MODE_0xFFFF])
def test_enable_battery_reserve(self, storage_mode):
Expand All @@ -105,18 +117,6 @@ def test_disable_allow_grid_charging(self, storage_mode):

assert storage_mode.is_allow_grid_charging() is False

@pytest.mark.parametrize("storage_mode", [STORAGE_MODE_0x0000, STORAGE_MODE_0xFFFF])
def test_enable_peak_shaving(self, storage_mode):
storage_mode.enable_peak_shaving()

assert storage_mode.is_peak_shaving() is True

@pytest.mark.parametrize("storage_mode", [STORAGE_MODE_0x0000, STORAGE_MODE_0xFFFF])
def test_disable_peak_shaving(self, storage_mode):
storage_mode.disable_peak_shaving()

assert storage_mode.is_peak_shaving() is False

@pytest.mark.parametrize("storage_mode", [STORAGE_MODE_0x0000, STORAGE_MODE_0xFFFF])
def test_enable_tou_mode(self, storage_mode):
storage_mode.enable_tou_mode()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ async def test_async_setup_entry(hass, mock_api_client, mock_config_entry, any_i
assert platform_counts[Platform.NUMBER] == 40
assert platform_counts[Platform.SELECT] == 1
assert platform_counts[Platform.SENSOR] == 7
assert platform_counts[Platform.SWITCH] == 18
assert platform_counts[Platform.SWITCH] == 17
assert platform_counts[Platform.TEXT] == 18

platform_disabled_counts = Counter(entry.domain for entry in entries if entry.disabled_by is not None)
Expand Down
3 changes: 3 additions & 0 deletions tests/test_select.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ async def test_options(self, storage_mode_entity):
storage_mode_entity.MODE_SELF_USE,
storage_mode_entity.MODE_FEED_IN_PRIORITY,
storage_mode_entity.MODE_OFF_GRID,
storage_mode_entity.MODE_GRID_PEAK_SHAVING,
]

@pytest.mark.parametrize(
Expand All @@ -28,6 +29,7 @@ async def test_options(self, storage_mode_entity):
(str(1 << StorageMode.BIT_SELF_USE), StorageModeSelect.MODE_SELF_USE),
(str(1 << StorageMode.BIT_FEED_IN_PRIORITY), StorageModeSelect.MODE_FEED_IN_PRIORITY),
(str(1 << StorageMode.BIT_OFF_GRID), StorageModeSelect.MODE_OFF_GRID),
(str(1 << StorageMode.BIT_PEAK_SHAVING), StorageModeSelect.MODE_GRID_PEAK_SHAVING),
(str(0), None),
("not a number", None),
(None, None),
Expand All @@ -43,6 +45,7 @@ async def test_current_option(self, storage_mode_entity, value, expected_mode):
(StorageModeSelect.MODE_SELF_USE, str(1 << StorageMode.BIT_SELF_USE)),
(StorageModeSelect.MODE_FEED_IN_PRIORITY, str(1 << StorageMode.BIT_FEED_IN_PRIORITY)),
(StorageModeSelect.MODE_OFF_GRID, str(1 << StorageMode.BIT_OFF_GRID)),
(StorageModeSelect.MODE_GRID_PEAK_SHAVING, str(1 << StorageMode.BIT_PEAK_SHAVING)),
],
)
async def test_async_select_option(self, storage_mode_entity, mode, expected_value):
Expand Down
81 changes: 0 additions & 81 deletions tests/test_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
AllowExportSwitch,
AllowGridChargingSwitch,
BatteryReserveSwitch,
GridPeakShavingSwitch,
OnOffSwitch,
SlotV2Switch,
TimeOfUseSwitch,
Expand Down Expand Up @@ -389,83 +388,3 @@ async def test_async_turn_on_off_invalid_initial(self, time_of_use_switch, initi
await time_of_use_switch.async_turn_on()
await time_of_use_switch.async_turn_off()
time_of_use_switch.coordinator.control.assert_not_awaited()


@pytest.fixture
def grid_peak_shaving_switch(mock_coordinator, any_inverter):
return GridPeakShavingSwitch(
coordinator=mock_coordinator,
entity_description=SwitchEntityDescription(key="any_key", name="any name"),
inverter_storage_mode=any_inverter.storage_mode,
)


class TestGridPeakShavingSwitch:
def test_is_on_when_none(self, grid_peak_shaving_switch):
grid_peak_shaving_switch.coordinator.data = {grid_peak_shaving_switch.inverter_storage_mode.cid: None}
assert grid_peak_shaving_switch.is_on is None

def test_is_on_when_on(self, grid_peak_shaving_switch):
bit = StorageMode.BIT_PEAK_SHAVING
grid_peak_shaving_switch.coordinator.data = {grid_peak_shaving_switch.inverter_storage_mode.cid: str(1 << bit)}
assert grid_peak_shaving_switch.is_on is True

def test_is_on_when_off(self, grid_peak_shaving_switch):
grid_peak_shaving_switch.coordinator.data = {grid_peak_shaving_switch.inverter_storage_mode.cid: str(0)}
assert grid_peak_shaving_switch.is_on is False

def test_available_when_storage_mode_is_self_use(self, grid_peak_shaving_switch):
mode = str(1 << StorageMode.BIT_SELF_USE)
grid_peak_shaving_switch.coordinator.data = {
grid_peak_shaving_switch.inverter_storage_mode.cid: mode,
}
assert grid_peak_shaving_switch.available is True

def test_available_when_storage_mode_is_feed_in_priority(self, grid_peak_shaving_switch):
mode = str(1 << StorageMode.BIT_FEED_IN_PRIORITY)
grid_peak_shaving_switch.coordinator.data = {
grid_peak_shaving_switch.inverter_storage_mode.cid: mode,
}
assert grid_peak_shaving_switch.available is True

def test_available_when_storage_mode_is_off_grid(self, grid_peak_shaving_switch):
mode = str(1 << StorageMode.BIT_OFF_GRID)
grid_peak_shaving_switch.coordinator.data = {
grid_peak_shaving_switch.inverter_storage_mode.cid: mode,
}
assert grid_peak_shaving_switch.available is False

def test_available_when_storage_mode_none(self, grid_peak_shaving_switch):
grid_peak_shaving_switch.coordinator.data = {
grid_peak_shaving_switch.inverter_storage_mode.cid: None,
}
assert grid_peak_shaving_switch.available is False

async def test_turn_on(self, grid_peak_shaving_switch):
bit = StorageMode.BIT_PEAK_SHAVING
grid_peak_shaving_switch.coordinator.data = {grid_peak_shaving_switch.inverter_storage_mode.cid: str(0)}
await grid_peak_shaving_switch.async_turn_on()
grid_peak_shaving_switch.coordinator.control.assert_awaited_once_with(
grid_peak_shaving_switch.inverter_storage_mode.cid, str(1 << bit)
)

async def test_turn_off(self, grid_peak_shaving_switch):
bit = StorageMode.BIT_PEAK_SHAVING
grid_peak_shaving_switch.coordinator.data = {grid_peak_shaving_switch.inverter_storage_mode.cid: str(1 << bit)}
await grid_peak_shaving_switch.async_turn_off()
grid_peak_shaving_switch.coordinator.control.assert_awaited_once_with(
grid_peak_shaving_switch.inverter_storage_mode.cid, str(0)
)

@pytest.mark.parametrize(
"initial_value",
[
"not a number",
None,
],
)
async def test_async_turn_on_off_invalid_initial(self, grid_peak_shaving_switch, initial_value):
grid_peak_shaving_switch.coordinator.data = {grid_peak_shaving_switch.inverter_storage_mode.cid: initial_value}
await grid_peak_shaving_switch.async_turn_on()
await grid_peak_shaving_switch.async_turn_off()
grid_peak_shaving_switch.coordinator.control.assert_not_awaited()
Loading