feat(services): add find_best_start and plan_charging services

Add two new service actions for intelligent device scheduling:

- find_best_start: Find optimal start time for run-once appliances
  - Considers price, optional energy estimates, optional PV power
  - Supports flexible time windows (HH:MM, ISO datetime, with/without timezone)
  - Prefers future candidates over past ones
  - Includes current interval by default (configurable)
  - Returns recommended start time with cost analysis

- plan_charging: Create optimized charging schedule for energy storage
  - Supports EV, home battery, balcony battery use cases
  - Energy target or duration-based planning
  - Split or continuous charging modes
  - Efficiency factor support
  - Includes current interval by default (configurable)
  - Returns detailed slot-by-slot charging plan

Common improvements:
- Flexible datetime parsing (ISO 8601, with/without timezone, microseconds)
- Time selector in GUI (better UX than text field)
- Currency display based on config entry settings
- Comprehensive error handling and validation
- Detailed response envelopes with warnings/errors

Impact: Users can automate appliance scheduling based on electricity
prices without external automation rules.
This commit is contained in:
Julian Pawlowski 2026-01-20 11:47:26 +00:00
parent 4ceff6cf5f
commit 95950f48c1
12 changed files with 2063 additions and 38 deletions

View file

@ -20,6 +20,28 @@
},
"refresh_user_data": {
"service": "mdi:refresh"
},
"find_best_start": {
"service": "mdi:clock-start",
"sections": {
"window": "mdi:calendar-range",
"job": "mdi:washing-machine",
"costing": "mdi:cash-multiple",
"pv": "mdi:solar-power",
"preferences": "mdi:tune"
}
},
"plan_charging": {
"service": "mdi:battery-charging",
"sections": {
"window": "mdi:calendar-range",
"charge": "mdi:ev-station",
"pv": "mdi:solar-power",
"preferences": "mdi:tune"
}
},
"debug_clear_tomorrow": {
"service": "mdi:bug"
}
}
}

View file

@ -1,7 +1,7 @@
get_price:
fields:
entry_id:
required: true
required: false
example: "1234567890abcdef"
selector:
config_entry:
@ -19,7 +19,7 @@ get_price:
get_apexcharts_yaml:
fields:
entry_id:
required: true
required: false
example: "1234567890abcdef"
selector:
config_entry:
@ -63,7 +63,7 @@ get_chartdata:
general:
fields:
entry_id:
required: true
required: false
example: "1234567890abcdef"
selector:
config_entry:
@ -246,11 +246,232 @@ get_chartdata:
refresh_user_data:
fields:
entry_id:
required: true
required: false
example: "1234567890abcdef"
selector:
config_entry:
integration: tibber_prices
find_best_start:
fields:
entry_id:
required: false
example: "1234567890abcdef"
selector:
config_entry:
integration: tibber_prices
window:
collapsed: false
fields:
start:
required: false
example: "14:00"
selector:
time:
end:
required: false
example: "23:00"
selector:
time:
horizon_hours:
required: false
default: 36
selector:
number:
min: 1
max: 72
mode: box
unit_of_measurement: h
job:
collapsed: false
fields:
duration_minutes:
required: true
example: 120
selector:
number:
min: 15
max: 1440
step: 15
mode: box
unit_of_measurement: min
rounding:
required: false
default: ceil
selector:
select:
options:
- nearest
- floor
- ceil
translation_key: rounding
costing:
collapsed: true
fields:
estimate_energy_kwh:
required: false
example: 1.2
selector:
number:
min: 0.01
max: 100
step: 0.01
mode: box
unit_of_measurement: kWh
estimate_avg_power_w:
required: false
example: 600
selector:
number:
min: 1
max: 50000
step: 1
mode: box
unit_of_measurement: W
pv:
collapsed: true
fields:
pv_entity_id:
required: false
selector:
entity:
domain: sensor
preferences:
collapsed: true
fields:
prefer_earlier_start_on_tie:
required: false
default: true
selector:
boolean:
include_current_interval:
required: false
default: true
selector:
boolean:
plan_charging:
fields:
entry_id:
required: false
example: "1234567890abcdef"
selector:
config_entry:
integration: tibber_prices
window:
collapsed: false
fields:
start:
required: false
example: "00:00"
selector:
time:
end:
required: false
example: "06:00"
selector:
time:
horizon_hours:
required: false
default: 36
selector:
number:
min: 1
max: 72
mode: box
unit_of_measurement: h
charge:
collapsed: false
fields:
energy_target_kwh:
required: false
example: 2.0
selector:
number:
min: 0.01
max: 200
step: 0.01
mode: box
unit_of_measurement: kWh
duration_minutes:
required: false
example: 120
selector:
number:
min: 15
max: 1440
step: 15
mode: box
unit_of_measurement: min
max_power_w:
required: true
example: 1200
selector:
number:
min: 1
max: 50000
step: 1
mode: box
unit_of_measurement: W
min_power_w:
required: false
default: 0
selector:
number:
min: 0
max: 50000
step: 1
mode: box
unit_of_measurement: W
allow_split:
required: false
default: false
selector:
boolean:
rounding:
required: false
default: ceil
selector:
select:
options:
- nearest
- floor
- ceil
translation_key: rounding
efficiency:
required: false
default: 1.0
example: 0.92
selector:
number:
min: 0.01
max: 1.0
step: 0.01
mode: box
pv:
collapsed: true
fields:
pv_entity_id:
required: false
selector:
entity:
domain: sensor
preferences:
collapsed: true
fields:
prefer_fewer_splits:
required: false
default: true
selector:
boolean:
prefer_earlier_completion:
required: false
default: true
selector:
boolean:
include_current_interval:
required: false
default: true
selector:
boolean:
debug_clear_tomorrow:
fields:

View file

