Skip to content
Open
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
12 changes: 12 additions & 0 deletions custom_components/solar_optimizer/config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.calendar import DOMAIN as CALENDAR_DOMAIN


from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
Expand Down Expand Up @@ -87,6 +89,11 @@
vol.Optional(CONF_MAX_ON_TIME_PER_DAY_MIN): str,
vol.Optional(CONF_MIN_ON_TIME_PER_DAY_MIN): str,
vol.Optional(CONF_OFFPEAK_TIME): str,
vol.Optional(CONF_OFFPEAK_ENTITY_ID): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=[BINARY_SENSOR_DOMAIN, INPUT_BOOLEAN_DOMAIN, SENSOR_DOMAIN, CALENDAR_DOMAIN]
)
),
}
)

Expand Down Expand Up @@ -138,5 +145,10 @@
vol.Optional(CONF_MAX_ON_TIME_PER_DAY_MIN): str,
vol.Optional(CONF_MIN_ON_TIME_PER_DAY_MIN): str,
vol.Optional(CONF_OFFPEAK_TIME): str,
vol.Optional(CONF_OFFPEAK_ENTITY_ID): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=[BINARY_SENSOR_DOMAIN, INPUT_BOOLEAN_DOMAIN, SENSOR_DOMAIN, CALENDAR_DOMAIN]
)
),
}
)
37 changes: 37 additions & 0 deletions custom_components/solar_optimizer/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@
CONF_MAX_ON_TIME_PER_DAY_MIN = "max_on_time_per_day_min"
CONF_MIN_ON_TIME_PER_DAY_MIN = "min_on_time_per_day_min"
CONF_OFFPEAK_TIME = "offpeak_time"
CONF_OFFPEAK_ENTITY_ID = "offpeak_entity_id"

# Regex for time range format like "13:00-14:00" or "01:00-07:00,13:00-14:00"
TIME_RANGE_REGEX = r"^(?:[01]\d|2[0-3]):[0-5]\d-(?:[01]\d|2[0-3]):[0-5]\d$"
TIME_RANGES_REGEX = r"^((?:[01]\d|2[0-3]):[0-5]\d-(?:[01]\d|2[0-3]):[0-5]\d)(,((?:[01]\d|2[0-3]):[0-5]\d-(?:[01]\d|2[0-3]):[0-5]\d))*$"

PRIORITY_WEIGHT_NULL = "None"
PRIORITY_WEIGHT_LOW = "Low"
Expand Down Expand Up @@ -157,6 +162,38 @@ def validate_time_format(value: str) -> str:
return value


def validate_time_ranges_format(value: str) -> str:
"""Check if a string has format "HH:MM-HH:MM" or "HH:MM-HH:MM,HH:MM-HH:MM,..."
Example: "13:00-14:00" or "01:00-07:00,13:00-14:00"
"""
if value is not None and not re.match(TIME_RANGES_REGEX, value):
raise Invalid(
"The time ranges value should be formatted like 'HH:MM-HH:MM' or 'HH:MM-HH:MM,HH:MM-HH:MM'. "
"Example: '13:00-14:00' or '01:00-07:00,13:00-14:00'"
)
return value


def parse_time_ranges(value: str) -> list[tuple]:
"""Parse a time ranges string like "13:00-14:00,01:00-07:00" into a list of (start_time, end_time) tuples.
Returns a list of tuples where each tuple contains (start_time, end_time) as datetime.time objects.
"""
from datetime import datetime

if not value:
return []

ranges = []
for time_range in value.split(","):
time_range = time_range.strip()
if "-" in time_range:
start_str, end_str = time_range.split("-")
start_time = datetime.strptime(start_str.strip(), "%H:%M").time()
end_time = datetime.strptime(end_str.strip(), "%H:%M").time()
ranges.append((start_time, end_time))
return ranges


