Skip to content
Closed
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
39 changes: 36 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ If you had previously been using this custom component in Home Assistant you wil



## Alpha ESS: GUI based Set Battery Charge/Discharge Times information<br>
## Alpha ESS: GUI based Set Battery Charge/Discharge Times information

These settings will only use slot 1 for charging and discharging, while you are not able to modify slot 2 from this integration, you are able to view its current settings

Alpha has recently, and unannounced removed most of the restrictions around the POST API calls that can be made. The current restrictions are:
Expand All @@ -68,8 +68,41 @@ An error will be placed in the logs

The current charge config, discharge config and charging range will only update once the API is re-called (can be up to 1 min)

If you want to adjust the restrictions yourself, you are able to by modifying the `ALPHA_POST_REQUEST_RESTRICTION` varible in const.py to the amount of seconds allowed per call
If you want to adjust the restrictions yourself, you are able to by modifying the `ALPHA_POST_REQUEST_RESTRICTION` variable in const.py to the amount of seconds allowed per call

### Time Window Calculation

The time window calculation has been updated to ensure that charge/discharge periods start immediately when activated. The system now:
- Rounds the current time to the next 15-minute interval
- Sets the start time to 15 minutes BEFORE the rounded time (alphaess requires a 15-minute interval to be set)
- Calculates the end time based on the selected duration

This ensures the current time always falls within the configured window, allowing immediate effect.

#### Examples:

**Example 1: 30-minute charge at 10:23**
- Current time: 10:23
- Rounded to: 10:30
- Start time: 10:15 (10:30 - 15 minutes)
- End time: 10:45 (10:15 + 30 minutes)
- Result: Charging window 10:15 - 10:45

**Example 2: 60-minute discharge at 14:46**
- Current time: 14:46
- Rounded to: 15:00
- Start time: 14:45 (15:00 - 15 minutes)
- End time: 15:45 (14:45 + 60 minutes)
- Result: Discharging window 14:45 - 15:45

**Example 3: 15-minute charge at 09:02**
- Current time: 09:02
- Rounded to: 09:15
- Start time: 09:00 (09:15 - 15 minutes)
- End time: 09:15 (09:00 + 15 minutes)
- Result: Charging window 09:00 - 09:15

This approach maintains the 15-minute interval alignment while ensuring the battery immediately begins charging or discharging when the button is pressed.
## Local Inverter Support

To use the local inverter support, you will need to have a local inverter that is able to reach your HA instance (preferably on the same subnet).
Expand Down
58 changes: 39 additions & 19 deletions custom_components/alphaess/button.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timedelta
from typing import List
import logging
from homeassistant.components.button import ButtonEntity, ButtonDeviceClass
Expand Down Expand Up @@ -42,21 +42,22 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:
description.key: description for description in EV_DISCHARGE_AND_CHARGE_BUTTONS
}


for serial, data in coordinator.data.items():
model = data.get("Model")
has_local_ip_data = 'Local IP' in data
if model not in INVERTER_SETTING_BLACKLIST:
for description in full_button_supported_states:
button_entities.append(
AlphaESSBatteryButton(coordinator, entry, serial, full_button_supported_states[description], has_local_connection=has_local_ip_data))
AlphaESSBatteryButton(coordinator, entry, serial, full_button_supported_states[description],
has_local_connection=has_local_ip_data))

ev_charger = data.get("EV Charger S/N")
if ev_charger:
for description in ev_charging_supported_states:
button_entities.append(
AlphaESSBatteryButton(
coordinator, entry, serial, ev_charging_supported_states[description], True, has_local_connection=has_local_ip_data
coordinator, entry, serial, ev_charging_supported_states[description], True,
has_local_connection=has_local_ip_data
)
)

Expand All @@ -65,10 +66,10 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None:

class AlphaESSBatteryButton(CoordinatorEntity, ButtonEntity):

