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/button.py b/custom_components/alphaess/button.py index 7b1a70e..27aad06 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 @@ -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,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() @@ -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() @@ -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", 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" diff --git a/custom_components/alphaess/coordinator.py b/custom_components/alphaess/coordinator.py index 3f2b9ca..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 @@ -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") @@ -69,11 +72,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 ) - 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") @@ -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 \ 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 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,