def get_safe_float(hass, str_as_float) -> float | None:
"""Get a safe float state value for an str_as_float string.
Return None if str_as_float is None or not a valid float.
Expand Down
121 changes: 97 additions & 24 deletions custom_components/solar_optimizer/managed_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
get_template_or_value,
get_safe_float,
convert_to_template_or_value,
parse_time_ranges,
CONF_ACTION_MODE_ACTION,
CONF_ACTION_MODE_EVENT,
CONF_ACTION_MODES,
Expand Down Expand Up @@ -203,23 +204,36 @@ def __init__(self, hass: HomeAssistant, device_config, coordinator):

self._min_on_time_per_day_min = convert_to_template_or_value(hass, device_config.get("min_on_time_per_day_min") or 0)

# Off-peak configuration - supports two modes:
# 1. Time ranges string like "13:00-14:00,01:00-07:00"
# 2. Entity ID (binary_sensor, input_boolean, sensor, calendar)
offpeak_time = device_config.get("offpeak_time", None)
self._offpeak_time = None
self._offpeak_time = None # Legacy single time (kept for backward compatibility)
self._offpeak_time_ranges = [] # List of (start_time, end_time) tuples
self._offpeak_entity_id = device_config.get("offpeak_entity_id", None)

if offpeak_time:
self._offpeak_time = datetime.strptime(
device_config.get("offpeak_time"), "%H:%M"
).time()
# Check if it's the new format with ranges (contains "-")
if "-" in offpeak_time:
self._offpeak_time_ranges = parse_time_ranges(offpeak_time)
else:
# Legacy format: single time "HH:MM"
self._offpeak_time = datetime.strptime(offpeak_time, "%H:%M").time()

if self.is_active:
self._requested_power = self._current_power = self.power_max if self._can_change_power else self._power_min

self._enable = True

# Some checks
# min_on_time_per_day_sec requires an offpeak_time
if self.min_on_time_per_day_sec > 0 and self._offpeak_time is None:
msg = f"configuration of device ${self.name} is incorrect. min_on_time_per_day_sec requires offpeak_time value"
# min_on_time_per_day_sec requires an offpeak configuration (time, time_ranges, or entity)
has_offpeak_config = (
self._offpeak_time is not None
or len(self._offpeak_time_ranges) > 0
or self._offpeak_entity_id is not None
)
if self.min_on_time_per_day_sec > 0 and not has_offpeak_config:
msg = f"configuration of device ${self.name} is incorrect. min_on_time_per_day_sec requires offpeak_time or offpeak_entity_id value"
_LOGGER.error("%s - %s", self, msg)
raise ConfigurationError(msg)

Expand Down Expand Up @@ -456,25 +470,69 @@ def is_usable(self) -> bool:
and the _max_on_time_per_day_sec is not exceeded"""
return self.check_usable(True)

def _is_currently_offpeak(self) -> bool:
"""Check if current time is within offpeak hours.
Supports three modes:
1. Entity-based: checks the state of offpeak_entity_id
2. Time ranges: checks if current time is within any defined range
3. Legacy single time: checks if current time is between offpeak_time and raz_time
"""
# Mode 1: Entity-based off-peak detection
if self._offpeak_entity_id is not None:
entity_state = self._hass.states.get(self._offpeak_entity_id)
if entity_state is not None:
state_value = entity_state.state.lower()
# Handle various "on" states for different entity types
return state_value in (STATE_ON, "true", "1", "on", "yes")
return False

# Mode 2: Multiple time ranges like "13:00-14:00,01:00-07:00"
if len(self._offpeak_time_ranges) > 0:
current_time = self.now.time()
for start_time, end_time in self._offpeak_time_ranges:
if start_time <= end_time:
# Normal range (e.g., 13:00-14:00)
if start_time <= current_time < end_time:
return True
else:
# Range crosses midnight (e.g., 22:00-06:00)
if current_time >= start_time or current_time < end_time:
return True
return False

# Mode 3: Legacy single offpeak_time (from offpeak_time to raz_time)
if self._offpeak_time is not None:
current_time = self.now.time()
raz_time = self._coordinator.raz_time
if self._offpeak_time >= raz_time:
# Offpeak crosses midnight (e.g., 23:00 to 05:00)
return current_time >= self._offpeak_time or current_time < raz_time
else:
# Offpeak within same day (e.g., 01:00 to 05:00)
return self._offpeak_time <= current_time < raz_time

return False

@property
def should_be_forced_offpeak(self) -> bool:
"""True is we are offpeak and the max_on_time is not exceeded"""
if not self.check_usable(False) or self._offpeak_time is None:
"""True if we are offpeak and the max_on_time is not exceeded"""
if not self.check_usable(False):
return False

if self._offpeak_time >= self._coordinator.raz_time:
return (
(self.now.time() >= self._offpeak_time or self.now.time() < self._coordinator.raz_time)
and self._on_time_sec < self.max_on_time_per_day_sec
and self._on_time_sec < self.min_on_time_per_day_sec
)
else:
return (
self.now.time() >= self._offpeak_time
and self.now.time() < self._coordinator.raz_time
and self._on_time_sec < self.max_on_time_per_day_sec
and self._on_time_sec < self.min_on_time_per_day_sec
)
# Check if any offpeak configuration exists
has_offpeak_config = (
self._offpeak_time is not None
or len(self._offpeak_time_ranges) > 0
or self._offpeak_entity_id is not None
)
if not has_offpeak_config:
return False

return (
self._is_currently_offpeak()
and self._on_time_sec < self.max_on_time_per_day_sec
and self._on_time_sec < self.min_on_time_per_day_sec
)

@property
def is_waiting(self):
Expand Down Expand Up @@ -577,10 +635,25 @@ def min_on_time_per_day_sec(self) -> int:
return get_template_or_value(self._hass, self._min_on_time_per_day_min) * 60

@property
def offpeak_time(self) -> int:
"""The offpeak_time configured"""
def offpeak_time(self) -> time:
"""The offpeak_time configured (legacy single time)"""
return self._offpeak_time

@property
def offpeak_time_ranges(self) -> list:
"""The offpeak time ranges configured as list of (start, end) tuples"""
return self._offpeak_time_ranges

@property
def offpeak_entity_id(self) -> str:
"""The entity ID used for off-peak detection"""
return self._offpeak_entity_id