def __init__(self, coordinator, config, serial, key_supported_states, ev_charger=False, has_local_connection=False):
def __init__(self, coordinator: AlphaESSDataUpdateCoordinator, config, serial, key_supported_states, ev_charger=False, has_local_connection=False):
super().__init__(coordinator)
self._serial = serial
self._coordinator = coordinator
self._coordinator: AlphaESSDataUpdateCoordinator = coordinator
self._name = key_supported_states.name
self._key = key_supported_states.key
if not ev_charger:
Expand All @@ -77,10 +78,17 @@ def __init__(self, coordinator, config, serial, key_supported_states, ev_charger
self._icon = key_supported_states.icon
self._entity_category = key_supported_states.entity_category
self._config = config
self._has_local_connection = has_local_connection

self._time = None

if self._key != AlphaESSNames.ButtonRechargeConfig:
if not ev_charger:
self._time = int(self._name.split()[0])
try:
self._time = int(self._name.split()[0])
except (ValueError, IndexError):
_LOGGER.warning(f"Could not extract time from button name: {self._name}")
self._time = None

for invertor in coordinator.data:
serial = invertor.upper()
Expand Down Expand Up @@ -139,13 +147,13 @@ async def handle_time_restriction(last_update_dict, update_fn, update_key, movem
last_update_dict[self._serial] = local_current_time
await update_fn(update_key, self._serial, self._time)
else:
remaining_time = ALPHA_POST_REQUEST_RESTRICTION - (local_current_time - last_update)
minutes, seconds = divmod(remaining_time.total_seconds(), 60)

await create_persistent_notification(self.hass,
message=f"HPlease wait {int(minutes)} minutes and {int(seconds)} seconds.",
title=f"{self._serial} cannot call {movement_direction}")

time_remaining = ALPHA_POST_REQUEST_RESTRICTION - (local_current_time - last_update)
mins, secs = divmod(time_remaining.total_seconds(), 60)
await create_persistent_notification(
self.hass,
message=f"Please wait {int(mins)} minutes and {int(secs)} seconds.",
title=f"{self._serial} cannot call {movement_direction}"
)
return last_update_dict

current_time = datetime.now()
Expand All @@ -158,11 +166,23 @@ async def handle_time_restriction(last_update_dict, update_fn, update_key, movem
last_discharge_update[self._serial] = last_charge_update[self._serial] = current_time
await self._coordinator.reset_config(self._serial)
else:
last_charge_update = await handle_time_restriction(last_charge_update, self._coordinator.update_charge,
"charge", self._movement_state)
last_discharge_update = await handle_time_restriction(last_discharge_update,
self._coordinator.update_discharge, "discharge",
self._movement_state)
charge_remaining = timedelta(0)
discharge_remaining = timedelta(0)

if last_charge_update.get(self._serial) is not None:
charge_remaining = ALPHA_POST_REQUEST_RESTRICTION - (
current_time - last_charge_update[self._serial])

if last_discharge_update.get(self._serial) is not None:
discharge_remaining = ALPHA_POST_REQUEST_RESTRICTION - (
current_time - last_discharge_update[self._serial])

remaining_time = max(charge_remaining, discharge_remaining)
minutes, seconds = divmod(remaining_time.total_seconds(), 60)

await create_persistent_notification(self.hass,
message=f"Please wait {int(minutes)} minutes and {int(seconds)} seconds.",
title=f"{self._serial} cannot reset configuration")
elif self._movement_state == "Discharge":
last_discharge_update = await handle_time_restriction(last_discharge_update,
self._coordinator.update_discharge, "batUseCap",
Expand Down
6 changes: 3 additions & 3 deletions custom_components/alphaess/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import voluptuous as vol

from homeassistant import config_entries
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError

Expand All @@ -22,7 +22,7 @@
})


async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
async def validate_input(data: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input allows us to connect."""

client = alphaess.alphaess(data["AppID"], data["AppSecret"], ipaddress=data["IPAddress"])
Expand Down Expand Up @@ -66,7 +66,7 @@ async def async_step_user(
if user_input:

try:
await validate_input(self.hass, user_input)
await validate_input(user_input)

except CannotConnect:
errors["base"] = "cannot_connect"
Expand Down
33 changes: 22 additions & 11 deletions custom_components/alphaess/coordinator.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Coordinator for AlphaEss integration."""
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, Optional, Union
from typing import Any, Dict, Optional

import aiohttp
from alphaess import alphaess
Expand Down Expand Up @@ -56,24 +56,35 @@ async def get_rounded_time() -> str:
"""Get time rounded to next 15-minute interval."""
now = datetime.now()

if now.minute > 45:
rounded_time = now + timedelta(hours=1)
rounded_time = rounded_time.replace(minute=0, second=0, microsecond=0)
else:
rounded_time = now + timedelta(minutes=15 - (now.minute % 15))
rounded_time = rounded_time.replace(second=0, microsecond=0)
# Calculate minutes to add to reach next 15-minute interval
minutes_to_add = 15 - (now.minute % 15)
if minutes_to_add == 15: # Already on a 15-minute mark
minutes_to_add = 0

rounded_time = (now + timedelta(minutes=minutes_to_add)).replace(
second=0,
microsecond=0
)

return rounded_time.strftime("%H:%M")

@staticmethod
async def calculate_time_window(time_period_minutes: int) -> tuple[str, str]:
"""Calculate start and end time for a given period."""
now = datetime.now()
start_time_str = await TimeHelper.get_rounded_time()
start_time = datetime.strptime(start_time_str, "%H:%M").replace(

# Get the rounded time (next 15-minute interval)
rounded_time_str = await TimeHelper.get_rounded_time()
rounded_time = datetime.strptime(rounded_time_str, "%H:%M").replace(
year=now.year, month=now.month, day=now.day
)
end_time = start_time + timedelta(minutes=time_period_minutes)

# Start time is 15 minutes BEFORE the rounded time
start_time = rounded_time - timedelta(minutes=15)

# End time is the rounded time PLUS the time period
end_time = rounded_time + timedelta(minutes=time_period_minutes)

return start_time.strftime("%H:%M"), end_time.strftime("%H:%M")


Expand Down Expand Up @@ -454,4 +465,4 @@ async def _parse_inverter_data(self, invertor: Dict) -> Dict[str, Any]:
bat_use_cap = discharge_config.get("batUseCap", 10) if discharge_config else 10
data[AlphaESSNames.ChargeRange] = f"{bat_use_cap}% - {bat_high_cap}%"

return data
return data
1 change: 1 addition & 0 deletions custom_components/alphaess/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def __init__(self, coordinator, serial, config, full_number_supported_states, ha
self._native_unit_of_measurement = full_number_supported_states.native_unit_of_measurement
self._icon = full_number_supported_states.icon
self._name = full_number_supported_states.name
self._has_local_connection = has_local_connection

if self.key is AlphaESSNames.batHighCap:
self._def_initial_value = float(90)
Expand Down
4 changes: 2 additions & 2 deletions custom_components/alphaess/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def __init__(self, coordinator, config, serial, key_supported_states, currency,
self._state_class = key_supported_states.state_class
self._serial = serial
self._coordinator = coordinator
self._has_local_connection = has_local_connection

if key_supported_states.native_unit_of_measurement is CURRENCY_DOLLAR:
self._native_unit_of_measurement = currency
Expand Down Expand Up @@ -158,7 +159,6 @@ def __init__(self, coordinator, config, serial, key_supported_states, currency,
name=f"Alpha ESS Energy Statistics : {serial}",
)


@property
def unique_id(self):
"""Return a unique ID to use for this entity."""
Expand Down Expand Up @@ -324,4 +324,4 @@ def get_time_range(prefix):
elif direction == "Charge":
return get_time_range("charge")

return None
return None
2 changes: 1 addition & 1 deletion custom_components/alphaess/sensorlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
icon="mdi:solar-power",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
state_class=SensorStateClass.MEASUREMENT,
),
AlphaESSSensorDescription(
key=AlphaESSNames.TotalLoad,
Expand Down