From c3151c5039e8e3d60d0bde9b9998d823729ead56 Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Tue, 29 Jul 2025 19:53:32 +0930 Subject: [PATCH 1/5] Update documentation --- README.md | 39 +++++++++++++++++++++-- custom_components/alphaess/coordinator.py | 12 +++++-- custom_components/alphaess/sensorlist.py | 2 +- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 49e4172..50c507a 100644 --- a/README.md +++ b/README.md @@ -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
- +## 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: @@ -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). diff --git a/custom_components/alphaess/coordinator.py b/custom_components/alphaess/coordinator.py index 3f2b9ca..bf717e1 100644 --- a/custom_components/alphaess/coordinator.py +++ b/custom_components/alphaess/coordinator.py @@ -69,11 +69,19 @@ async def get_rounded_time() -> str: 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 ) + + # Start time is 15 minutes BEFORE the rounded time + start_time = rounded_time - timedelta(minutes=15) + + # End time is the time_period_minutes after the start time end_time = start_time + timedelta(minutes=time_period_minutes) + return start_time.strftime("%H:%M"), end_time.strftime("%H:%M") diff --git a/custom_components/alphaess/sensorlist.py b/custom_components/alphaess/sensorlist.py index 79e7d42..8a8775e 100644 --- a/custom_components/alphaess/sensorlist.py +++ b/custom_components/alphaess/sensorlist.py @@ -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, From 618d41aa4f0fe5cd8fd1d8dee1ddd1c5c56eac0d Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Tue, 29 Jul 2025 20:28:24 +0930 Subject: [PATCH 2/5] Fix reset button not working --- custom_components/alphaess/button.py | 32 ++++++++++++++++++----- custom_components/alphaess/coordinator.py | 4 +-- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/custom_components/alphaess/button.py b/custom_components/alphaess/button.py index 7b1a70e..d033ac8 100644 --- a/custom_components/alphaess/button.py +++ b/custom_components/alphaess/button.py @@ -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 @@ -78,9 +78,15 @@ def __init__(self, coordinator, config, serial, key_supported_states, ev_charger self._entity_category = key_supported_states.entity_category self._config = config + 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() @@ -158,11 +164,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", diff --git a/custom_components/alphaess/coordinator.py b/custom_components/alphaess/coordinator.py index bf717e1..505b750 100644 --- a/custom_components/alphaess/coordinator.py +++ b/custom_components/alphaess/coordinator.py @@ -79,8 +79,8 @@ async def calculate_time_window(time_period_minutes: int) -> tuple[str, str]: # Start time is 15 minutes BEFORE the rounded time start_time = rounded_time - timedelta(minutes=15) - # End time is the time_period_minutes after the start time - end_time = start_time + timedelta(minutes=time_period_minutes) + # 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") From 6e72ff950d52a5cca3d6044b7d1c3e445400cb58 Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Tue, 29 Jul 2025 20:34:59 +0930 Subject: [PATCH 3/5] rework get_rounded_time function --- custom_components/alphaess/coordinator.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/custom_components/alphaess/coordinator.py b/custom_components/alphaess/coordinator.py index 505b750..8abba90 100644 --- a/custom_components/alphaess/coordinator.py +++ b/custom_components/alphaess/coordinator.py @@ -56,12 +56,15 @@ 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") From 47671e2c98ff7751beaeb326f05265f6442ce7fc Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Tue, 29 Jul 2025 20:40:00 +0930 Subject: [PATCH 4/5] IDE error fixes --- custom_components/alphaess/button.py | 30 ++++++++++++----------- custom_components/alphaess/coordinator.py | 4 +-- custom_components/alphaess/number.py | 1 + custom_components/alphaess/sensor.py | 4 +-- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/custom_components/alphaess/button.py b/custom_components/alphaess/button.py index d033ac8..27aad06 100644 --- a/custom_components/alphaess/button.py +++ b/custom_components/alphaess/button.py @@ -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 ) ) @@ -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: @@ -77,6 +78,7 @@ 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 @@ -145,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() @@ -169,11 +171,11 @@ async def handle_time_restriction(last_update_dict, update_fn, update_key, movem if last_charge_update.get(self._serial) is not None: charge_remaining = ALPHA_POST_REQUEST_RESTRICTION - ( - current_time - last_charge_update[self._serial]) + 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]) + current_time - last_discharge_update[self._serial]) remaining_time = max(charge_remaining, discharge_remaining) minutes, seconds = divmod(remaining_time.total_seconds(), 60) diff --git a/custom_components/alphaess/coordinator.py b/custom_components/alphaess/coordinator.py index 8abba90..c1f8594 100644 --- a/custom_components/alphaess/coordinator.py +++ b/custom_components/alphaess/coordinator.py @@ -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 @@ -465,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 \ No newline at end of file + return data diff --git a/custom_components/alphaess/number.py b/custom_components/alphaess/number.py index 38d97d0..5833618 100644 --- a/custom_components/alphaess/number.py +++ b/custom_components/alphaess/number.py @@ -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) diff --git a/custom_components/alphaess/sensor.py b/custom_components/alphaess/sensor.py index 764ce34..546c2bc 100644 --- a/custom_components/alphaess/sensor.py +++ b/custom_components/alphaess/sensor.py @@ -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 @@ -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.""" @@ -324,4 +324,4 @@ def get_time_range(prefix): elif direction == "Charge": return get_time_range("charge") - return None \ No newline at end of file + return None From 1dedb21e3cd60a60767d8cf34b0314549369bc8d Mon Sep 17 00:00:00 2001 From: Joshua Leaper Date: Tue, 29 Jul 2025 20:47:53 +0930 Subject: [PATCH 5/5] Remove unused calls in config_flow --- custom_components/alphaess/config_flow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/alphaess/config_flow.py b/custom_components/alphaess/config_flow.py index 7678b9a..f2e4e21 100644 --- a/custom_components/alphaess/config_flow.py +++ b/custom_components/alphaess/config_flow.py @@ -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 @@ -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"]) @@ -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"