@property
def is_offpeak(self) -> bool:
"""Returns True if currently in off-peak period"""
return self._is_currently_offpeak()

@property
def battery_soc(self) -> int:
"""The battery soc"""
Expand Down
24 changes: 16 additions & 8 deletions custom_components/solar_optimizer/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@
"battery_soc_threshold": "Battery soc threshold",
"max_on_time_per_day_min": "Max on time per day",
"min_on_time_per_day_min": "Min on time per day",
"offpeak_time": "Offpeak time"
"offpeak_time": "Offpeak time ranges",
"offpeak_entity_id": "Offpeak sensor entity"
},
"data_description": {
"name": "The name of the device",
Expand All @@ -76,7 +77,8 @@
"battery_soc_threshold": "The battery state of charge threshold to activate the device. Can be a number or a template",
"max_on_time_per_day_min": "The maximum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time until max time is reached",
"min_on_time_per_day_min": "The minimum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time",
"offpeak_time": "The offpeak time with format HH:MM"
"offpeak_time": "Off-peak time ranges. Format: HH:MM-HH:MM or multiple ranges separated by commas. Examples: '22:00-06:00' or '13:00-14:00,22:00-06:00'",
"offpeak_entity_id": "Entity (binary_sensor, input_boolean, sensor, or calendar) that indicates off-peak periods. When 'on', it's off-peak time. Alternative to offpeak_time"
}
},
"powered_device": {
Expand All @@ -102,7 +104,8 @@
"battery_soc_threshold": "Battery soc threshold",
"max_on_time_per_day_min": "Max on time per day",
"min_on_time_per_day_min": "Min on time per day",
"offpeak_time": "Offpeak time"
"offpeak_time": "Offpeak time ranges",
"offpeak_entity_id": "Offpeak sensor entity"
},
"data_description": {
"name": "The name of the device",
Expand All @@ -124,7 +127,8 @@
"battery_soc_threshold": "The battery state of charge threshold to activate the device. Can be a number or a template",
"max_on_time_per_day_min": "The maximum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time until max time is reached",
"min_on_time_per_day_min": "The minimum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time",
"offpeak_time": "The offpeak time with format HH:MM"
"offpeak_time": "Off-peak time ranges. Format: HH:MM-HH:MM or multiple ranges separated by commas. Examples: '22:00-06:00' or '13:00-14:00,22:00-06:00'",
"offpeak_entity_id": "Entity (binary_sensor, input_boolean, sensor, or calendar) that indicates off-peak periods. When 'on', it's off-peak time. Alternative to offpeak_time"
}
}
},
Expand Down Expand Up @@ -192,7 +196,8 @@
"battery_soc_threshold": "Battery soc threshold",
"max_on_time_per_day_min": "Max on time per day",
"min_on_time_per_day_min": "Min on time per day",
"offpeak_time": "Offpeak time"
"offpeak_time": "Offpeak time ranges",
"offpeak_entity_id": "Offpeak sensor entity"
},
"data_description": {
"name": "The name of the device",
Expand All @@ -208,7 +213,8 @@
"battery_soc_threshold": "The battery state of charge threshold to activate the device. Can be a number or a template",
"max_on_time_per_day_min": "The maximum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time until max time is reached",
"min_on_time_per_day_min": "The minimum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time",
"offpeak_time": "The offpeak time with format HH:MM"
"offpeak_time": "Off-peak time ranges. Format: HH:MM-HH:MM or multiple ranges separated by commas. Examples: '22:00-06:00' or '13:00-14:00,22:00-06:00'",
"offpeak_entity_id": "Entity (binary_sensor, input_boolean, sensor, or calendar) that indicates off-peak periods. When 'on', it's off-peak time. Alternative to offpeak_time"
}
},
"powered_device": {
Expand All @@ -234,7 +240,8 @@
"battery_soc_threshold": "Battery soc threshold",
"max_on_time_per_day_min": "Max on time per day",
"min_on_time_per_day_min": "Min on time per day",
"offpeak_time": "Offpeak time"
"offpeak_time": "Offpeak time ranges",
"offpeak_entity_id": "Offpeak sensor entity"
},
"data_description": {
"name": "The name of the device",
Expand All @@ -256,7 +263,8 @@
"battery_soc_threshold": "The battery state of charge threshold to activate the device. Can be a number or a template",
"max_on_time_per_day_min": "The maximum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time until max time is reached",
"min_on_time_per_day_min": "The minimum time per day in state on in minutes. Can be a number or a template. If minium time is not reached during daylight, it will be activated during offpeak time",
"offpeak_time": "The offpeak time with format HH:MM"
"offpeak_time": "Off-peak time ranges. Format: HH:MM-HH:MM or multiple ranges separated by commas. Examples: '22:00-06:00' or '13:00-14:00,22:00-06:00'",
"offpeak_entity_id": "Entity (binary_sensor, input_boolean, sensor, or calendar) that indicates off-peak periods. When 'on', it's off-peak time. Alternative to offpeak_time"
}
}
},
Expand Down
Loading