@ -5,14 +5,19 @@ This package provides service endpoints for external integrations and data expor
- Chart data export (get_chartdata)
- ApexCharts YAML generation (get_apexcharts_yaml)
- User data refresh (refresh_user_data)
- Find best start time (find_best_start) - Planning for run-once devices
- Plan charging (plan_charging) - Charging plan for energy storage
- Debug: Clear tomorrow data (debug_clear_tomorrow) - DevContainer only
Architecture:
- helpers.py: Common utilities (get_entry_and_data)
- common.py: Shared planning utilities (window parsing, response envelope)
- formatters.py: Data transformation and formatting functions
- chartdata.py: Main data export service handler
- apexcharts.py: ApexCharts card YAML generator
- refresh_user_data.py: User data refresh handler
- find_best_start.py: Best start time finder for appliances
- plan_charging.py: Charging plan generator for batteries/EVs
- debug_clear_tomorrow.py: Debug tool for testing tomorrow refresh (dev only)
"""
@ -25,6 +30,11 @@ from typing import TYPE_CHECKING
from custom_components.tibber_prices.const import DOMAIN
from homeassistant.core import SupportsResponse, callback
from .find_best_start import (
FIND_BEST_START_SERVICE_NAME,
FIND_BEST_START_SERVICE_SCHEMA,
handle_find_best_start,
)
from .get_apexcharts_yaml import (
APEXCHARTS_SERVICE_SCHEMA,
APEXCHARTS_YAML_SERVICE_NAME,
@ -32,6 +42,11 @@ from .get_apexcharts_yaml import (
)
from .get_chartdata import CHARTDATA_SERVICE_NAME, CHARTDATA_SERVICE_SCHEMA, handle_chartdata
from .get_price import GET_PRICE_SERVICE_NAME, GET_PRICE_SERVICE_SCHEMA, handle_get_price
from .plan_charging import (
PLAN_CHARGING_SERVICE_NAME,
PLAN_CHARGING_SERVICE_SCHEMA,
handle_plan_charging,
)
from .refresh_user_data import (
REFRESH_USER_DATA_SERVICE_NAME,
REFRESH_USER_DATA_SERVICE_SCHEMA,
@ -80,6 +95,20 @@ def async_setup_services(hass: HomeAssistant) -> None:
schema=REFRESH_USER_DATA_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
FIND_BEST_START_SERVICE_NAME,
handle_find_best_start,
schema=FIND_BEST_START_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
PLAN_CHARGING_SERVICE_NAME,
handle_plan_charging,
schema=PLAN_CHARGING_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
# Debug services - only available in DevContainer (TIBBER_PRICES_DEV=1)
if _IS_DEV_MODE:

View file

@ -0,0 +1,444 @@
"""
Common utilities for planning services (find_best_start, plan_charging).
This module provides shared functionality for:
- Response envelope building
- Window parsing (datetime vs HH:MM)
- Time rounding and normalization
- Currency formatting
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from custom_components.tibber_prices.const import get_currency_info
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
# API Version for response envelope
SERVICE_API_VERSION = "0.1"
DEFAULT_HORIZON_HOURS = 36 # Default horizon for planning services (hours)
RESOLUTION_MINUTES = 15 # Resolution in minutes (quarter-hourly)
RESOLUTION_HALF = 8 # Half the resolution for nearest rounding threshold (15/2 rounded up)
@dataclass
class ServiceResponse:
"""Response envelope for planning services."""
ok: bool = True
service: str = ""
version: str = SERVICE_API_VERSION
generated_at: str = ""
entry_id: str = ""
resolution_minutes: int = RESOLUTION_MINUTES
currency: str = "EUR"
currency_subunit: str = "ct"
window_start: str = ""
window_end: str = ""
warnings: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
result: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for service response."""
return {
"ok": self.ok,
"service": self.service,
"version": self.version,
"generated_at": self.generated_at,
"entry_id": self.entry_id,
"resolution_minutes": self.resolution_minutes,
"currency": self.currency,
"currency_subunit": self.currency_subunit,
"window": {
"start": self.window_start,
"end": self.window_end,
},
"warnings": self.warnings,
"errors": self.errors,
"result": self.result,
}
@dataclass
class ParsedWindow:
"""Parsed and validated time window."""
start: datetime
end: datetime
warnings: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def create_response_envelope(
service_name: str,
entry_id: str,
currency: str,
window_start: datetime,
window_end: datetime,
) -> ServiceResponse:
"""
Create a response envelope with common fields.
Args:
service_name: Full service name (e.g., "tibber_prices.find_best_start")
entry_id: Config entry ID
currency: Currency code (e.g., "EUR")
window_start: Start of time window
window_end: End of time window
Returns:
ServiceResponse with common fields populated
"""
_, subunit_symbol, _ = get_currency_info(currency)
return ServiceResponse(
service=service_name,
generated_at=dt_util.now().isoformat(),
entry_id=entry_id,
currency=currency,
currency_subunit=subunit_symbol,
window_start=window_start.isoformat(),
window_end=window_end.isoformat(),
)
def is_hhmm_format(time_str: str) -> bool:
"""
Check if string is in HH:MM or HH:MM:SS format (time-only, no date).
The time selector in HA services.yaml returns HH:MM:SS format (e.g., "14:00:00").
Args:
time_str: Time string to check
Returns:
True if HH:MM or HH:MM:SS format (with optional microseconds), False otherwise
"""
if not time_str:
return False
# Match HH:MM or HH:MM:SS format with optional microseconds
# Examples: "14:00", "14:00:00", "14:00:00.123456"
pattern = r"^([01]?[0-9]|2[0-3]):([0-5][0-9])(?::([0-5][0-9])(?:\.(\d+))?)?$"
return bool(re.match(pattern, time_str.strip()))
def parse_datetime_string(time_str: str) -> datetime | None:
"""
Parse a datetime string flexibly, accepting many common formats.
Supports:
- ISO 8601 with timezone: "2025-12-28T14:00:00+01:00"
- ISO 8601 with Z: "2025-12-28T14:00:00Z"
- ISO 8601 without timezone: "2025-12-28T14:00:00"
- With microseconds: "2025-12-28T14:00:00.123456+01:00"
- Date with space separator: "2025-12-28 14:00:00"
- Without seconds: "2025-12-28T14:00" or "2025-12-28 14:00"
Args:
time_str: Datetime string in various formats
Returns:
Parsed datetime (timezone-aware in HA timezone) or None if unparseable
"""
if not time_str:
return None
time_str = time_str.strip()
# Replace Z with +00:00 for UTC
if time_str.endswith("Z"):
time_str = time_str[:-1] + "+00:00"
# Replace space with T for ISO compatibility
# Handle "2025-12-28 14:00:00" format
if " " in time_str and "T" not in time_str:
time_str = time_str.replace(" ", "T", 1)
# Try parsing with fromisoformat (handles most ISO 8601 variants)
try:
parsed = datetime.fromisoformat(time_str)
except ValueError:
parsed = None
if parsed is not None:
# Add timezone if missing
if parsed.tzinfo is None:
parsed = dt_util.as_local(parsed)
return parsed
# Try additional formats that fromisoformat might not handle
# Format without seconds: "2025-12-28T14:00"
try:
if len(time_str) == 16 and time_str[10] == "T": # noqa: PLR2004
parsed = datetime.strptime(time_str, "%Y-%m-%dT%H:%M") # noqa: DTZ007
return dt_util.as_local(parsed)
except ValueError:
pass
return None
def parse_hhmm_to_datetime(time_str: str, reference_time: datetime) -> datetime:
"""
Parse HH:MM or HH:MM:SS string to datetime, finding next occurrence from reference.
Seconds are ignored as we round to 15-minute boundaries anyway.
Args:
time_str: Time in HH:MM or HH:MM:SS format
reference_time: Reference datetime (typically now)
Returns:
Timezone-aware datetime for next occurrence of this time
"""
parts = time_str.strip().split(":")
hour = int(parts[0])
minute = int(parts[1])
# Ignore seconds if present (parts[2])
# Create datetime for today with given time
candidate = reference_time.replace(hour=hour, minute=minute, second=0, microsecond=0)
# If candidate is in the past, move to next day
if candidate <= reference_time:
candidate += timedelta(days=1)
return candidate
def parse_window( # noqa: PLR0912, PLR0915
_hass: HomeAssistant,
start_input: str | datetime | None,
end_input: str | datetime | None,
horizon_hours: int = DEFAULT_HORIZON_HOURS,
duration_minutes: int | None = None,
) -> ParsedWindow:
"""
Parse window start/end with automatic format detection.
Supports:
- HH:MM or HH:MM:SS format (e.g., "14:00", "14:00:00") - time-only, next occurrence
- ISO datetime strings with/without timezone (e.g., "2025-12-28T14:00:00+01:00")
- ISO datetime with Z for UTC (e.g., "2025-12-28T14:00:00Z")
- Datetime with space separator (e.g., "2025-12-28 14:00:00")
- With or without microseconds
- datetime objects
Args:
hass: Home Assistant instance
start_input: Start time (HH:MM, ISO string, datetime, or None for now)
end_input: End time (HH:MM, ISO string, datetime, or None for horizon)
horizon_hours: Maximum look-ahead hours (default: 36)
duration_minutes: Optional duration - if provided and > window, window is extended
Returns:
ParsedWindow with start/end datetimes and any warnings/errors
"""
warnings: list[str] = []
errors: list[str] = []
now = dt_util.now()
horizon_end = now + timedelta(hours=horizon_hours)
# Parse start
start: datetime
start_is_hhmm = False
if start_input is None:
start = now
elif isinstance(start_input, datetime):
start = start_input
if start.tzinfo is None:
start = dt_util.as_local(start)
elif isinstance(start_input, str):
if is_hhmm_format(start_input):
start = parse_hhmm_to_datetime(start_input, now)
start_is_hhmm = True
else:
# Try to parse as datetime string (flexible format)
parsed = parse_datetime_string(start_input)
if parsed is not None:
start = parsed
else:
errors.append("invalid_parameters")
return ParsedWindow(start=now, end=horizon_end, errors=errors)
else:
errors.append("invalid_parameters")
return ParsedWindow(start=now, end=horizon_end, errors=errors)
# Parse end
end: datetime
end_is_hhmm = False
if end_input is None:
end = horizon_end
elif isinstance(end_input, datetime):
end = end_input
if end.tzinfo is None:
end = dt_util.as_local(end)
elif isinstance(end_input, str):
if is_hhmm_format(end_input):
end_is_hhmm = True
end_parts = end_input.strip().split(":")
end_hour = int(end_parts[0])
end_minute = int(end_parts[1])
# Create end datetime based on start
end = start.replace(hour=end_hour, minute=end_minute, second=0, microsecond=0)
# Handle midnight wrap logic for HH:MM
start_time_of_day = start.hour * 60 + start.minute
end_time_of_day = end_hour * 60 + end_minute
if end_time_of_day == start_time_of_day:
# Same time -> +24h
end += timedelta(days=1)
elif end_time_of_day < start_time_of_day:
# End is earlier in day -> wrap to next day
end += timedelta(days=1)
# else: end is later same day, keep as-is
else:
# Try to parse as datetime string (flexible format)
parsed = parse_datetime_string(end_input)
if parsed is not None:
end = parsed
else:
errors.append("invalid_parameters")
return ParsedWindow(start=start, end=horizon_end, errors=errors)
else:
errors.append("invalid_parameters")
return ParsedWindow(start=start, end=horizon_end, errors=errors)
# Validate: end < start only allowed for HH:MM (already handled above)
if end <= start and not (start_is_hhmm and end_is_hhmm):
errors.append("window_end_before_start")
return ParsedWindow(start=start, end=end, errors=errors)
# Check if window extends duration (if provided)
if duration_minutes is not None:
window_duration = (end - start).total_seconds() / 60
if duration_minutes > window_duration:
# Extend window to fit duration
extension_minutes = duration_minutes - window_duration
end = end + timedelta(minutes=extension_minutes)
warnings.append("window_extended_for_duration")
# Clamp end to horizon
if end > horizon_end:
end = horizon_end
warnings.append("window_end_clamped_to_horizon")
return ParsedWindow(start=start, end=end, warnings=warnings, errors=errors)
def round_to_quarter(dt: datetime, mode: str = "ceil") -> datetime:
"""
Round datetime to 15-minute boundary.
Args:
dt: Datetime to round
mode: "nearest", "floor", or "ceil"
Returns:
Rounded datetime
"""
minute = dt.minute
quarter = minute // 15
if mode == "floor":
new_minute = quarter * 15
elif mode == "ceil":
new_minute = minute if minute % 15 == 0 else (quarter + 1) * 15
else: # nearest
remainder = minute % 15
new_minute = quarter * 15 if remainder < RESOLUTION_HALF else (quarter + 1) * 15
# Handle hour overflow
hour_add = new_minute // 60
new_minute = new_minute % 60
result = dt.replace(minute=new_minute, second=0, microsecond=0)
if hour_add:
result += timedelta(hours=hour_add)
return result
def format_price(price: float, decimals: int = 4) -> float:
"""Round price to specified decimals."""
return round(price, decimals)
def price_to_subunit(price_eur: float) -> float:
"""Convert price from main unit to subunit (e.g., EUR to ct)."""
return round(price_eur * 100, 2)
def get_intervals_in_window(
all_intervals: list[dict[str, Any]],
window_start: datetime,
window_end: datetime,
) -> list[dict[str, Any]]:
"""
Filter intervals to those within the specified window.
Args:
all_intervals: List of all available price intervals
window_start: Start of window (inclusive)
window_end: End of window (exclusive)
Returns:
List of intervals within window
"""
result = []
for interval in all_intervals:
starts_at = interval.get("startsAt")
if not starts_at:
continue
# Parse interval start time
interval_start = datetime.fromisoformat(starts_at) if isinstance(starts_at, str) else starts_at
# Interval is within window if its start is >= window_start and < window_end
if window_start <= interval_start < window_end:
result.append(interval)
return result
def calculate_intervals_needed(duration_minutes: int, rounding: str = "ceil") -> int:
"""
Calculate number of 15-minute intervals needed for a duration.
Args:
duration_minutes: Duration in minutes
rounding: "nearest", "floor", or "ceil"
Returns:
Number of intervals
"""
exact = duration_minutes / RESOLUTION_MINUTES
if rounding == "floor":
return int(exact)
if rounding == "ceil":
return int(exact) if exact == int(exact) else int(exact) + 1
# nearest
return round(exact)

View file

@ -0,0 +1,479 @@
"""
Service handler for find_best_start service.
This service finds the optimal start time for run-once devices
(e.g., washing machine, dishwasher, dryer) within a time window.
The algorithm:
1. Generates all possible start times on 15-min boundaries
2. Scores each candidate by average price (or expected cost if energy estimate provided)
3. Returns the best candidate with lowest cost/price
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from custom_components.tibber_prices.const import (
DOMAIN,
get_display_unit_factor,
get_display_unit_string,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util
from .common import (
DEFAULT_HORIZON_HOURS,
RESOLUTION_MINUTES,
calculate_intervals_needed,
create_response_envelope,
get_intervals_in_window,
parse_window,
round_to_quarter,
)
from .helpers import get_entry_and_data
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
_LOGGER = logging.getLogger(__name__)
FIND_BEST_START_SERVICE_NAME = "find_best_start"
# Schema for find_best_start service - FLAT structure
# Note: services.yaml sections are UI-only groupings, HA sends data flat
FIND_BEST_START_SERVICE_SCHEMA = vol.Schema(
{
# General / entry_id (optional - auto-resolved if single entry)
vol.Optional("entry_id"): cv.string,
# Window section (UI grouping only)
vol.Optional("start"): cv.string,
vol.Optional("end"): cv.string,
vol.Optional("horizon_hours", default=DEFAULT_HORIZON_HOURS): vol.All(
vol.Coerce(int), vol.Range(min=1, max=72)
),
# Job section (UI grouping only)
vol.Required("duration_minutes"): vol.All(vol.Coerce(int), vol.Range(min=15, max=1440)),
vol.Optional("rounding", default="ceil"): vol.In(["nearest", "floor", "ceil"]),
# Costing section (UI grouping only)
vol.Optional("estimate_energy_kwh"): vol.All(vol.Coerce(float), vol.Range(min=0.01, max=100)),
vol.Optional("estimate_avg_power_w"): vol.All(vol.Coerce(float), vol.Range(min=1, max=50000)),
# PV section (UI grouping only) - reserved for future use
vol.Optional("pv_entity_id"): cv.entity_id,
# Preferences section (UI grouping only)
vol.Optional("prefer_earlier_start_on_tie", default=True): cv.boolean,
vol.Optional("include_current_interval", default=True): cv.boolean,
}
)
async def handle_find_best_start( # noqa: PLR0915
call: ServiceCall,
) -> ServiceResponse:
"""
Handle find_best_start service call.
Finds the optimal start time for a run-once device within a time window.
Args:
call: Service call with parameters
Returns:
Dict with recommended start time and scoring details
"""
hass: HomeAssistant = call.hass
entry_id: str | None = call.data.get("entry_id")
# Extract parameters (flat structure - HA sends all fields at top level)
# Window parameters
start = call.data.get("start")
end = call.data.get("end")
horizon_hours = call.data.get("horizon_hours", DEFAULT_HORIZON_HOURS)
# Job parameters
duration_minutes = call.data["duration_minutes"]
rounding = call.data.get("rounding", "ceil")
# Costing parameters
estimate_energy_kwh = call.data.get("estimate_energy_kwh")
estimate_avg_power_w = call.data.get("estimate_avg_power_w")
# PV parameters (reserved for future use)
_pv_entity_id = call.data.get("pv_entity_id")
# Preferences
prefer_earlier = call.data.get("prefer_earlier_start_on_tie", True)
include_current = call.data.get("include_current_interval", True)
# Derive energy from power if only power provided
if estimate_energy_kwh is None and estimate_avg_power_w is not None:
duration_hours = duration_minutes / 60
estimate_energy_kwh = (estimate_avg_power_w * duration_hours) / 1000
# Validate and get entry data (auto-resolves entry_id if single entry)
entry, _coordinator, data = get_entry_and_data(hass, entry_id)
resolved_entry_id = entry.entry_id # Use resolved entry_id
# Get currency from coordinator data
currency = data.get("currency", "EUR")
# Get currency display settings from config
display_factor = get_display_unit_factor(entry)
price_unit = get_display_unit_string(entry, currency)
# Parse window
parsed_window = parse_window(
hass,
start,
end,
horizon_hours=horizon_hours,
duration_minutes=duration_minutes,
)
# Create response envelope
response = create_response_envelope(
service_name=f"{DOMAIN}.{FIND_BEST_START_SERVICE_NAME}",
entry_id=resolved_entry_id,
currency=currency,
window_start=parsed_window.start,
window_end=parsed_window.end,
)
# Add any parsing warnings/errors
response.warnings.extend(parsed_window.warnings)
response.errors.extend(parsed_window.errors)
# If there were parsing errors, return early
if parsed_window.errors:
response.ok = False
return response.to_dict()
# Round window to quarter boundaries
# If include_current_interval is True, use floor to include the current interval
# (e.g., 14:05 -> 14:00), otherwise ceil to start at next interval (14:05 -> 14:15)
start_rounding = "floor" if include_current else "ceil"
window_start = round_to_quarter(parsed_window.start, start_rounding)
window_end = round_to_quarter(parsed_window.end, "floor")
# Update response with rounded window
response.window_start = window_start.isoformat()
response.window_end = window_end.isoformat()
# Get price intervals from coordinator
all_intervals = data.get("priceInfo", [])
if not all_intervals:
response.ok = False
response.errors.append("no_price_data_available_in_window")
return response.to_dict()
# Filter intervals to window
window_intervals = get_intervals_in_window(all_intervals, window_start, window_end)
if not window_intervals:
response.ok = False
response.errors.append("no_price_data_available_in_window")
return response.to_dict()
# Calculate number of intervals needed
intervals_needed = calculate_intervals_needed(duration_minutes, rounding)
# Get PV power if entity provided
pv_power_w = 0.0
if _pv_entity_id:
pv_state = hass.states.get(_pv_entity_id)
if pv_state and pv_state.state not in ("unknown", "unavailable"):
try:
pv_power_w = float(pv_state.state)
except (ValueError, TypeError):
response.warnings.append("pv_entity_unavailable_used_zero")
else:
response.warnings.append("pv_entity_unavailable_used_zero")
# Generate and score candidates
candidates = _generate_candidates(
window_intervals=window_intervals,
window_start=window_start,
window_end=window_end,
intervals_needed=intervals_needed,
duration_minutes=duration_minutes,
estimate_energy_kwh=estimate_energy_kwh,
pv_power_w=pv_power_w,
)
if not candidates:
response.ok = False
response.errors.append("no_price_data_available_in_window")
response.warnings.append("some_prices_missing_used_partial_window")
return response.to_dict()
# Sort and select best candidate (prefers future starts, closest to now if all past)
now = dt_util.now()
best_candidate = _select_best_candidate(candidates, prefer_earlier=prefer_earlier, now=now)
# Warn if recommended start is in the past
recommended_start: datetime = best_candidate["start"]
if recommended_start < now:
response.warnings.append("recommended_start_in_past")
# Build result
response.result = _build_result(
candidate=best_candidate,
total_candidates=len(candidates),
duration_minutes=duration_minutes,
display_factor=display_factor,
price_unit=price_unit,
)
return response.to_dict()
def _generate_candidates( # noqa: PLR0913
window_intervals: list[dict[str, Any]],
window_start: datetime,
window_end: datetime,
intervals_needed: int,
duration_minutes: int,
estimate_energy_kwh: float | None,
pv_power_w: float,
) -> list[dict[str, Any]]:
"""
Generate all possible start time candidates with scoring.
Args:
window_intervals: Available price intervals in window
window_start: Start of window
window_end: End of window
intervals_needed: Number of 15-min intervals for the job
duration_minutes: Job duration in minutes
estimate_energy_kwh: Optional energy estimate for cost calculation
pv_power_w: Current PV power in watts
Returns:
List of scored candidates
"""
candidates = []
# Build lookup for intervals by start time
interval_lookup: dict[str, dict[str, Any]] = {}
for interval in window_intervals:
starts_at = interval.get("startsAt", "")
if starts_at:
# Normalize to ISO string for lookup
key = starts_at.isoformat() if isinstance(starts_at, datetime) else starts_at
interval_lookup[key] = interval
# Generate candidates on 15-min boundaries
current_start = window_start
duration_delta = timedelta(minutes=duration_minutes)
while current_start + duration_delta <= window_end:
# Collect intervals for this candidate
candidate_intervals = []
slot_start = current_start
for i in range(intervals_needed):
slot_time = slot_start + timedelta(minutes=i * RESOLUTION_MINUTES)
slot_key = slot_time.isoformat()
# Also try without microseconds
slot_key_no_micro = slot_time.replace(microsecond=0).isoformat()
interval = interval_lookup.get(slot_key) or interval_lookup.get(slot_key_no_micro)
if interval:
candidate_intervals.append(interval)
# Only consider candidates with all intervals available
if len(candidate_intervals) == intervals_needed:
# Calculate average price
prices = [iv.get("total", 0) for iv in candidate_intervals]
avg_price = sum(prices) / len(prices) if prices else 0
# Calculate expected cost if energy estimate provided
expected_cost = None
expected_grid_kwh = None
if estimate_energy_kwh is not None:
# Distribute energy evenly across intervals
energy_per_interval = estimate_energy_kwh / intervals_needed
pv_kwh_per_interval = (pv_power_w / 1000) * (RESOLUTION_MINUTES / 60)
total_grid_kwh = 0
total_cost = 0
for interval in candidate_intervals:
price = interval.get("total", 0)
grid_kwh = max(energy_per_interval - pv_kwh_per_interval, 0)
total_grid_kwh += grid_kwh
total_cost += grid_kwh * price
expected_cost = total_cost
expected_grid_kwh = total_grid_kwh
candidate_end = current_start + duration_delta
candidates.append(
{
"start": current_start,
"end": candidate_end,
"intervals": candidate_intervals,
"avg_price": avg_price,
"expected_cost": expected_cost,
"expected_grid_kwh": expected_grid_kwh,
"expected_energy_kwh": estimate_energy_kwh,
}
)
# Move to next 15-min slot
current_start += timedelta(minutes=RESOLUTION_MINUTES)
return candidates
def _select_best_candidate(
candidates: list[dict[str, Any]],
*,
prefer_earlier: bool,
now: datetime | None = None,
) -> dict[str, Any]:
"""
Select the best candidate from scored list.
Selection priority:
1. Prefer candidates starting in the future over past candidates
2. Among future candidates: lowest cost/price wins
3. Among past candidates: closest to now wins (to minimize "too late" impact)
4. Tie-breaker: earlier or later start based on prefer_earlier
Args:
candidates: List of scored candidates
prefer_earlier: If True, earlier start wins ties
now: Current time (defaults to dt_util.now())
Returns:
Best candidate
"""
current_time = now if now is not None else dt_util.now()
# Separate future and past candidates
future_candidates = [c for c in candidates if c["start"] >= current_time]
past_candidates = [c for c in candidates if c["start"] < current_time]
# Sort by expected_cost (if available) or avg_price
# Secondary sort by start time for tie-breaking
def sort_key_by_price(c: dict[str, Any]) -> tuple[float, float]:
cost = c.get("expected_cost")
primary = cost if cost is not None else c["avg_price"]
# Use timestamp for tie-breaking
start: datetime = c["start"]
secondary = start.timestamp() if prefer_earlier else -start.timestamp()
return (primary, secondary)
# If we have future candidates, pick best by price among them
if future_candidates:
sorted_candidates = sorted(future_candidates, key=sort_key_by_price)
return sorted_candidates[0]
# All candidates are in the past - pick the one closest to now
# (minimizes how late the recommendation is)
def sort_key_closest_to_now(c: dict[str, Any]) -> tuple[float, float]:
start: datetime = c["start"]
# Primary: distance from now (smaller = better, so closest to now wins)
distance_from_now = abs((start - current_time).total_seconds())
# Secondary: still prefer lower price for ties
cost = c.get("expected_cost")
price = cost if cost is not None else c["avg_price"]
return (distance_from_now, price)
sorted_past = sorted(past_candidates, key=sort_key_closest_to_now)
return sorted_past[0]
def _build_result(
candidate: dict[str, Any],
total_candidates: int,
duration_minutes: int,
display_factor: int,
price_unit: str,
) -> dict[str, Any]:
"""
Build the result dictionary from best candidate.
Args:
candidate: Best candidate
total_candidates: Total number of candidates considered
duration_minutes: Job duration
display_factor: Currency display factor (1 for base, 100 for subunit)
price_unit: Currency unit string (e.g., 'ct/kWh' or '€/kWh')
Returns:
Result dictionary
"""
start: datetime = candidate["start"]
end: datetime = candidate["end"]
avg_price = candidate["avg_price"]
expected_cost = candidate.get("expected_cost")
expected_grid_kwh = candidate.get("expected_grid_kwh")
expected_energy_kwh = candidate.get("expected_energy_kwh")
intervals = candidate["intervals"]
# Build intervals list for response
response_intervals = []
for iv in intervals:
price = iv.get("total", 0)
response_intervals.append(
{
"start": iv.get("startsAt"),
"end": _calculate_interval_end(iv.get("startsAt")),
"price_per_kwh": round(price * display_factor, 4),
}
)
result: dict[str, Any] = {
"recommended_start": start.isoformat(),
"recommended_end": end.isoformat(),
"duration_minutes": duration_minutes,
"price_unit": price_unit,
"score": {
"avg_price_per_kwh": round(avg_price * display_factor, 4),
"rank": f"1/{total_candidates}",
"tie_breaker": "earlier_start",
},
"intervals": response_intervals,
"debug": {
"candidates_considered": total_candidates,
"missing_price_intervals": 0,
},
}
# Add cost info if available
if expected_cost is not None:
result["cost"] = {
"expected_energy_kwh": round(expected_energy_kwh, 4) if expected_energy_kwh else None,
"expected_grid_energy_kwh": round(expected_grid_kwh, 4) if expected_grid_kwh else None,
"expected_cost": round(expected_cost * display_factor, 4),
}
return result
def _calculate_interval_end(starts_at: str | None) -> str | None:
"""Calculate interval end time (start + 15 minutes)."""
if not starts_at:
return None
try:
start = datetime.fromisoformat(starts_at)
end = start + timedelta(minutes=RESOLUTION_MINUTES)
return end.isoformat()
except (ValueError, TypeError):
return None

View file

@ -25,7 +25,6 @@ import voluptuous as vol
from custom_components.tibber_prices.const import (
CONF_CURRENCY_DISPLAY_MODE,
DISPLAY_MODE_SUBUNIT,
DOMAIN,
PRICE_LEVEL_CHEAP,
PRICE_LEVEL_EXPENSIVE,
PRICE_LEVEL_NORMAL,
@ -37,7 +36,6 @@ from custom_components.tibber_prices.const import (
get_display_unit_string,
get_translation,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_registry import (
EntityRegistry,
@ -60,7 +58,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
# Service schema
APEXCHARTS_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTRY_ID): cv.string,
vol.Optional(ATTR_ENTRY_ID): cv.string,
vol.Optional("day"): vol.In(["yesterday", "today", "tomorrow", "rolling_window", "rolling_window_autozoom"]),
vol.Optional("level_type", default="rating_level"): vol.In(["rating_level", "level"]),
vol.Optional("highlight_best_price", default=True): cv.boolean,
@ -285,14 +283,11 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
Dictionary with ApexCharts card configuration
Raises:
ServiceValidationError: If entry_id is missing or invalid
ServiceValidationError: If entry_id cannot be resolved or is invalid
"""
hass = call.hass
entry_id_raw = call.data.get(ATTR_ENTRY_ID)
if entry_id_raw is None:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
entry_id: str = str(entry_id_raw)
entry_id = call.data.get(ATTR_ENTRY_ID) # Optional - auto-resolved if single entry
day = call.data.get("day") # Can be None (rolling window mode)
level_type = call.data.get("level_type", "rating_level")
@ -303,7 +298,9 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa:
user_language = hass.config.language or "en"
# Get coordinator to access price data (for currency) and config entry for display settings
# get_entry_and_data auto-resolves entry_id if only one entry exists
config_entry, coordinator, _ = get_entry_and_data(hass, entry_id)
entry_id = config_entry.entry_id # Use resolved entry_id for entity lookups
# Get currency from coordinator data
currency = coordinator.data.get("currency", "EUR")

View file

@ -53,6 +53,7 @@ from custom_components.tibber_prices.coordinator.helpers import (
get_intervals_for_day_offsets,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from .formatters import aggregate_hourly_exact, get_period_data, normalize_level_filter, normalize_rating_level_filter
from .helpers import get_entry_and_data, has_tomorrow_data
@ -265,7 +266,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
# Service schema
CHARTDATA_SERVICE_SCHEMA: Final = vol.Schema(
{
vol.Required(ATTR_ENTRY_ID): str,
vol.Optional(ATTR_ENTRY_ID): cv.string,
vol.Optional(ATTR_DAY): vol.All(vol.Coerce(list), [vol.In(["yesterday", "today", "tomorrow"])]),
vol.Optional("resolution", default="interval"): vol.In(["interval", "hourly"]),
vol.Optional("output_format", default="array_of_objects"): vol.In(["array_of_objects", "array_of_arrays"]),
@ -333,16 +334,14 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091
Dictionary with chart data in requested format
Raises:
ServiceValidationError: If entry_id is missing or invalid
ServiceValidationError: If entry_id cannot be resolved or is invalid
"""
hass = call.hass
entry_id_raw = call.data.get(ATTR_ENTRY_ID)
if entry_id_raw is None:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
entry_id: str = str(entry_id_raw)
entry_id = call.data.get(ATTR_ENTRY_ID) # Optional - auto-resolved if single entry
# Get coordinator to check data availability
# get_entry_and_data auto-resolves entry_id if only one entry exists
_, coordinator, _ = get_entry_and_data(hass, entry_id)
days_raw = call.data.get(ATTR_DAY)

View file

@ -41,7 +41,7 @@ GET_PRICE_SERVICE_NAME = "get_price"
GET_PRICE_SERVICE_SCHEMA = vol.Schema(
{
vol.Required("entry_id"): cv.string,
vol.Optional("entry_id"): cv.string,
vol.Required("start_time"): cv.datetime,
vol.Required("end_time"): cv.datetime,
}
@ -75,7 +75,7 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse:
"""
hass: HomeAssistant = call.hass
entry_id: str = call.data["entry_id"]
entry_id: str | None = call.data.get("entry_id")
start_time: datetime = call.data["start_time"]
end_time: datetime = call.data["end_time"]

View file

@ -5,12 +5,15 @@ This module provides common helper functions used across multiple service handle
such as entry validation and data extraction.
Functions:
resolve_entry_id: Auto-resolve entry_id when only one config entry exists
get_entry_and_data: Validate config entry and extract coordinator data
Used by:
- services/chartdata.py: Chart data export service
- services/apexcharts.py: ApexCharts YAML generation
- services/refresh_user_data.py: User data refresh
- services/find_best_start.py: Find best start time
- services/plan_charging.py: Plan charging
"""
@ -27,25 +30,69 @@ if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
def get_entry_and_data(hass: HomeAssistant, entry_id: str) -> tuple[Any, Any, dict]:
def resolve_entry_id(hass: HomeAssistant, entry_id: str | None) -> str:
"""
Validate entry and extract coordinator and data.
Resolve entry_id, auto-selecting if only one config entry exists.
This provides a user-friendly experience where entry_id is optional
when only a single Tibber home is configured.
Args:
hass: Home Assistant instance
entry_id: Config entry ID to validate
entry_id: Config entry ID (optional)
Returns:
Resolved entry_id string
Raises:
ServiceValidationError: If no entries exist or multiple entries exist without entry_id
"""
entries = hass.config_entries.async_entries(DOMAIN)
if not entries:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="no_entries_configured")
# If entry_id provided, use it (will be validated by get_entry_and_data)
if entry_id:
return entry_id
# Auto-select if only one entry exists
if len(entries) == 1:
return entries[0].entry_id
# Multiple entries: require explicit entry_id
# Build a helpful error message listing available entries
entry_list = ", ".join(f"{e.title} ({e.entry_id})" for e in entries)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="multiple_entries_require_entry_id",
translation_placeholders={"entries": entry_list},
)
def get_entry_and_data(hass: HomeAssistant, entry_id: str | None) -> tuple[Any, Any, dict]:
"""
Validate entry and extract coordinator and data.
If entry_id is None or empty, auto-resolves when only one entry exists.
Args:
hass: Home Assistant instance
entry_id: Config entry ID to validate (optional if single entry)
Returns:
Tuple of (entry, coordinator, data)
Raises:
ServiceValidationError: If entry_id is missing or invalid
ServiceValidationError: If entry_id cannot be resolved or is invalid
"""
if not entry_id:
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="missing_entry_id")
# Auto-resolve entry_id if not provided
resolved_entry_id = resolve_entry_id(hass, entry_id)
entry = next(
(e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id),
(e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == resolved_entry_id),
None,
)
if not entry or not hasattr(entry, "runtime_data") or not entry.runtime_data:

View file

@ -0,0 +1,627 @@
"""
Service handler for plan_charging service.
This service creates a charging plan for energy storage devices
(EV, house battery, balcony battery) within a time window.
The algorithm:
1. Calculates required intervals based on energy target or duration
2. Finds optimal intervals (contiguous or split based on allow_split)
3. Merges adjacent intervals into slots
4. Optionally reduces last slot power for precise energy targeting
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from custom_components.tibber_prices.const import (
DOMAIN,
get_display_unit_factor,
get_display_unit_string,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from .common import (
DEFAULT_HORIZON_HOURS,
RESOLUTION_MINUTES,
calculate_intervals_needed,
create_response_envelope,
get_intervals_in_window,
parse_window,
round_to_quarter,
)
from .helpers import get_entry_and_data
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
_LOGGER = logging.getLogger(__name__)
PLAN_CHARGING_SERVICE_NAME = "plan_charging"
# Schema for plan_charging service - FLAT structure
# Note: services.yaml sections are UI-only groupings, HA sends data flat
PLAN_CHARGING_SERVICE_SCHEMA = vol.Schema(
{
# General / entry_id (optional - auto-resolved if single entry)
vol.Optional("entry_id"): cv.string,
# Window section (UI grouping only)
vol.Optional("start"): cv.string,
vol.Optional("end"): cv.string,
vol.Optional("horizon_hours", default=DEFAULT_HORIZON_HOURS): vol.All(
vol.Coerce(int), vol.Range(min=1, max=72)
),
# Charge section (UI grouping only)
vol.Exclusive("energy_target_kwh", "charge_mode"): vol.All(vol.Coerce(float), vol.Range(min=0.01, max=200)),
vol.Exclusive("duration_minutes", "charge_mode"): vol.All(vol.Coerce(int), vol.Range(min=15, max=1440)),
vol.Required("max_power_w"): vol.All(vol.Coerce(float), vol.Range(min=1, max=50000)),
vol.Optional("min_power_w", default=0): vol.All(vol.Coerce(float), vol.Range(min=0, max=50000)),
vol.Optional("allow_split", default=False): cv.boolean,
vol.Optional("rounding", default="ceil"): vol.In(["nearest", "floor", "ceil"]),
vol.Optional("efficiency", default=1.0): vol.All(vol.Coerce(float), vol.Range(min=0.01, max=1.0)),
# PV section (UI grouping only) - reserved for future use
vol.Optional("pv_entity_id"): cv.entity_id,
# Preferences section (UI grouping only)
vol.Optional("prefer_fewer_splits", default=True): cv.boolean,
vol.Optional("prefer_earlier_completion", default=True): cv.boolean,
vol.Optional("include_current_interval", default=True): cv.boolean,
}
)
async def handle_plan_charging( # noqa: PLR0912, PLR0915
call: ServiceCall,
) -> ServiceResponse:
"""
Handle plan_charging service call.
Creates a charging plan for energy storage within a time window.
Args:
call: Service call with parameters
Returns:
Dict with charging plan including slots, energy, and cost details
"""
hass: HomeAssistant = call.hass
entry_id: str | None = call.data.get("entry_id")
# Extract parameters (flat structure - HA sends all fields at top level)
# Window parameters
start = call.data.get("start")
end = call.data.get("end")
horizon_hours = call.data.get("horizon_hours", DEFAULT_HORIZON_HOURS)
# Charge parameters
energy_target_kwh = call.data.get("energy_target_kwh")
duration_minutes = call.data.get("duration_minutes")
max_power_w = call.data["max_power_w"]
min_power_w = call.data.get("min_power_w", 0)
allow_split = call.data.get("allow_split", False)
rounding = call.data.get("rounding", "ceil")
efficiency = call.data.get("efficiency", 1.0)
# PV parameters (reserved for future use)
_pv_entity_id = call.data.get("pv_entity_id")
# Preferences
_prefer_fewer_splits = call.data.get("prefer_fewer_splits", True)
prefer_earlier = call.data.get("prefer_earlier_completion", True)
include_current = call.data.get("include_current_interval", True)
# Validate: exactly one of energy_target_kwh or duration_minutes must be set
if energy_target_kwh is None and duration_minutes is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_parameters",
)
# Validate min_power <= max_power
if min_power_w > max_power_w:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_parameters",
)
# Validate and get entry data (auto-resolves entry_id if single entry)
entry, _coordinator, data = get_entry_and_data(hass, entry_id)
resolved_entry_id = entry.entry_id # Use resolved entry_id
# Get currency from coordinator data
currency = data.get("currency", "EUR")
# Get currency display settings from config
display_factor = get_display_unit_factor(entry)
price_unit = get_display_unit_string(entry, currency)
# Calculate duration if energy target provided
if energy_target_kwh is not None:
# Calculate needed charge energy (accounting for efficiency)
needed_charge_kwh = energy_target_kwh / efficiency
# Energy per interval at max power
interval_kwh = (max_power_w / 1000) * (RESOLUTION_MINUTES / 60)
# Intervals needed
intervals_needed = calculate_intervals_needed(
int((needed_charge_kwh / interval_kwh) * RESOLUTION_MINUTES),
rounding,
)
# Recalculate duration from intervals
effective_duration_minutes = intervals_needed * RESOLUTION_MINUTES
else:
# Type guard: We already validated that at least one of energy_target_kwh or duration_minutes is set
# This assert satisfies the type checker
assert duration_minutes is not None # noqa: S101
intervals_needed = calculate_intervals_needed(duration_minutes, rounding)
effective_duration_minutes = duration_minutes
needed_charge_kwh = None
# Parse window
parsed_window = parse_window(
hass,
start,
end,
horizon_hours=horizon_hours,
duration_minutes=effective_duration_minutes,
)
# Create response envelope
response = create_response_envelope(
service_name=f"{DOMAIN}.{PLAN_CHARGING_SERVICE_NAME}",
entry_id=resolved_entry_id,
currency=currency,
window_start=parsed_window.start,
window_end=parsed_window.end,
)
# Add any parsing warnings/errors
response.warnings.extend(parsed_window.warnings)
response.errors.extend(parsed_window.errors)
# If there were parsing errors, return early
if parsed_window.errors:
response.ok = False
return response.to_dict()
# Round window to quarter boundaries
# If include_current_interval is True, use floor to include the current interval
# (e.g., 14:05 -> 14:00), otherwise ceil to start at next interval (14:05 -> 14:15)
start_rounding = "floor" if include_current else "ceil"
window_start = round_to_quarter(parsed_window.start, start_rounding)
window_end = round_to_quarter(parsed_window.end, "floor")
# Update response with rounded window
response.window_start = window_start.isoformat()
response.window_end = window_end.isoformat()
# Get price intervals from coordinator
all_intervals = data.get("priceInfo", [])
if not all_intervals:
response.ok = False
response.errors.append("no_price_data_available_in_window")
return response.to_dict()
# Filter intervals to window
window_intervals = get_intervals_in_window(all_intervals, window_start, window_end)
if not window_intervals:
response.ok = False
response.errors.append("no_price_data_available_in_window")
return response.to_dict()
# Check if we have enough intervals
if len(window_intervals) < intervals_needed:
response.warnings.append("some_prices_missing_used_partial_window")
# Adjust intervals_needed to what's available
intervals_needed = len(window_intervals)
# Get PV power if entity provided
pv_power_w = 0.0
pv_source = "none"
if _pv_entity_id:
pv_state = hass.states.get(_pv_entity_id)
if pv_state and pv_state.state not in ("unknown", "unavailable"):
try:
pv_power_w = float(pv_state.state)
pv_source = _pv_entity_id
except (ValueError, TypeError):
response.warnings.append("pv_entity_unavailable_used_zero")
else:
response.warnings.append("pv_entity_unavailable_used_zero")
# Select intervals based on allow_split
if allow_split:
selected_intervals = _select_cheapest_intervals(
window_intervals, intervals_needed, prefer_earlier=prefer_earlier
)
else:
selected_intervals = _find_best_contiguous_block(
window_intervals, intervals_needed, prefer_earlier=prefer_earlier
)
if not selected_intervals:
response.ok = False
response.errors.append("no_price_data_available_in_window")
return response.to_dict()
# Merge adjacent intervals into slots
slots = _merge_intervals_to_slots(
selected_intervals,
max_power_w,
min_power_w,
pv_power_w,
energy_target_kwh,
efficiency,
)
# Build result
response.result = _build_result(
slots=slots,
selected_intervals=selected_intervals,
energy_target_kwh=energy_target_kwh,
_needed_charge_kwh=needed_charge_kwh,
efficiency=efficiency,
pv_source=pv_source,
display_factor=display_factor,
price_unit=price_unit,
)
return response.to_dict()
def _select_cheapest_intervals(
intervals: list[dict[str, Any]],
count: int,
*,
prefer_earlier: bool,
) -> list[dict[str, Any]]:
"""
Select the N cheapest intervals (for allow_split=true).
Args:
intervals: Available intervals
count: Number to select
prefer_earlier: Prefer earlier intervals on tie
Returns:
Selected intervals sorted by time
"""
# Sort by price, then by time for tie-breaking
def sort_key(iv: dict[str, Any]) -> tuple[float, float]:
price = iv.get("total", 0)
starts_at = iv.get("startsAt", "")
if isinstance(starts_at, str):
try:
ts = datetime.fromisoformat(starts_at).timestamp()
except ValueError:
ts = 0
else:
ts = starts_at.timestamp() if starts_at else 0
return (price, ts if prefer_earlier else -ts)
sorted_intervals = sorted(intervals, key=sort_key)
selected = sorted_intervals[:count]
# Re-sort by time for output
def time_key(iv: dict[str, Any]) -> float:
starts_at = iv.get("startsAt", "")
if isinstance(starts_at, str):
try:
return datetime.fromisoformat(starts_at).timestamp()
except ValueError:
return 0
return starts_at.timestamp() if starts_at else 0
return sorted(selected, key=time_key)
def _find_best_contiguous_block( # noqa: PLR0912
intervals: list[dict[str, Any]],
count: int,
*,
prefer_earlier: bool,
) -> list[dict[str, Any]]:
"""
Find the best contiguous block of N intervals (for allow_split=false).
Args:
intervals: Available intervals (should be sorted by time)
count: Number of contiguous intervals needed
prefer_earlier: Prefer earlier block on tie
Returns:
Best contiguous block of intervals
"""
if len(intervals) < count:
return []
# Sort intervals by time
def time_key(iv: dict[str, Any]) -> float:
starts_at = iv.get("startsAt", "")
if isinstance(starts_at, str):
try:
return datetime.fromisoformat(starts_at).timestamp()
except ValueError:
return 0
return starts_at.timestamp() if starts_at else 0
sorted_intervals = sorted(intervals, key=time_key)
# Find all contiguous blocks
blocks: list[list[dict[str, Any]]] = []
current_block: list[dict[str, Any]] = []
for i, interval in enumerate(sorted_intervals):
if not current_block:
current_block = [interval]
else:
# Check if this interval is contiguous with previous
prev_start = sorted_intervals[i - 1].get("startsAt", "")
curr_start = interval.get("startsAt", "")
prev_dt = datetime.fromisoformat(prev_start) if isinstance(prev_start, str) else prev_start
curr_dt = datetime.fromisoformat(curr_start) if isinstance(curr_start, str) else curr_start
expected_next = prev_dt + timedelta(minutes=RESOLUTION_MINUTES)
if curr_dt == expected_next:
current_block.append(interval)
else:
# Gap found, start new block
if len(current_block) >= count:
blocks.append(current_block)
current_block = [interval]
# Don't forget the last block
if len(current_block) >= count:
blocks.append(current_block)
if not blocks:
return []
# For each block, find the best sub-block of exactly 'count' intervals
best_candidates: list[tuple[float, float, list[dict[str, Any]]]] = []
for block in blocks:
for i in range(len(block) - count + 1):
sub_block = block[i : i + count]
avg_price = sum(iv.get("total", 0) for iv in sub_block) / count
# Get start time for tie-breaking
first_start = sub_block[0].get("startsAt", "")
if isinstance(first_start, str):
try:
ts = datetime.fromisoformat(first_start).timestamp()
except ValueError:
ts = 0
else:
ts = first_start.timestamp() if first_start else 0
best_candidates.append((avg_price, ts if prefer_earlier else -ts, sub_block))
# Sort by price, then by time
best_candidates.sort(key=lambda x: (x[0], x[1]))
return best_candidates[0][2] if best_candidates else []
def _merge_intervals_to_slots( # noqa: PLR0913
intervals: list[dict[str, Any]],
max_power_w: float,
min_power_w: float,
pv_power_w: float,
energy_target_kwh: float | None,
efficiency: float,
) -> list[dict[str, Any]]:
"""
Merge adjacent intervals into charging slots.
Args:
intervals: Selected intervals (sorted by time)
max_power_w: Maximum charging power
min_power_w: Minimum charging power
pv_power_w: Current PV power
energy_target_kwh: Target energy (if set)
efficiency: Charging efficiency
Returns:
List of slots with merged intervals
"""
if not intervals:
return []
slots: list[dict[str, Any]] = []
current_slot_intervals: list[dict[str, Any]] = []
for _i, interval in enumerate(intervals):
if not current_slot_intervals:
current_slot_intervals = [interval]
else:
# Check if contiguous
prev_start = current_slot_intervals[-1].get("startsAt", "")
curr_start = interval.get("startsAt", "")
prev_dt = datetime.fromisoformat(prev_start) if isinstance(prev_start, str) else prev_start
curr_dt = datetime.fromisoformat(curr_start) if isinstance(curr_start, str) else curr_start
expected_next = prev_dt + timedelta(minutes=RESOLUTION_MINUTES)
if curr_dt == expected_next:
current_slot_intervals.append(interval)
else:
# Gap found, finalize current slot
slots.append(_create_slot(current_slot_intervals, max_power_w, pv_power_w))
current_slot_intervals = [interval]
# Finalize last slot
if current_slot_intervals:
slots.append(_create_slot(current_slot_intervals, max_power_w, pv_power_w))
# Adjust last slot power for energy target if needed
if energy_target_kwh is not None and slots:
_adjust_last_slot_power(slots, energy_target_kwh, efficiency, max_power_w, min_power_w)
return slots
def _create_slot(
intervals: list[dict[str, Any]],
power_w: float,
pv_power_w: float,
) -> dict[str, Any]:
"""Create a slot from a list of contiguous intervals."""
first_start = intervals[0].get("startsAt", "")
last_start = intervals[-1].get("startsAt", "")
# Calculate end time (last interval start + 15 min)
last_dt = datetime.fromisoformat(last_start) if isinstance(last_start, str) else last_start
end_dt = last_dt + timedelta(minutes=RESOLUTION_MINUTES)
# Calculate average price (raw, in base currency)
prices = [iv.get("total", 0) for iv in intervals]
avg_price = sum(prices) / len(prices) if prices else 0
# Calculate expected grid power (reduced by PV)
expected_grid_w = max(power_w - pv_power_w, 0)
return {
"start": first_start if isinstance(first_start, str) else first_start.isoformat(),
"end": end_dt.isoformat(),
"duration_minutes": len(intervals) * RESOLUTION_MINUTES,
"intervals": len(intervals),
"target_power_w": power_w,
"expected_pv_w": pv_power_w,
"expected_grid_w": expected_grid_w,
"_avg_price": avg_price, # Raw price for later formatting
"_interval_prices": prices, # Keep for calculations, remove later
}
def _adjust_last_slot_power(
slots: list[dict[str, Any]],
energy_target_kwh: float,
efficiency: float,
max_power_w: float,
min_power_w: float,
) -> None:
"""Adjust last slot power to achieve precise energy target."""
# Calculate total energy from all slots except last
total_energy = 0
for slot in slots[:-1]:
slot_hours = slot["duration_minutes"] / 60
slot_energy = (slot["target_power_w"] / 1000) * slot_hours * efficiency
total_energy += slot_energy
# Calculate remaining energy needed
remaining_kwh = energy_target_kwh - total_energy
if remaining_kwh <= 0:
# Already have enough, remove last slot
slots.pop()
return
# Calculate needed power for last slot
last_slot = slots[-1]
last_slot_hours = last_slot["duration_minutes"] / 60
# Account for efficiency: needed_charge = remaining / efficiency
needed_charge_kwh = remaining_kwh / efficiency
needed_power_w = (needed_charge_kwh / last_slot_hours) * 1000
# Clamp to min/max
adjusted_power = max(min_power_w, min(max_power_w, needed_power_w))
# Update last slot
last_slot["target_power_w"] = round(adjusted_power, 0)
last_slot["expected_grid_w"] = max(adjusted_power - last_slot["expected_pv_w"], 0)
def _build_result( # noqa: PLR0913
slots: list[dict[str, Any]],
selected_intervals: list[dict[str, Any]],
energy_target_kwh: float | None,
_needed_charge_kwh: float | None, # Reserved for future use
efficiency: float,
pv_source: str,
display_factor: int,
price_unit: str,
) -> dict[str, Any]:
"""Build the result dictionary."""
# Clean up slots (remove internal fields) and add formatted prices
clean_slots = []
for slot in slots:
clean_slot = {k: v for k, v in slot.items() if not k.startswith("_")}
# Add formatted avg_price
avg_price = slot.get("_avg_price", 0)
clean_slot["avg_price_per_kwh"] = round(avg_price * display_factor, 4)
clean_slots.append(clean_slot)
# Calculate totals
total_charge_kwh = 0
total_grid_kwh = 0
total_cost = 0
all_prices = []
for slot in slots:
slot_hours = slot["duration_minutes"] / 60
slot_charge_kwh = (slot["target_power_w"] / 1000) * slot_hours
slot_grid_kwh = (slot["expected_grid_w"] / 1000) * slot_hours
total_charge_kwh += slot_charge_kwh
total_grid_kwh += slot_grid_kwh
# Calculate cost per interval in this slot
interval_prices = slot.get("_interval_prices", [])
interval_hours = RESOLUTION_MINUTES / 60
interval_kwh = (slot["expected_grid_w"] / 1000) * interval_hours
for price in interval_prices:
total_cost += interval_kwh * price
all_prices.append(price)
# Calculate average and worst price
avg_price = sum(all_prices) / len(all_prices) if all_prices else 0
worst_price = max(all_prices) if all_prices else 0
# Determine next slot start
next_slot_start = clean_slots[0]["start"] if clean_slots else None
result: dict[str, Any] = {
"plan": {
"mode": "CHARGE",
"slots": clean_slots,
"total_slots": len(clean_slots),
"next_slot_start": next_slot_start,
},
"price_unit": price_unit,
"energy": {
"target_kwh": energy_target_kwh,
"expected_charge_kwh": round(total_charge_kwh, 4),
"expected_grid_kwh": round(total_grid_kwh, 4),
"efficiency_applied": efficiency,
},
"cost": {
"expected_cost": round(total_cost * display_factor, 4),
"avg_price_per_kwh": round(avg_price * display_factor, 4),
"worst_price_per_kwh": round(worst_price * display_factor, 4),
},
"debug": {
"intervals_selected": len(selected_intervals),
"missing_price_intervals": 0,
"split_count": len(clean_slots),
"pv_source": pv_source,
},
}
return result

View file

@ -27,6 +27,7 @@ from custom_components.tibber_prices.api import (
TibberPricesApiClientError,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from .helpers import get_entry_and_data
@ -40,7 +41,7 @@ ATTR_ENTRY_ID: Final = "entry_id"
# Service schema
REFRESH_USER_DATA_SERVICE_SCHEMA: Final = vol.Schema(
{
vol.Required(ATTR_ENTRY_ID): str,
vol.Optional(ATTR_ENTRY_ID): cv.string,
}
)
@ -67,12 +68,6 @@ async def handle_refresh_user_data(call: ServiceCall) -> dict[str, Any]:
entry_id = call.data.get(ATTR_ENTRY_ID)
hass = call.hass
if not entry_id:
return {
"success": False,
"message": "Entry ID is required",
}
# Get the entry and coordinator
try:
_, coordinator, _ = get_entry_and_data(hass, entry_id)

View file

@ -63,7 +63,9 @@
"invalid_yaml_syntax": "Invalid YAML syntax. Please check indentation, colons, and special characters.",
"invalid_yaml_structure": "YAML must be a dictionary/object (key: value pairs), not a list or plain text.",
"service_call_failed": "Service call validation failed: {error_detail}",
"no_entries_configured": "No Tibber Prices entries configured. Please set up the integration first.",
"missing_entry_id": "Entry ID is required but was not provided.",
"multiple_entries_require_entry_id": "Multiple Tibber homes configured. Please specify which one to use with the entry_id parameter. Available entries: {entries}",
"invalid_entry_id": "Invalid entry ID or entry not found.",
"missing_home_id": "Home ID is missing from the configuration entry.",
"user_data_not_available": "User data is not available. Please refresh user data first.",
@ -891,7 +893,7 @@
"fields": {
"entry_id": {
"name": "Entry ID",
"description": "The config entry ID for the Tibber integration."
"description": "Optional if only one Tibber home is configured. Required when multiple homes are set up to specify which one to use."
},
"start_time": {
"name": "Start Time",
@ -909,7 +911,7 @@
"fields": {
"entry_id": {
"name": "Entry ID",
"description": "The config entry ID for the Tibber integration."
"description": "Optional if only one Tibber home is configured. Required when multiple homes are set up to specify which one to use."
},
"day": {
"name": "Day",
@ -961,7 +963,7 @@
"fields": {
"entry_id": {
"name": "Entry ID",
"description": "The config entry ID for the Tibber integration."
"description": "Optional if only one Tibber home is configured. Required when multiple homes are set up to specify which one to use."
},
"day": {
"name": "Day",
@ -1063,7 +1065,163 @@
"fields": {
"entry_id": {
"name": "Entry ID",
"description": "The config entry ID for the Tibber integration."
"description": "Optional if only one Tibber home is configured. Required when multiple homes are set up to specify which one to use."
}
}
},
"find_best_start": {
"name": "Find Best Start Time",
"description": "Finds the optimal start time for appliances that run once and cannot be interrupted (e.g., washing machine, dishwasher, dryer). Evaluates all possible start times within the window and returns the one with the lowest average electricity price or expected cost.",
"sections": {
"window": {
"name": "Time Window",
"description": "Define when the appliance can be started."
},
"job": {
"name": "Job Settings",
"description": "Configure the appliance's run duration."
},
"costing": {
"name": "Cost Estimation",
"description": "Optional: Provide energy consumption to calculate actual costs instead of just comparing prices."
},
"pv": {
"name": "Solar Power",
"description": "Optional: Account for solar generation to reduce grid import estimates."
},
"preferences": {
"name": "Preferences",
"description": "How to handle ties when multiple slots have the same price."
}
},
"fields": {
"entry_id": {
"name": "Entry ID",
"description": "Optional if only one Tibber home is configured. Required when multiple homes are set up to specify which one to use."
},
"start": {
"name": "Start Time",
"description": "Earliest allowed start time. Accepts HH:MM (e.g., '14:00') or ISO datetime. Defaults to now if not specified."
},
"end": {
"name": "End Time",
"description": "Latest time by which the appliance must finish. Accepts HH:MM (e.g., '23:00') or ISO datetime. If HH:MM is earlier than start, it's interpreted as the next day."
},
"horizon_hours": {
"name": "Horizon",
"description": "Maximum look-ahead in hours (1-72). Limits how far into the future to search. Default: 36 hours."
},
"duration_minutes": {
"name": "Duration",
"description": "How long the appliance runs in minutes (15-1440). For best results, use a multiple of 15."
},
"rounding": {
"name": "Rounding",
"description": "How to round duration to 15-minute intervals. 'ceil' (default): round up, 'floor': round down, 'nearest': round to nearest."
},
"estimate_energy_kwh": {
"name": "Estimated Energy",
"description": "Expected energy consumption in kWh. When provided, enables actual cost calculation instead of price-only comparison."
},
"estimate_avg_power_w": {
"name": "Estimated Power",
"description": "Alternative to energy: average power draw in Watts. Automatically converted to energy based on the duration."
},
"pv_entity_id": {
"name": "PV Power Entity",
"description": "Sensor entity providing current solar power output in Watts. Used to estimate reduced grid import."
},
"prefer_earlier_start_on_tie": {
"name": "Prefer Earlier Start",
"description": "When multiple time slots have identical costs, prefer starting earlier rather than later."
},
"include_current_interval": {
"name": "Include Current Interval",
"description": "Allow the currently running 15-minute interval as a potential start time. If disabled, only future intervals are considered. Enabling this may result in starting slightly late (up to 14 minutes into the interval) but can avoid waiting for a later slot."
}
}
},
"plan_charging": {
"name": "Plan Charging",
"description": "Creates an optimized charging schedule for energy storage systems (EV, home battery, balcony battery). Finds the cheapest intervals within the time window. Can either find one continuous charging block or split charging across multiple cheap periods.",
"sections": {
"window": {
"name": "Time Window",
"description": "Define when charging is allowed to occur."
},
"charge": {
"name": "Charging Parameters",
"description": "Configure power limits and energy targets."
},
"pv": {
"name": "Solar Power",
"description": "Optional: Account for solar generation to reduce grid import estimates."
},
"preferences": {
"name": "Preferences",
"description": "Fine-tune the charging schedule optimization."
}
},
"fields": {
"entry_id": {
"name": "Entry ID",
"description": "Optional if only one Tibber home is configured. Required when multiple homes are set up to specify which one to use."
},
"start": {
"name": "Start Time",
"description": "Earliest allowed charging start. Accepts HH:MM (e.g., '00:00') or ISO datetime. Defaults to now if not specified."
},
"end": {
"name": "End Time",
"description": "Time by which charging must be complete (ready by). Accepts HH:MM (e.g., '06:00') or ISO datetime. If HH:MM is earlier than start, it's interpreted as the next day."
},
"horizon_hours": {
"name": "Horizon",
"description": "Maximum look-ahead in hours (1-72). Default: 36 hours."
},
"energy_target_kwh": {
"name": "Energy Target",
"description": "Amount of energy to charge in kWh. Specify either this OR duration, not both."
},
"duration_minutes": {
"name": "Duration",
"description": "Fixed charging duration in minutes. Specify either this OR energy target, not both."
},
"max_power_w": {
"name": "Maximum Power",
"description": "Maximum charging power in Watts. Required."
},
"min_power_w": {
"name": "Minimum Power",
"description": "Minimum charging power in Watts. Used when throttling the final slot to hit an exact energy target. Default: 0."
},
"allow_split": {
"name": "Allow Split Charging",
"description": "When enabled, charging can be split across multiple non-contiguous cheap periods for better prices. When disabled (default), finds one continuous charging block."
},
"rounding": {
"name": "Rounding",
"description": "How to round duration to 15-minute intervals. 'ceil' (default): round up, 'floor': round down, 'nearest': round to nearest."
},
"efficiency": {
"name": "Efficiency",
"description": "Charging efficiency factor (0.01-1.0). Accounts for conversion losses: efficiency = energy_stored / grid_energy_drawn. Default: 1.0 (no losses)."
},
"pv_entity_id": {
"name": "PV Power Entity",
"description": "Sensor entity providing current solar power output in Watts. Used to estimate reduced grid import."
},
"prefer_fewer_splits": {
"name": "Prefer Fewer Splits",
"description": "When split charging is enabled, prefer fewer longer sessions over many short ones."
},
"prefer_earlier_completion": {
"name": "Prefer Earlier Completion",
"description": "When multiple schedules have the same total cost, prefer the one that finishes earlier."
},
"include_current_interval": {
"name": "Include Current Interval",
"description": "Allow the currently running 15-minute interval as a potential start time. If disabled, only future intervals are considered. Enabling this may result in starting slightly late (up to 14 minutes into the interval) but can avoid waiting for a later slot."
}
}
},
@ -1176,6 +1334,13 @@
"median": "Median",
"mean": "Arithmetic Mean"
}
},
"rounding": {
"options": {
"ceil": "Round Up (ceil)",
"floor": "Round Down (floor)",
"nearest": "Round to Nearest"
}
}
},
"title": "Tibber Price Information & Ratings"