mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
Compare commits
9 commits
91147bd79c
...
ee9adce9d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee9adce9d5 | ||
|
|
240acac00a | ||
|
|
33fa536198 | ||
|
|
1d065b11cd | ||
|
|
07788a57ea | ||
|
|
ccf1d6185d | ||
|
|
061b42b8f3 | ||
|
|
a4ad506e01 | ||
|
|
6d22ea7151 |
120 changed files with 1204 additions and 1019 deletions
|
|
@ -1803,12 +1803,14 @@ Public entry points → direct helpers (call order) → pure utilities. Prefix p
|
|||
|
||||
**Legacy/Backwards compatibility:**
|
||||
|
||||
- **Do NOT add legacy migration code** unless the change was already released in a version tag
|
||||
- **Check if released**: Use `./scripts/release/check-if-released <commit-hash>` to verify if code is in any `v*.*.*` tag
|
||||
- **Do NOT add legacy migration code** unless the change was already released in a **public version tag** (pre-release tags like `v1.0.0-rc.1` do NOT count)
|
||||
- **Uncommitted changes never need migration** — if the feature has never been committed, it has never reached any user
|
||||
- **New features never need migration** — if all commits introducing the feature are newer than the last public version tag, no user has ever seen the old behaviour
|
||||
- **Check if released**: Use `./scripts/release/check-if-released <commit-hash>` to verify if code is in any public `v*.*.*` tag (ignores pre-release tags)
|
||||
- **Example**: If introducing breaking config change in commit `abc123`, run `./scripts/release/check-if-released abc123`:
|
||||
- ✓ NOT RELEASED → No migration needed, just use new code
|
||||
- ✗ ALREADY RELEASED → Migration may be needed for users upgrading from that version
|
||||
- **Rule**: Only add backwards compatibility for changes that shipped to users via HACS/GitHub releases
|
||||
- **Rule**: Only add backwards compatibility for changes that shipped to users via HACS/GitHub releases under a public version tag
|
||||
- **Prefer breaking changes over complexity**: If migration code would be complex or clutter the codebase, prefer documenting the breaking change in release notes (Home Assistant style). Only add simple migrations (e.g., `.lower()` call, key rename) when trivial.
|
||||
|
||||
**Translation sync:** When updating `/translations/en.json`, update ALL language files (`de.json`, etc.) with same keys (placeholder values OK).
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import voluptuous as vol
|
|||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.loader import async_get_loaded_integration
|
||||
|
|
@ -25,6 +26,7 @@ from .const import (
|
|||
CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
|
||||
DATA_CHART_CONFIG,
|
||||
DATA_CHART_METADATA_CONFIG,
|
||||
DATA_STATISTICS_REVIEW_REQUIRED,
|
||||
DISPLAY_MODE_SUBUNIT,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
|
|
@ -212,6 +214,30 @@ def _get_access_token(hass: HomeAssistant, entry: ConfigEntry) -> str:
|
|||
raise ConfigEntryAuthFailed(msg)
|
||||
|
||||
|
||||
def _check_statistics_review_repair(hass: HomeAssistant, entry: TibberPricesConfigEntry) -> None:
|
||||
"""Re-create the statistics-review repair issue fresh on every setup when the flag is set.
|
||||
|
||||
Using delete + create (instead of get_or_create) resets dismissed_version, so the issue
|
||||
reappears in the Repairs panel even if the user had dismissed it before a restart.
|
||||
The flag is cleared from config_entry.data only when the user acknowledges the change
|
||||
by re-saving the currency display settings in the options flow.
|
||||
"""
|
||||
if not entry.data.get(DATA_STATISTICS_REVIEW_REQUIRED):
|
||||
return
|
||||
issue_id = f"currency_display_mode_changed_{entry.entry_id}"
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=False,
|
||||
is_persistent=True,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="currency_display_mode_changed",
|
||||
translation_placeholders={"home_name": entry.title},
|
||||
)
|
||||
|
||||
|
||||
# https://developers.home-assistant.io/docs/config_entries_index/#setting-up-an-entry
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
|
|
@ -226,6 +252,9 @@ async def async_setup_entry(
|
|||
# Check for entity migrations (renames, breaking changes) and create repairs
|
||||
check_entity_migrations(hass, entry)
|
||||
|
||||
# Re-create statistics review repair issue fresh (resets any previous dismiss)
|
||||
_check_statistics_review_repair(hass, entry)
|
||||
|
||||
# Preload translations to populate the cache
|
||||
await async_load_translations(hass, "en")
|
||||
await async_load_standard_translations(hass, "en")
|
||||
|
|
|
|||
|
|
@ -4,16 +4,16 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.util import dt as dt_utils
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .exceptions import (
|
||||
TibberPricesApiClientAuthenticationError,
|
||||
|
|
@ -21,12 +21,7 @@ from .exceptions import (
|
|||
TibberPricesApiClientError,
|
||||
TibberPricesApiClientPermissionError,
|
||||
)
|
||||
from .helpers import (
|
||||
flatten_price_info,
|
||||
prepare_headers,
|
||||
verify_graphql_response,
|
||||
verify_response_or_raise,
|
||||
)
|
||||
from .helpers import flatten_price_info, prepare_headers, verify_graphql_response, verify_response_or_raise
|
||||
from .queries import TibberPricesQueryType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -163,9 +158,7 @@ class TibberPricesApiClient:
|
|||
|
||||
"""
|
||||
# Import here to avoid circular dependency (interval_pool imports TibberPricesApiClient)
|
||||
from custom_components.tibber_prices.interval_pool import ( # noqa: PLC0415
|
||||
get_price_intervals_for_range,
|
||||
)
|
||||
from custom_components.tibber_prices.interval_pool import get_price_intervals_for_range # noqa: PLC0415
|
||||
|
||||
price_info = await get_price_intervals_for_range(
|
||||
api_client=self,
|
||||
|
|
@ -581,7 +574,7 @@ class TibberPricesApiClient:
|
|||
"""
|
||||
Calculate day before yesterday midnight in home's timezone.
|
||||
|
||||
CRITICAL: Uses REAL TIME (dt_utils.now()), NOT TimeService.now().
|
||||
CRITICAL: Uses REAL TIME (dt_util.now()), NOT TimeService.now().
|
||||
This ensures API boundary calculations are based on actual current time,
|
||||
not simulated time from TimeService.
|
||||
|
||||
|
|
@ -594,7 +587,7 @@ class TibberPricesApiClient:
|
|||
|
||||
"""
|
||||
# Get current REAL time (not TimeService)
|
||||
now = dt_utils.now()
|
||||
now = dt_util.now()
|
||||
|
||||
# Convert to home's timezone or fallback to HA system timezone
|
||||
if home_timezone:
|
||||
|
|
@ -607,10 +600,10 @@ class TibberPricesApiClient:
|
|||
home_timezone,
|
||||
error,
|
||||
)
|
||||
now_in_home_tz = dt_utils.as_local(now)
|
||||
now_in_home_tz = dt_util.as_local(now)
|
||||
else:
|
||||
# Fallback to HA system timezone
|
||||
now_in_home_tz = dt_utils.as_local(now)
|
||||
now_in_home_tz = dt_util.as_local(now)
|
||||
|
||||
# Calculate day before yesterday midnight
|
||||
return (now_in_home_tz - timedelta(days=2)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
|
@ -640,7 +633,7 @@ class TibberPricesApiClient:
|
|||
Timezone-aware datetime object.
|
||||
|
||||
"""
|
||||
return dt_utils.parse_datetime(timestamp_str) or dt_utils.now()
|
||||
return dt_util.parse_datetime(timestamp_str) or dt_util.now()
|
||||
|
||||
def _calculate_cursor_for_home(self, home_timezone: str | None) -> str:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -492,7 +492,7 @@ def build_final_attributes_simple(
|
|||
return result
|
||||
|
||||
|
||||
async def build_async_extra_state_attributes( # noqa: PLR0913
|
||||
async def build_async_extra_state_attributes(
|
||||
entity_key: str,
|
||||
translation_key: str | None,
|
||||
hass: HomeAssistant,
|
||||
|
|
@ -555,7 +555,7 @@ async def build_async_extra_state_attributes( # noqa: PLR0913
|
|||
return attributes or None
|
||||
|
||||
|
||||
def build_sync_extra_state_attributes( # noqa: PLR0913
|
||||
def build_sync_extra_state_attributes(
|
||||
entity_key: str,
|
||||
translation_key: str | None,
|
||||
hass: HomeAssistant,
|
||||
|
|
|
|||
|
|
@ -9,10 +9,7 @@ from custom_components.tibber_prices.coordinator.core import get_connection_stat
|
|||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
from custom_components.tibber_prices.entity import TibberPricesEntity
|
||||
from custom_components.tibber_prices.entity_utils import get_binary_sensor_icon
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
|
@ -27,9 +24,7 @@ from .attributes import (
|
|||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from custom_components.tibber_prices.coordinator import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
|
||||
# Period lookahead removed - icons show "waiting" state if ANY future periods exist
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ The actual implementation is in the config_flow_handlers package.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from .config_flow_handlers.options_flow import (
|
||||
TibberPricesOptionsFlowHandler as OptionsFlowHandler,
|
||||
)
|
||||
from .config_flow_handlers.options_flow import TibberPricesOptionsFlowHandler as OptionsFlowHandler
|
||||
from .config_flow_handlers.schemas import (
|
||||
get_best_price_schema,
|
||||
get_options_init_schema,
|
||||
|
|
@ -23,9 +21,7 @@ from .config_flow_handlers.schemas import (
|
|||
get_user_schema,
|
||||
get_volatility_schema,
|
||||
)
|
||||
from .config_flow_handlers.subentry_flow import (
|
||||
TibberPricesSubentryFlowHandler as SubentryFlowHandler,
|
||||
)
|
||||
from .config_flow_handlers.subentry_flow import TibberPricesSubentryFlowHandler as SubentryFlowHandler
|
||||
from .config_flow_handlers.user_flow import TibberPricesConfigFlowHandler as ConfigFlow
|
||||
from .config_flow_handlers.validators import (
|
||||
TibberPricesCannotConnectError,
|
||||
|
|
|
|||
|
|
@ -20,9 +20,7 @@ Supporting modules:
|
|||
from __future__ import annotations
|
||||
|
||||
# Phase 3: Import flow handlers from their new modular structure
|
||||
from custom_components.tibber_prices.config_flow_handlers.options_flow import (
|
||||
TibberPricesOptionsFlowHandler,
|
||||
)
|
||||
from custom_components.tibber_prices.config_flow_handlers.options_flow import TibberPricesOptionsFlowHandler
|
||||
from custom_components.tibber_prices.config_flow_handlers.schemas import (
|
||||
get_best_price_schema,
|
||||
get_options_init_schema,
|
||||
|
|
@ -36,12 +34,8 @@ from custom_components.tibber_prices.config_flow_handlers.schemas import (
|
|||
get_user_schema,
|
||||
get_volatility_schema,
|
||||
)
|
||||
from custom_components.tibber_prices.config_flow_handlers.subentry_flow import (
|
||||
TibberPricesSubentryFlowHandler,
|
||||
)
|
||||
from custom_components.tibber_prices.config_flow_handlers.user_flow import (
|
||||
TibberPricesConfigFlowHandler,
|
||||
)
|
||||
from custom_components.tibber_prices.config_flow_handlers.subentry_flow import TibberPricesSubentryFlowHandler
|
||||
from custom_components.tibber_prices.config_flow_handlers.user_flow import TibberPricesConfigFlowHandler
|
||||
from custom_components.tibber_prices.config_flow_handlers.validators import (
|
||||
TibberPricesCannotConnectError,
|
||||
TibberPricesInvalidAuthError,
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -52,6 +52,7 @@ from custom_components.tibber_prices.const import (
|
|||
CONF_BEST_PRICE_MAX_LEVEL_GAP_COUNT,
|
||||
CONF_BEST_PRICE_MIN_DISTANCE_FROM_AVG,
|
||||
CONF_BEST_PRICE_MIN_PERIOD_LENGTH,
|
||||
CONF_CURRENCY_DISPLAY_MODE,
|
||||
CONF_MIN_PERIODS_BEST,
|
||||
CONF_MIN_PERIODS_PEAK,
|
||||
CONF_PEAK_PRICE_FLEX,
|
||||
|
|
@ -71,6 +72,7 @@ from custom_components.tibber_prices.const import (
|
|||
CONF_VOLATILITY_THRESHOLD_HIGH,
|
||||
CONF_VOLATILITY_THRESHOLD_MODERATE,
|
||||
CONF_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
DATA_STATISTICS_REVIEW_REQUIRED,
|
||||
DEFAULT_VOLATILITY_THRESHOLD_HIGH,
|
||||
DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
|
||||
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
|
|
@ -82,7 +84,7 @@ from custom_components.tibber_prices.const import (
|
|||
get_display_unit_factor,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -501,10 +503,43 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
|
|||
currency_code = tibber_data.coordinator.data.get("currency")
|
||||
|
||||
if user_input is not None:
|
||||
# Detect currency display mode change before saving
|
||||
old_mode = self.config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE)
|
||||
new_mode = user_input.get(CONF_CURRENCY_DISPLAY_MODE)
|
||||
|
||||
# Update options with new values
|
||||
self._options.update(user_input)
|
||||
# async_create_entry automatically handles change detection and listener triggering
|
||||
self._save_options_if_changed()
|
||||
|
||||
# Handle currency display mode change repair + persistent flag
|
||||
issue_id = f"currency_display_mode_changed_{self.config_entry.entry_id}"
|
||||
mode_changed = old_mode is not None and new_mode is not None and old_mode != new_mode
|
||||
|
||||
if mode_changed:
|
||||
# Set persistent flag so repair issue reappears after dismiss + HA restart
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry,
|
||||
data={**self.config_entry.data, DATA_STATISTICS_REVIEW_REQUIRED: True},
|
||||
)
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=False,
|
||||
is_persistent=True,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="currency_display_mode_changed",
|
||||
translation_placeholders={
|
||||
"home_name": self.config_entry.title,
|
||||
},
|
||||
)
|
||||
elif self.config_entry.data.get(DATA_STATISTICS_REVIEW_REQUIRED):
|
||||
# User re-saved display settings with same mode = acknowledgement → clear flag
|
||||
new_data = {k: v for k, v in self.config_entry.data.items() if k != DATA_STATISTICS_REVIEW_REQUIRED}
|
||||
self.hass.config_entries.async_update_entry(self.config_entry, data=new_data)
|
||||
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
|
||||
|
||||
# Return to menu for more changes
|
||||
return await self.async_step_init()
|
||||
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ ConfigOverrides = dict[str, dict[str, Any]]
|
|||
|
||||
def is_field_overridden(
|
||||
config_key: str,
|
||||
config_section: str, # noqa: ARG001 - kept for API compatibility
|
||||
config_section: str,
|
||||
overrides: ConfigOverrides | None,
|
||||
) -> bool:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ from typing import TYPE_CHECKING, Any
|
|||
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.tibber_prices.config_flow_handlers.options_flow import (
|
||||
TibberPricesOptionsFlowHandler,
|
||||
)
|
||||
from custom_components.tibber_prices.config_flow_handlers.options_flow import TibberPricesOptionsFlowHandler
|
||||
from custom_components.tibber_prices.config_flow_handlers.schemas import (
|
||||
get_reauth_confirm_schema,
|
||||
get_select_home_schema,
|
||||
|
|
@ -20,26 +18,11 @@ from custom_components.tibber_prices.config_flow_handlers.validators import (
|
|||
TibberPricesInvalidAuthError,
|
||||
validate_api_token,
|
||||
)
|
||||
from custom_components.tibber_prices.const import (
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
get_default_options,
|
||||
get_translation,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from custom_components.tibber_prices.const import DOMAIN, LOGGER, get_default_options, get_translation
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
from homeassistant.helpers.selector import SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigSubentryFlow
|
||||
|
|
@ -65,7 +48,7 @@ class TibberPricesConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||
@callback
|
||||
def async_get_supported_subentry_types(
|
||||
cls,
|
||||
config_entry: ConfigEntry, # noqa: ARG003
|
||||
config_entry: ConfigEntry,
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this integration."""
|
||||
# Temporarily disabled: Time-travel feature not yet fully implemented
|
||||
|
|
@ -85,7 +68,7 @@ class TibberPricesConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||
"""Return True if match_dict matches this flow."""
|
||||
return bool(other_flow.get("domain") == DOMAIN)
|
||||
|
||||
async def async_step_reauth(self, entry_data: dict[str, Any]) -> ConfigFlowResult: # noqa: ARG002
|
||||
async def async_step_reauth(self, entry_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Handle reauth flow when access token becomes invalid."""
|
||||
entry_id = self.context.get("entry_id")
|
||||
if entry_id:
|
||||
|
|
@ -295,7 +278,7 @@ class TibberPricesConfigFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||
description_placeholders={"tibber_url": "https://developer.tibber.com"},
|
||||
)
|
||||
|
||||
async def async_step_select_home(self, user_input: dict | None = None) -> ConfigFlowResult: # noqa: PLR0911
|
||||
async def async_step_select_home(self, user_input: dict | None = None) -> ConfigFlowResult:
|
||||
"""Handle home selection during initial setup."""
|
||||
homes = self._viewer.get("homes", []) if self._viewer else []
|
||||
|
||||
|
|
|
|||
|
|
@ -9,12 +9,7 @@ from typing import TYPE_CHECKING, Any
|
|||
|
||||
import aiofiles
|
||||
|
||||
from homeassistant.const import (
|
||||
CURRENCY_DOLLAR,
|
||||
CURRENCY_EURO,
|
||||
UnitOfPower,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.const import CURRENCY_DOLLAR, CURRENCY_EURO, UnitOfPower, UnitOfTime
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
|
@ -29,6 +24,11 @@ LOGGER = logging.getLogger(__package__)
|
|||
DATA_CHART_CONFIG = "chart_config" # Key for chart export config in hass.data
|
||||
DATA_CHART_METADATA_CONFIG = "chart_metadata_config" # Key for chart metadata config in hass.data
|
||||
|
||||
# Config entry data flag: set when user switches currency display mode.
|
||||
# Triggers a fresh (un-dismissed) repair issue on every setup/reload until
|
||||
# the user explicitly re-saves the currency settings to acknowledge.
|
||||
DATA_STATISTICS_REVIEW_REQUIRED = "statistics_review_required"
|
||||
|
||||
# Configuration keys
|
||||
CONF_EXTENDED_DESCRIPTIONS = "extended_descriptions"
|
||||
CONF_VIRTUAL_TIME_OFFSET_DAYS = (
|
||||
|
|
@ -462,14 +462,42 @@ def get_display_unit_factor(config_entry: ConfigEntry) -> int:
|
|||
Example:
|
||||
price_base = 0.2534 # Internal: 0.2534 €/kWh
|
||||
factor = get_display_unit_factor(config_entry)
|
||||
display_value = round(price_base * factor, 2)
|
||||
# → 25.34 ct/kWh (subunit) or 0.25 €/kWh (base)
|
||||
precision = get_display_precision(config_entry)
|
||||
display_value = round(price_base * factor, precision)
|
||||
# → 25.34 ct/kWh (subunit, 2 decimals) or 0.2534 €/kWh (base, 4 decimals)
|
||||
|
||||
"""
|
||||
display_mode = config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_SUBUNIT)
|
||||
return 100 if display_mode == DISPLAY_MODE_SUBUNIT else 1
|
||||
|
||||
|
||||
# Rounding precision constants for display currency
|
||||
DISPLAY_PRECISION_SUBUNIT = 2 # Decimal places for subunit currency (ct, øre)
|
||||
DISPLAY_PRECISION_BASE = 4 # Decimal places for base currency (€, kr)
|
||||
|
||||
|
||||
def get_display_precision(config_entry: ConfigEntry) -> int:
|
||||
"""
|
||||
Get decimal precision for rounding prices in the configured display currency.
|
||||
|
||||
Subunit currencies (ct, øre) use 2 decimal places (e.g., 25.34 ct/kWh).
|
||||
Base currencies (€, kr) use 4 decimal places (e.g., 0.2534 €/kWh).
|
||||
|
||||
This ensures sufficient precision for all currency modes:
|
||||
- Subunit: 2 decimals (the sub-cent level is rarely meaningful)
|
||||
- Base: 4 decimals (preserves full API precision for EUR/NOK/SEK prices)
|
||||
|
||||
Args:
|
||||
config_entry: ConfigEntry with currency_display_mode option
|
||||
|
||||
Returns:
|
||||
2 for subunit currency, 4 for base currency
|
||||
|
||||
"""
|
||||
display_mode = config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_SUBUNIT)
|
||||
return DISPLAY_PRECISION_SUBUNIT if display_mode == DISPLAY_MODE_SUBUNIT else DISPLAY_PRECISION_BASE
|
||||
|
||||
|
||||
def get_display_unit_string(config_entry: ConfigEntry, currency_code: str | None) -> str:
|
||||
"""
|
||||
Get unit string for display based on configuration.
|
||||
|
|
|
|||
|
|
@ -16,11 +16,7 @@ Main components:
|
|||
- period_handlers/: Period calculation sub-package
|
||||
"""
|
||||
|
||||
from .constants import (
|
||||
MINUTE_UPDATE_ENTITY_KEYS,
|
||||
STORAGE_VERSION,
|
||||
TIME_SENSITIVE_ENTITY_KEYS,
|
||||
)
|
||||
from .constants import MINUTE_UPDATE_ENTITY_KEYS, STORAGE_VERSION, TIME_SENSITIVE_ENTITY_KEYS
|
||||
from .core import TibberPricesDataUpdateCoordinator
|
||||
from .time_service import TibberPricesTimeService
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,18 @@ TIME_SENSITIVE_ENTITY_KEYS = frozenset(
|
|||
"best_price_next_start_time",
|
||||
"peak_price_end_time",
|
||||
"peak_price_next_start_time",
|
||||
# Price rank sensors (rank of current/next/previous interval within a day scope)
|
||||
"current_interval_price_rank_today",
|
||||
"current_interval_price_rank_tomorrow",
|
||||
"current_interval_price_rank_today_tomorrow",
|
||||
"current_hour_price_rank_today",
|
||||
"current_hour_price_rank_today_tomorrow",
|
||||
"next_interval_price_rank_today",
|
||||
"next_interval_price_rank_today_tomorrow",
|
||||
"next_hour_price_rank_today",
|
||||
"next_hour_price_rank_today_tomorrow",
|
||||
"previous_interval_price_rank_today",
|
||||
"previous_interval_price_rank_today_tomorrow",
|
||||
# Lifecycle sensor needs quarter-hour precision for state transitions:
|
||||
# - 23:45: turnover_pending (last interval before midnight)
|
||||
# - 00:00: turnover complete (after midnight API update)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
|
|
@ -24,16 +24,11 @@ from custom_components.tibber_prices.api import (
|
|||
TibberPricesApiClientError,
|
||||
)
|
||||
from custom_components.tibber_prices.const import DOMAIN
|
||||
from custom_components.tibber_prices.utils.price import (
|
||||
find_price_data_for_interval,
|
||||
)
|
||||
from custom_components.tibber_prices.utils.price import find_price_data_for_interval
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
|
||||
from . import helpers
|
||||
from .constants import (
|
||||
STORAGE_VERSION,
|
||||
UPDATE_INTERVAL,
|
||||
)
|
||||
from .constants import STORAGE_VERSION, UPDATE_INTERVAL
|
||||
from .data_transformation import TibberPricesDataTransformer
|
||||
from .listeners import TibberPricesListenerManager
|
||||
from .midnight_handler import TibberPricesMidnightHandler
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
|
|
|||
|
|
@ -11,9 +11,7 @@ if TYPE_CHECKING:
|
|||
|
||||
from .types import TibberPricesPeriodConfig
|
||||
|
||||
from .outlier_filtering import (
|
||||
filter_price_outliers,
|
||||
)
|
||||
from .outlier_filtering import filter_price_outliers
|
||||
from .period_building import (
|
||||
add_interval_ends,
|
||||
build_periods,
|
||||
|
|
@ -24,9 +22,7 @@ from .period_building import (
|
|||
filter_superseded_periods,
|
||||
split_intervals_by_day,
|
||||
)
|
||||
from .period_statistics import (
|
||||
extract_period_summaries,
|
||||
)
|
||||
from .period_statistics import extract_period_summaries
|
||||
from .shape_extension import extend_periods_for_shape
|
||||
from .types import TibberPricesThresholdConfig
|
||||
|
||||
|
|
@ -81,7 +77,7 @@ def calculate_periods(
|
|||
|
||||
from .types import INDENT_L0 # noqa: PLC0415
|
||||
|
||||
_LOGGER = logging.getLogger(__name__) # noqa: N806
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Extract config values
|
||||
reverse_sort = config.reverse_sort
|
||||
|
|
@ -141,7 +137,7 @@ def calculate_periods(
|
|||
# User's flex setting still applies to period criteria (in_flex check).
|
||||
|
||||
# Import details logger locally (core.py imports logger locally in function)
|
||||
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details") # noqa: N806
|
||||
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
|
||||
|
||||
outlier_flex = min(abs(flex) * 100, MAX_OUTLIER_FLEX * 100)
|
||||
if abs(flex) * 100 > MAX_OUTLIER_FLEX * 100:
|
||||
|
|
@ -298,7 +294,7 @@ def calculate_periods(
|
|||
def _period_belongs_to_side(
|
||||
period: list[dict],
|
||||
side_times: set,
|
||||
time: "TibberPricesTimeService",
|
||||
time: TibberPricesTimeService,
|
||||
) -> bool:
|
||||
"""Return True if the majority of a period's intervals are in side_times."""
|
||||
if not period:
|
||||
|
|
@ -307,14 +303,14 @@ def _period_belongs_to_side(
|
|||
return in_side * 2 >= len(period)
|
||||
|
||||
|
||||
def _apply_segment_forcing( # noqa: PLR0913
|
||||
def _apply_segment_forcing(
|
||||
all_prices_smoothed: list[dict],
|
||||
periods: list[list[dict]],
|
||||
price_context: dict[str, Any],
|
||||
config: "TibberPricesPeriodConfig",
|
||||
config: TibberPricesPeriodConfig,
|
||||
*,
|
||||
day_patterns_by_date: dict,
|
||||
time: "TibberPricesTimeService",
|
||||
time: TibberPricesTimeService,
|
||||
) -> list[list[dict]]:
|
||||
"""
|
||||
Force at least segment_min_periods periods per segment for W/M-shaped days.
|
||||
|
|
@ -341,7 +337,7 @@ def _apply_segment_forcing( # noqa: PLR0913
|
|||
from .period_building import build_periods # noqa: PLC0415
|
||||
from .types import DAY_PATTERN_DOUBLE_PEAK, DAY_PATTERN_DOUBLE_VALLEY, INDENT_L1, INDENT_L2 # noqa: PLC0415
|
||||
|
||||
_LOGGER = logging.getLogger(__name__) # noqa: N806
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
reverse_sort = config.reverse_sort
|
||||
target_pattern = DAY_PATTERN_DOUBLE_PEAK if reverse_sort else DAY_PATTERN_DOUBLE_VALLEY
|
||||
|
|
|
|||
|
|
@ -344,7 +344,7 @@ def _deduplicate_extrema(extrema: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||
# ─── pattern classification ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _classify_pattern( # noqa: PLR0911, PLR0912
|
||||
def _classify_pattern(
|
||||
extrema: list[dict[str, Any]],
|
||||
cv_pct: float,
|
||||
times: list[datetime],
|
||||
|
|
@ -399,7 +399,7 @@ def _classify_pattern( # noqa: PLR0911, PLR0912
|
|||
return DAY_PATTERN_PEAK, confidence
|
||||
|
||||
# ── two extrema ─────────────────────────────────────────────────────────────
|
||||
if n_extrema == 2: # noqa: PLR2004
|
||||
if n_extrema == 2:
|
||||
if types == ["max", "min"]:
|
||||
return DAY_PATTERN_FALLING, 0.7
|
||||
if types == ["min", "max"]:
|
||||
|
|
@ -410,7 +410,7 @@ def _classify_pattern( # noqa: PLR0911, PLR0912
|
|||
return DAY_PATTERN_DOUBLE_PEAK, 0.65
|
||||
|
||||
# ── three extrema ────────────────────────────────────────────────────────────
|
||||
if n_extrema == 3: # noqa: PLR2004
|
||||
if n_extrema == 3:
|
||||
# min-max-min → W-shape
|
||||
if types == ["min", "max", "min"]:
|
||||
return DAY_PATTERN_DOUBLE_VALLEY, 0.75
|
||||
|
|
@ -498,7 +498,7 @@ def _find_knee_on_flank(
|
|||
# Normalise so that start=(0,0) and end=(1,1)
|
||||
px_range = float(length)
|
||||
py_range = p_end - p_start
|
||||
if abs(py_range) < 1e-9: # noqa: PLR2004
|
||||
if abs(py_range) < 1e-9:
|
||||
return None # Flat flank - no knee
|
||||
|
||||
max_dist = 0.0
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ def check_interval_criteria(
|
|||
if scale_factor < SCALE_FACTOR_WARNING_THRESHOLD:
|
||||
import logging # noqa: PLC0415
|
||||
|
||||
_LOGGER = logging.getLogger(f"{__name__}.details") # noqa: N806
|
||||
_LOGGER = logging.getLogger(f"{__name__}.details")
|
||||
_LOGGER.debug(
|
||||
"High flex %.1f%% detected: Reducing min_distance %.1f%% → %.1f%% (scale %.2f)",
|
||||
flex_abs * 100,
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ Uses statistical methods:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import NamedTuple
|
||||
|
||||
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.const import PRICE_LEVEL_MAPPING
|
||||
|
|
@ -11,11 +11,7 @@ from custom_components.tibber_prices.const import PRICE_LEVEL_MAPPING
|
|||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
|
||||
from .level_filtering import (
|
||||
apply_level_filter,
|
||||
check_interval_criteria,
|
||||
compute_geometric_flex_bonus,
|
||||
)
|
||||
from .level_filtering import apply_level_filter, check_interval_criteria, compute_geometric_flex_bonus
|
||||
from .types import TibberPricesIntervalCriteria
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
|
@ -54,7 +50,7 @@ def calculate_reference_prices(intervals_by_day: dict[date, list[dict]], *, reve
|
|||
return ref_prices
|
||||
|
||||
|
||||
def build_periods( # noqa: PLR0913, PLR0915, PLR0912 - Complex period building logic requires many arguments, statements, and branches
|
||||
def build_periods(
|
||||
all_prices: list[dict],
|
||||
price_context: dict[str, Any],
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -9,11 +9,7 @@ if TYPE_CHECKING:
|
|||
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
|
||||
from .types import (
|
||||
TibberPricesPeriodData,
|
||||
TibberPricesPeriodStatistics,
|
||||
TibberPricesThresholdConfig,
|
||||
)
|
||||
from .types import TibberPricesPeriodData, TibberPricesPeriodStatistics, TibberPricesThresholdConfig
|
||||
|
||||
from custom_components.tibber_prices.utils.average import calculate_median
|
||||
from custom_components.tibber_prices.utils.price import (
|
||||
|
|
@ -272,7 +268,7 @@ def _add_interval_flag_counts(summary: dict, period: list[dict], *, geo_extensio
|
|||
summary["segment_forced"] = True
|
||||
|
||||
|
||||
def extract_period_summaries( # noqa: PLR0912, PLR0915 - CV pre-check for geo-extension adds necessary branches/statements
|
||||
def extract_period_summaries(
|
||||
periods: list[list[dict]],
|
||||
all_prices: list[dict],
|
||||
price_context: dict[str, Any],
|
||||
|
|
@ -302,10 +298,7 @@ def extract_period_summaries( # noqa: PLR0912, PLR0915 - CV pre-check for geo-e
|
|||
time: TibberPricesTimeService instance (required).
|
||||
|
||||
"""
|
||||
from .types import ( # noqa: PLC0415 - Avoid circular import
|
||||
TibberPricesPeriodData,
|
||||
TibberPricesPeriodStatistics,
|
||||
)
|
||||
from .types import TibberPricesPeriodData, TibberPricesPeriodStatistics # noqa: PLC0415 - Avoid circular import
|
||||
|
||||
# Build lookup dictionary for full price data by timestamp
|
||||
price_lookup: dict[str, dict] = {}
|
||||
|
|
@ -344,7 +337,7 @@ def extract_period_summaries( # noqa: PLR0912, PLR0915 - CV pre-check for geo-e
|
|||
if cv_fails:
|
||||
base_period = _strip_geo_from_edges(period)
|
||||
if base_period:
|
||||
period = base_period # noqa: PLW2901 - intentional period replacement
|
||||
period = base_period
|
||||
geo_extension_status = "attempted"
|
||||
else:
|
||||
geo_extension_status = "active"
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -14,10 +14,7 @@ if TYPE_CHECKING:
|
|||
|
||||
from custom_components.tibber_prices.utils.price import calculate_coefficient_of_variation, calculate_iqr_stats
|
||||
|
||||
from .period_overlap import (
|
||||
recalculate_period_metadata,
|
||||
resolve_period_overlaps,
|
||||
)
|
||||
from .period_overlap import recalculate_period_metadata, resolve_period_overlaps
|
||||
from .types import (
|
||||
INDENT_L0,
|
||||
INDENT_L1,
|
||||
|
|
@ -486,7 +483,7 @@ def _compute_day_effective_min(
|
|||
|
||||
price_values = [float(p["total"]) for p in day_prices if p.get("total") is not None]
|
||||
|
||||
if len(price_values) < 2: # noqa: PLR2004 - need at least 2 prices for any metric
|
||||
if len(price_values) < 2:
|
||||
day_effective_min[day] = min_periods
|
||||
continue
|
||||
|
||||
|
|
@ -532,7 +529,7 @@ def _compute_day_effective_min(
|
|||
return day_effective_min, flat_day_count
|
||||
|
||||
|
||||
def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-day relaxation requires many parameters and branches
|
||||
def calculate_periods_with_relaxation(
|
||||
all_prices: list[dict],
|
||||
*,
|
||||
config: TibberPricesPeriodConfig,
|
||||
|
|
@ -584,12 +581,8 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from .core import ( # noqa: PLC0415
|
||||
calculate_periods,
|
||||
)
|
||||
from .period_building import ( # noqa: PLC0415
|
||||
filter_superseded_periods,
|
||||
)
|
||||
from .core import calculate_periods # noqa: PLC0415
|
||||
from .period_building import filter_superseded_periods # noqa: PLC0415
|
||||
|
||||
# Compact INFO-level summary
|
||||
period_type = "PEAK PRICE" if config.reverse_sort else "BEST PRICE"
|
||||
|
|
@ -691,7 +684,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
any_normal_day = False
|
||||
for day_prices in prices_by_day.values():
|
||||
prices = [float(p["total"]) for p in day_prices if p.get("total") is not None]
|
||||
if len(prices) >= 2: # noqa: PLR2004
|
||||
if len(prices) >= 2:
|
||||
day_min = min(prices)
|
||||
day_avg = sum(prices) / len(prices)
|
||||
span = abs(day_avg - day_min)
|
||||
|
|
@ -877,7 +870,7 @@ def calculate_periods_with_relaxation( # noqa: PLR0912, PLR0913, PLR0915 - Per-
|
|||
return final_result
|
||||
|
||||
|
||||
def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation requires many parameters and statements
|
||||
def relax_all_prices(
|
||||
all_prices: list[dict],
|
||||
config: TibberPricesPeriodConfig,
|
||||
min_periods: int,
|
||||
|
|
@ -914,9 +907,7 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require
|
|||
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from .core import ( # noqa: PLC0415
|
||||
calculate_periods,
|
||||
)
|
||||
from .core import calculate_periods # noqa: PLC0415
|
||||
|
||||
flex_increment = 0.03 # 3% per step (hard-coded for reliability)
|
||||
base_flex = abs(config.flex)
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ created by this step.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from datetime import timedelta
|
||||
import statistics
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
|
|
@ -30,10 +30,7 @@ from custom_components.tibber_prices.const import (
|
|||
PRICE_LEVEL_VERY_CHEAP,
|
||||
PRICE_LEVEL_VERY_EXPENSIVE,
|
||||
)
|
||||
from custom_components.tibber_prices.utils.price import (
|
||||
aggregate_period_levels,
|
||||
aggregate_period_ratings,
|
||||
)
|
||||
from custom_components.tibber_prices.utils.price import aggregate_period_levels, aggregate_period_ratings
|
||||
|
||||
from .period_statistics import (
|
||||
calculate_aggregated_rating_difference,
|
||||
|
|
@ -51,7 +48,7 @@ if TYPE_CHECKING:
|
|||
_INTERVAL_DURATION = timedelta(minutes=15)
|
||||
|
||||
|
||||
def extend_periods_for_shape( # noqa: PLR0913 - Extension requires all context params
|
||||
def extend_periods_for_shape(
|
||||
periods: list[dict[str, Any]],
|
||||
all_prices: list[dict[str, Any]],
|
||||
price_context: dict[str, Any],
|
||||
|
|
@ -164,7 +161,7 @@ def _walk_contiguous(
|
|||
return additions
|
||||
|
||||
|
||||
def _extend_period_edges( # noqa: PLR0913 - Period edge extension requires many args
|
||||
def _extend_period_edges(
|
||||
period: dict[str, Any],
|
||||
interval_index: dict[datetime, dict[str, Any]],
|
||||
*,
|
||||
|
|
@ -253,7 +250,7 @@ def _extend_period_edges( # noqa: PLR0913 - Period edge extension requires many
|
|||
# ── recalculate volatility (coefficient of variation) ────────────────────
|
||||
prices_for_vol = [float(p["total"]) for p in all_period_intervals if "total" in p]
|
||||
cv_pct: float | None = None
|
||||
if len(prices_for_vol) >= 2: # noqa: PLR2004
|
||||
if len(prices_for_vol) >= 2:
|
||||
mean_p = statistics.mean(prices_for_vol)
|
||||
if mean_p > 0:
|
||||
cv_pct = round(statistics.stdev(prices_for_vol) / mean_p * 100, 1)
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ gap tolerance, and coordination of the period_handlers calculation functions.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices import const as _const
|
||||
|
|
@ -20,10 +20,7 @@ if TYPE_CHECKING:
|
|||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
from .helpers import get_intervals_for_day_offsets
|
||||
from .period_handlers import (
|
||||
TibberPricesPeriodConfig,
|
||||
calculate_periods_with_relaxation,
|
||||
)
|
||||
from .period_handlers import TibberPricesPeriodConfig, calculate_periods_with_relaxation
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ source of truth. This module only caches user_data for daily refresh cycle.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.api import (
|
||||
|
|
@ -71,7 +71,7 @@ class TibberPricesPriceDataManager:
|
|||
This class orchestrates WHEN to fetch and processes the results.
|
||||
"""
|
||||
|
||||
def __init__( # noqa: PLR0913
|
||||
def __init__(
|
||||
self,
|
||||
api: TibberPricesApiClient,
|
||||
store: Any,
|
||||
|
|
@ -178,7 +178,7 @@ class TibberPricesPriceDataManager:
|
|||
)
|
||||
await cache.save_cache(self._store, cache_data, self._log_prefix)
|
||||
|
||||
def _validate_user_data(self, user_data: dict, home_id: str) -> bool: # noqa: PLR0911
|
||||
def _validate_user_data(self, user_data: dict, home_id: str) -> bool:
|
||||
"""
|
||||
Validate user data completeness.
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class TibberPricesRepairManager:
|
|||
|
||||
async def check_tomorrow_data_availability(
|
||||
self,
|
||||
has_tomorrow_data: bool, # noqa: FBT001 - Clear meaning in context
|
||||
has_tomorrow_data: bool,
|
||||
current_time: datetime,
|
||||
) -> None:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ scheduling delays. It is NOT used for Timer #1's offset tracking.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
import math
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
|||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, # noqa: ARG001
|
||||
hass: HomeAssistant,
|
||||
entry: TibberPricesConfigEntry,
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
|
|
|||
|
|
@ -18,15 +18,9 @@ For pure data transformation (no HA dependencies), see utils/ package.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from .attributes import (
|
||||
add_description_attributes,
|
||||
async_add_description_attributes,
|
||||
)
|
||||
from .attributes import add_description_attributes, async_add_description_attributes
|
||||
from .colors import add_icon_color_attribute, get_icon_color
|
||||
from .helpers import (
|
||||
find_rolling_hour_center_index,
|
||||
get_price_value,
|
||||
)
|
||||
from .helpers import find_rolling_hour_center_index, get_price_value
|
||||
from .icons import (
|
||||
get_binary_sensor_icon,
|
||||
get_dynamic_icon,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ if TYPE_CHECKING:
|
|||
from ..data import TibberPricesConfigEntry # noqa: TID252
|
||||
|
||||
|
||||
def add_description_attributes( # noqa: PLR0913, PLR0912
|
||||
def add_description_attributes(
|
||||
attributes: dict,
|
||||
platform: str,
|
||||
translation_key: str | None,
|
||||
|
|
@ -113,7 +113,7 @@ def add_description_attributes( # noqa: PLR0913, PLR0912
|
|||
attributes[key] = value
|
||||
|
||||
|
||||
async def async_add_description_attributes( # noqa: PLR0913, PLR0912
|
||||
async def async_add_description_attributes(
|
||||
attributes: dict,
|
||||
platform: str,
|
||||
translation_key: str | None,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.tibber_prices.const import get_display_unit_factor
|
||||
from custom_components.tibber_prices.const import get_display_precision, get_display_unit_factor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
|
@ -55,7 +55,8 @@ def get_price_value(
|
|||
# New mode: use config_entry
|
||||
if config_entry is not None:
|
||||
factor = get_display_unit_factor(config_entry)
|
||||
return round(price * factor, 2)
|
||||
precision = get_display_precision(config_entry)
|
||||
return round(price * factor, precision)
|
||||
|
||||
# Fallback: default to subunit currency (backward compatibility)
|
||||
return round(price * 100, 2)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ class TibberPricesIconContext:
|
|||
has_future_periods_callback: Callable[[], bool] | None = None
|
||||
period_is_active_callback: Callable[[], bool] | None = None
|
||||
time: TibberPricesTimeService | None = None
|
||||
trend_change_direction: str | None = None # For next_price_trend_change icon lookup
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -74,7 +75,7 @@ def get_dynamic_icon(
|
|||
|
||||
# Try various icon sources in order
|
||||
return (
|
||||
get_trend_icon(key, value)
|
||||
get_trend_icon(key, value, context=ctx)
|
||||
or get_timing_sensor_icon(key, value, period_is_active_callback=ctx.period_is_active_callback)
|
||||
or get_price_sensor_icon(key, ctx.coordinator_data, time=ctx.time)
|
||||
or get_level_sensor_icon(key, value)
|
||||
|
|
@ -84,12 +85,24 @@ def get_dynamic_icon(
|
|||
)
|
||||
|
||||
|
||||
def get_trend_icon(key: str, value: Any) -> str | None:
|
||||
# 5-level trend icons: strongly uses double arrows, normal uses single
|
||||
_TREND_ICONS = {
|
||||
"strongly_rising": "mdi:chevron-double-up",
|
||||
"rising": "mdi:trending-up",
|
||||
"stable": "mdi:trending-neutral",
|
||||
"falling": "mdi:trending-down",
|
||||
"strongly_falling": "mdi:chevron-double-down",
|
||||
}
|
||||
|
||||
|
||||
def get_trend_icon(key: str, value: Any, *, context: TibberPricesIconContext | None = None) -> str | None:
|
||||
"""Get icon for trend sensors using 5-level trend scale."""
|
||||
# Handle next_price_trend_change TIMESTAMP sensor differently
|
||||
# (icon based on attributes, not value which is a timestamp)
|
||||
# next_price_trend_change is a TIMESTAMP sensor — icon comes from direction attribute
|
||||
if key == "next_price_trend_change":
|
||||
return None # Will be handled by sensor's icon property using attributes
|
||||
direction = context.trend_change_direction if context else None
|
||||
if isinstance(direction, str):
|
||||
return _TREND_ICONS.get(direction, "mdi:help-circle-outline")
|
||||
return "mdi:help-circle-outline"
|
||||
|
||||
if not key.startswith(("price_trend_", "price_outlook_", "price_trajectory_")) and key != "current_price_trend":
|
||||
return None
|
||||
|
|
@ -97,15 +110,7 @@ def get_trend_icon(key: str, value: Any) -> str | None:
|
|||
if not isinstance(value, str):
|
||||
return None
|
||||
|
||||
# 5-level trend icons: strongly uses double arrows, normal uses single
|
||||
trend_icons = {
|
||||
"strongly_rising": "mdi:chevron-double-up", # Strong upward movement
|
||||
"rising": "mdi:trending-up", # Normal upward trend
|
||||
"stable": "mdi:trending-neutral", # No significant change
|
||||
"falling": "mdi:trending-down", # Normal downward trend
|
||||
"strongly_falling": "mdi:chevron-double-down", # Strong downward movement
|
||||
}
|
||||
return trend_icons.get(value)
|
||||
return _TREND_ICONS.get(value, "mdi:help-circle-outline")
|
||||
|
||||
|
||||
def get_timing_sensor_icon(
|
||||
|
|
|
|||
|
|
@ -2,16 +2,14 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.util import dt as dt_utils
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.coordinator.time_service import (
|
||||
TibberPricesTimeService,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
|
||||
|
|
@ -114,7 +112,7 @@ class TibberPricesIntervalPoolFetchGroupCache:
|
|||
|
||||
"""
|
||||
# Use TimeService if available (Time Machine support), else real time
|
||||
now = self._time_service.now() if self._time_service else dt_utils.now()
|
||||
now = self._time_service.now() if self._time_service else dt_util.now()
|
||||
today_date_str = now.date().isoformat()
|
||||
|
||||
# Check cache validity (invalidate daily)
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.util import dt as dt_utils
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
|
@ -287,11 +287,9 @@ class TibberPricesIntervalPoolFetcher:
|
|||
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from custom_components.tibber_prices.interval_pool.routing import ( # noqa: PLC0415
|
||||
get_price_intervals_for_range,
|
||||
)
|
||||
from custom_components.tibber_prices.interval_pool.routing import get_price_intervals_for_range # noqa: PLC0415
|
||||
|
||||
fetch_time_iso = dt_utils.now().isoformat()
|
||||
fetch_time_iso = dt_util.now().isoformat()
|
||||
all_fetched_intervals = []
|
||||
|
||||
for idx, (missing_start_iso, missing_end_iso) in enumerate(missing_ranges, start=1):
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ from custom_components.tibber_prices.api.exceptions import (
|
|||
TibberPricesApiClientCommunicationError,
|
||||
TibberPricesApiClientError,
|
||||
)
|
||||
from homeassistant.util import dt as dt_utils
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .cache import TibberPricesIntervalPoolFetchGroupCache
|
||||
from .fetcher import TibberPricesIntervalPoolFetcher
|
||||
|
|
@ -23,9 +23,7 @@ from .storage import async_save_pool_state
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.api.client import TibberPricesApiClient
|
||||
from custom_components.tibber_prices.coordinator.time_service import (
|
||||
TibberPricesTimeService,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER_DETAILS = logging.getLogger(__name__ + ".details")
|
||||
|
|
@ -101,7 +99,7 @@ class TibberPricesIntervalPool:
|
|||
hass: HomeAssistant instance for auto-save (optional).
|
||||
entry_id: Config entry ID for auto-save (optional).
|
||||
time_service: TimeService for time-travel support (optional).
|
||||
If None, uses real time (dt_utils.now()).
|
||||
If None, uses real time (dt_util.now()).
|
||||
|
||||
"""
|
||||
self._home_id = home_id
|
||||
|
|
@ -206,7 +204,7 @@ class TibberPricesIntervalPool:
|
|||
# Fetch missing ranges from API
|
||||
api_fetch_failed = False
|
||||
if missing_ranges:
|
||||
fetch_time_iso = dt_utils.now().isoformat()
|
||||
fetch_time_iso = dt_util.now().isoformat()
|
||||
|
||||
try:
|
||||
# Fetch with callback for immediate caching
|
||||
|
|
@ -301,7 +299,7 @@ class TibberPricesIntervalPool:
|
|||
|
||||
# Calculate range in home's timezone
|
||||
tz = ZoneInfo(tz_str) if tz_str else None
|
||||
now = self._time_service.now() if self._time_service else dt_utils.now()
|
||||
now = self._time_service.now() if self._time_service else dt_util.now()
|
||||
now_local = now.astimezone(tz) if tz else now
|
||||
|
||||
# Day before yesterday 00:00 (start) - same for both fetch and return
|
||||
|
|
@ -598,7 +596,7 @@ class TibberPricesIntervalPool:
|
|||
result = []
|
||||
|
||||
# Determine interval step (15 min post-2025-10-01, 60 min pre)
|
||||
resolution_change_naive = datetime(2025, 10, 1) # noqa: DTZ001
|
||||
resolution_change_naive = datetime(2025, 10, 1)
|
||||
interval_minutes = INTERVAL_QUARTER_HOURLY if current_naive >= resolution_change_naive else INTERVAL_HOURLY
|
||||
|
||||
fetch_groups = self._cache.get_fetch_groups()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ This module handles intelligent routing between different Tibber API endpoints:
|
|||
- PRICE_INFO_RANGE: Historical data (before "day before yesterday midnight")
|
||||
- Automatic splitting and merging when range spans the boundary
|
||||
|
||||
CRITICAL: Uses REAL TIME (dt_utils.now()) for API boundary calculation,
|
||||
CRITICAL: Uses REAL TIME (dt_util.now()) for API boundary calculation,
|
||||
NOT TimeService.now() which may be shifted for internal simulation.
|
||||
"""
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ import logging
|
|||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.api.exceptions import TibberPricesApiClientError
|
||||
from homeassistant.util import dt as dt_utils
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
|
@ -43,7 +43,7 @@ async def get_price_intervals_for_range(
|
|||
- PRICE_INFO: For intervals from "day before yesterday midnight" onwards
|
||||
- Both: If range spans across the boundary, splits the request
|
||||
|
||||
CRITICAL: Uses REAL TIME (dt_utils.now()) for API boundary calculation,
|
||||
CRITICAL: Uses REAL TIME (dt_util.now()) for API boundary calculation,
|
||||
NOT TimeService.now() which may be shifted for internal simulation.
|
||||
This ensures predictable API responses.
|
||||
|
||||
|
|
@ -173,7 +173,7 @@ def _parse_timestamp(timestamp_str: str) -> datetime:
|
|||
ValueError: If timestamp string cannot be parsed.
|
||||
|
||||
"""
|
||||
result = dt_utils.parse_datetime(timestamp_str)
|
||||
result = dt_util.parse_datetime(timestamp_str)
|
||||
if result is None:
|
||||
msg = f"Failed to parse timestamp: {timestamp_str}"
|
||||
raise ValueError(msg)
|
||||
|
|
|
|||
|
|
@ -7,5 +7,5 @@
|
|||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/jpawlowski/hass.tibber_prices/issues",
|
||||
"requirements": ["aiofiles>=23.2.1"],
|
||||
"version": "0.31.0b1"
|
||||
"version": "0.31.0b2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,7 @@ import logging
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
|
|
|||
|
|
@ -11,19 +11,13 @@ from __future__ import annotations
|
|||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
DOMAIN,
|
||||
get_home_type_translation,
|
||||
get_translation,
|
||||
)
|
||||
from custom_components.tibber_prices.const import DOMAIN, get_home_type_translation, get_translation
|
||||
from homeassistant.components.number import NumberEntity, RestoreNumber
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.coordinator import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
||||
|
||||
from .definitions import TibberPricesNumberEntityDescription
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,7 @@ from __future__ import annotations
|
|||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberEntityDescription,
|
||||
NumberMode,
|
||||
)
|
||||
from homeassistant.components.number import NumberEntityDescription, NumberMode
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -17,10 +17,7 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
CONF_CURRENCY_DISPLAY_MODE,
|
||||
DISPLAY_MODE_BASE,
|
||||
)
|
||||
from custom_components.tibber_prices.const import CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_BASE
|
||||
|
||||
from .core import TibberPricesSensor
|
||||
from .definitions import ENTITY_DESCRIPTIONS
|
||||
|
|
|
|||
|
|
@ -10,10 +10,7 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.entity_utils import (
|
||||
add_description_attributes,
|
||||
add_icon_color_attribute,
|
||||
)
|
||||
from custom_components.tibber_prices.entity_utils import add_description_attributes, add_icon_color_attribute
|
||||
from custom_components.tibber_prices.sensor.types import (
|
||||
DailyStatPriceAttributes,
|
||||
DailyStatRatingAttributes,
|
||||
|
|
@ -32,9 +29,7 @@ from custom_components.tibber_prices.sensor.types import (
|
|||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.coordinator.core import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.core import TibberPricesDataUpdateCoordinator
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
from custom_components.tibber_prices.data import TibberPricesConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
|
@ -74,7 +69,7 @@ __all__ = [
|
|||
]
|
||||
|
||||
|
||||
def build_sensor_attributes( # noqa: PLR0912
|
||||
def build_sensor_attributes(
|
||||
key: str,
|
||||
coordinator: TibberPricesDataUpdateCoordinator,
|
||||
native_value: Any,
|
||||
|
|
@ -228,7 +223,7 @@ def build_sensor_attributes( # noqa: PLR0912
|
|||
return attributes or None
|
||||
|
||||
|
||||
def build_extra_state_attributes( # noqa: PLR0913
|
||||
def build_extra_state_attributes(
|
||||
entity_key: str,
|
||||
translation_key: str | None,
|
||||
hass: HomeAssistant,
|
||||
|
|
|
|||
|
|
@ -4,13 +4,8 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
PRICE_RATING_MAPPING,
|
||||
get_display_unit_factor,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.helpers import (
|
||||
get_intervals_for_day_offsets,
|
||||
)
|
||||
from custom_components.tibber_prices.const import PRICE_RATING_MAPPING, get_display_precision, get_display_unit_factor
|
||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
from homeassistant.const import PERCENTAGE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -30,12 +25,13 @@ def _add_energy_tax_from_interval(
|
|||
) -> None:
|
||||
"""Add energy_price and tax from a single interval dict."""
|
||||
factor = get_display_unit_factor(config_entry)
|
||||
precision = get_display_precision(config_entry)
|
||||
energy = interval_data.get("energy")
|
||||
if energy is not None:
|
||||
attributes["energy_price"] = round(float(energy) * factor, 2)
|
||||
attributes["energy_price"] = round(float(energy) * factor, precision)
|
||||
tax = interval_data.get("tax")
|
||||
if tax is not None:
|
||||
attributes["tax"] = round(float(tax) * factor, 2)
|
||||
attributes["tax"] = round(float(tax) * factor, precision)
|
||||
|
||||
|
||||
def _add_energy_tax_averages_from_cache(
|
||||
|
|
@ -49,14 +45,15 @@ def _add_energy_tax_averages_from_cache(
|
|||
"last_energy_tax_averages", (None, None, None, None)
|
||||
)
|
||||
factor = get_display_unit_factor(config_entry)
|
||||
precision = get_display_precision(config_entry)
|
||||
if energy_mean is not None:
|
||||
attributes["energy_price_mean"] = round(float(energy_mean) * factor, 2)
|
||||
attributes["energy_price_mean"] = round(float(energy_mean) * factor, precision)
|
||||
if energy_median is not None:
|
||||
attributes["energy_price_median"] = round(float(energy_median) * factor, 2)
|
||||
attributes["energy_price_median"] = round(float(energy_median) * factor, precision)
|
||||
if tax_mean is not None:
|
||||
attributes["tax_mean"] = round(float(tax_mean) * factor, 2)
|
||||
attributes["tax_mean"] = round(float(tax_mean) * factor, precision)
|
||||
if tax_median is not None:
|
||||
attributes["tax_median"] = round(float(tax_median) * factor, 2)
|
||||
attributes["tax_median"] = round(float(tax_median) * factor, precision)
|
||||
|
||||
|
||||
def _get_day_midnight_timestamp(key: str, *, time: TibberPricesTimeService) -> datetime:
|
||||
|
|
|
|||
|
|
@ -4,13 +4,11 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.tibber_prices.const import get_display_unit_factor
|
||||
from custom_components.tibber_prices.const import get_display_precision, get_display_unit_factor
|
||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.coordinator.core import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.core import TibberPricesDataUpdateCoordinator
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
from custom_components.tibber_prices.data import TibberPricesConfigEntry
|
||||
|
||||
|
|
@ -20,7 +18,7 @@ from .helpers import add_alternate_average_attribute
|
|||
MAX_FORECAST_INTERVALS = 8 # Show up to 8 future intervals (2 hours with 15-min intervals)
|
||||
|
||||
|
||||
def add_next_avg_attributes( # noqa: PLR0913
|
||||
def add_next_avg_attributes(
|
||||
attributes: dict,
|
||||
key: str,
|
||||
coordinator: TibberPricesDataUpdateCoordinator,
|
||||
|
|
@ -142,7 +140,8 @@ def get_future_prices(
|
|||
# Convert to display currency unit based on configuration
|
||||
price_major = float(price_data["total"])
|
||||
factor = get_display_unit_factor(config_entry)
|
||||
price_display = round(price_major * factor, 2)
|
||||
precision = get_display_precision(config_entry)
|
||||
price_display = round(price_major * factor, precision)
|
||||
|
||||
future_prices.append(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ def add_alternate_average_attribute(
|
|||
cached_data: dict,
|
||||
base_key: str,
|
||||
*,
|
||||
config_entry: TibberPricesConfigEntry, # noqa: ARG001
|
||||
config_entry: TibberPricesConfigEntry,
|
||||
) -> None:
|
||||
"""
|
||||
Add both average values (mean and median) as attributes.
|
||||
|
|
|
|||
|
|
@ -8,15 +8,14 @@ from typing import TYPE_CHECKING, Any
|
|||
from custom_components.tibber_prices.const import (
|
||||
PRICE_LEVEL_MAPPING,
|
||||
PRICE_RATING_MAPPING,
|
||||
get_display_precision,
|
||||
get_display_unit_factor,
|
||||
)
|
||||
from custom_components.tibber_prices.entity_utils import add_icon_color_attribute
|
||||
from custom_components.tibber_prices.utils.price import find_price_data_for_interval
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.coordinator.core import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.core import TibberPricesDataUpdateCoordinator
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
from custom_components.tibber_prices.data import TibberPricesConfigEntry
|
||||
|
||||
|
|
@ -112,17 +111,18 @@ def _add_energy_tax_attributes(
|
|||
return
|
||||
|
||||
factor = get_display_unit_factor(config_entry)
|
||||
precision = get_display_precision(config_entry)
|
||||
|
||||
energy = interval_data.get("energy")
|
||||
if energy is not None:
|
||||
attributes["energy_price"] = round(float(energy) * factor, 2)
|
||||
attributes["energy_price"] = round(float(energy) * factor, precision)
|
||||
|
||||
tax = interval_data.get("tax")
|
||||
if tax is not None:
|
||||
attributes["tax"] = round(float(tax) * factor, 2)
|
||||
attributes["tax"] = round(float(tax) * factor, precision)
|
||||
|
||||
|
||||
def add_current_interval_price_attributes( # noqa: PLR0913
|
||||
def add_current_interval_price_attributes(
|
||||
attributes: dict,
|
||||
key: str,
|
||||
coordinator: TibberPricesDataUpdateCoordinator,
|
||||
|
|
@ -198,7 +198,7 @@ def add_current_interval_price_attributes( # noqa: PLR0913
|
|||
)
|
||||
|
||||
|
||||
def add_level_attributes_for_sensor( # noqa: PLR0913
|
||||
def add_level_attributes_for_sensor(
|
||||
attributes: dict,
|
||||
key: str,
|
||||
interval_data: dict | None,
|
||||
|
|
@ -252,7 +252,7 @@ def add_price_level_attributes(attributes: dict, level: str) -> None:
|
|||
add_icon_color_attribute(attributes, key="price_level", state_value=level)
|
||||
|
||||
|
||||
def add_rating_attributes_for_sensor( # noqa: PLR0913
|
||||
def add_rating_attributes_for_sensor(
|
||||
attributes: dict,
|
||||
key: str,
|
||||
interval_data: dict | None,
|
||||
|
|
|
|||
|
|
@ -25,12 +25,8 @@ from __future__ import annotations
|
|||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.coordinator.core import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.sensor.calculators.lifecycle import (
|
||||
TibberPricesLifecycleCalculator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.core import TibberPricesDataUpdateCoordinator
|
||||
from custom_components.tibber_prices.sensor.calculators.lifecycle import TibberPricesLifecycleCalculator
|
||||
|
||||
|
||||
def build_lifecycle_attributes(
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ from typing import TYPE_CHECKING, Any
|
|||
from custom_components.tibber_prices.utils.price import find_price_data_for_interval
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.coordinator.core import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.core import TibberPricesDataUpdateCoordinator
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ def add_volatility_attributes(
|
|||
attributes: dict,
|
||||
cached_data: dict,
|
||||
*,
|
||||
time: TibberPricesTimeService, # noqa: ARG001
|
||||
time: TibberPricesTimeService,
|
||||
) -> None:
|
||||
"""
|
||||
Add attributes for volatility sensors.
|
||||
|
|
@ -197,9 +197,7 @@ def add_percentile_rank_attributes(
|
|||
coordinator_data = cached_data.get("coordinator_data")
|
||||
|
||||
if coordinator_data:
|
||||
from custom_components.tibber_prices.coordinator.helpers import ( # noqa: PLC0415
|
||||
get_intervals_for_day_offsets,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets # noqa: PLC0415
|
||||
|
||||
all_intervals = get_intervals_for_day_offsets(coordinator_data, [-1, 0, 1])
|
||||
now = time.now()
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ from typing import TYPE_CHECKING
|
|||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.coordinator.core import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.core import TibberPricesDataUpdateCoordinator
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
from custom_components.tibber_prices.data import TibberPricesConfigEntry
|
||||
|
||||
|
|
@ -43,7 +41,7 @@ def _update_extreme_interval(extreme_interval: dict | None, price_data: dict, ke
|
|||
return price_data if is_new_extreme else extreme_interval
|
||||
|
||||
|
||||
def add_average_price_attributes( # noqa: PLR0913
|
||||
def add_average_price_attributes(
|
||||
attributes: dict,
|
||||
key: str,
|
||||
coordinator: TibberPricesDataUpdateCoordinator,
|
||||
|
|
|
|||
|
|
@ -4,14 +4,10 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.coordinator.helpers import (
|
||||
get_intervals_for_day_offsets,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.coordinator import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
||||
from custom_components.tibber_prices.data import TibberPricesConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
|
|
|||
|
|
@ -9,12 +9,10 @@ from custom_components.tibber_prices.const import (
|
|||
CONF_PRICE_RATING_THRESHOLD_LOW,
|
||||
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
|
||||
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
|
||||
get_display_precision,
|
||||
)
|
||||
from custom_components.tibber_prices.entity_utils import get_price_value
|
||||
from custom_components.tibber_prices.sensor.helpers import (
|
||||
aggregate_level_data,
|
||||
aggregate_rating_data,
|
||||
)
|
||||
from custom_components.tibber_prices.sensor.helpers import aggregate_level_data, aggregate_rating_data
|
||||
from custom_components.tibber_prices.utils.average import calculate_median
|
||||
|
||||
from .base import TibberPricesBaseCalculator
|
||||
|
|
@ -22,9 +20,7 @@ from .base import TibberPricesBaseCalculator
|
|||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from custom_components.tibber_prices.coordinator import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
||||
|
||||
|
||||
class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
|
||||
|
|
@ -115,9 +111,10 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
|
|||
# Compute and cache energy/tax averages for attribute builders
|
||||
self._cache_energy_tax_averages(price_intervals)
|
||||
# Convert to display currency units based on config
|
||||
avg_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), 2)
|
||||
precision = get_display_precision(self.coordinator.config_entry)
|
||||
avg_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), precision)
|
||||
median_result = (
|
||||
round(get_price_value(median, config_entry=self.coordinator.config_entry), 2)
|
||||
round(get_price_value(median, config_entry=self.coordinator.config_entry), precision)
|
||||
if median is not None
|
||||
else None
|
||||
)
|
||||
|
|
@ -132,9 +129,10 @@ class TibberPricesDailyStatCalculator(TibberPricesBaseCalculator):
|
|||
self._last_extreme_interval = pi["interval"]
|
||||
break
|
||||
|
||||
# Return in configured display currency units with 2 decimals
|
||||
# Return in configured display currency units
|
||||
precision = get_display_precision(self.coordinator.config_entry)
|
||||
result = get_price_value(value, config_entry=self.coordinator.config_entry)
|
||||
return round(result, 2)
|
||||
return round(result, precision)
|
||||
|
||||
def get_daily_aggregated_value(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -4,14 +4,12 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.tibber_prices.const import get_display_unit_factor
|
||||
from custom_components.tibber_prices.const import get_display_precision, get_display_unit_factor
|
||||
|
||||
from .base import TibberPricesBaseCalculator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.coordinator import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
||||
|
||||
|
||||
class TibberPricesIntervalCalculator(TibberPricesBaseCalculator):
|
||||
|
|
@ -36,7 +34,7 @@ class TibberPricesIntervalCalculator(TibberPricesBaseCalculator):
|
|||
self._last_rating_level: str | None = None
|
||||
self._last_rating_difference: float | None = None
|
||||
|
||||
def get_interval_value( # noqa: PLR0911
|
||||
def get_interval_value(
|
||||
self,
|
||||
*,
|
||||
interval_offset: int,
|
||||
|
|
@ -74,7 +72,8 @@ class TibberPricesIntervalCalculator(TibberPricesBaseCalculator):
|
|||
if in_euro:
|
||||
return price
|
||||
factor = get_display_unit_factor(self.config_entry)
|
||||
return round(price * factor, 2)
|
||||
precision = get_display_precision(self.config_entry)
|
||||
return round(price * factor, precision)
|
||||
|
||||
if value_type == "level":
|
||||
level = self.safe_get_from_interval(interval_data, "level")
|
||||
|
|
|
|||
|
|
@ -15,22 +15,18 @@ Caching strategy:
|
|||
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from custom_components.tibber_prices.const import get_display_unit_factor
|
||||
from custom_components.tibber_prices.const import get_display_precision, get_display_unit_factor
|
||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
from custom_components.tibber_prices.entity_utils.colors import get_icon_color
|
||||
from custom_components.tibber_prices.utils.average import calculate_mean, calculate_next_n_hours_mean
|
||||
from custom_components.tibber_prices.utils.price import (
|
||||
calculate_price_trend,
|
||||
find_price_data_for_interval,
|
||||
)
|
||||
from custom_components.tibber_prices.utils.price import calculate_price_trend, find_price_data_for_interval
|
||||
|
||||
from .base import TibberPricesBaseCalculator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
from custom_components.tibber_prices.coordinator import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
||||
|
||||
# Constants
|
||||
MIN_HOURS_FOR_LATER_HALF = 1 # Minimum hours needed to calculate half-window averages (activates at 2h+)
|
||||
|
|
@ -62,7 +58,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
|
|||
"strongly_rising": "rising",
|
||||
}
|
||||
|
||||
def __init__(self, coordinator: "TibberPricesDataUpdateCoordinator") -> None:
|
||||
def __init__(self, coordinator: TibberPricesDataUpdateCoordinator) -> None:
|
||||
"""Initialize trend calculator with caching state."""
|
||||
super().__init__(coordinator)
|
||||
# Per-sensor caches (for price_outlook_Xh and price_trajectory_Xh sensors)
|
||||
|
|
@ -168,18 +164,12 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
|
|||
volatility_threshold_high=volatility_threshold_high,
|
||||
)
|
||||
|
||||
# Determine icon color based on trend state (5-level scale)
|
||||
# Strongly rising/falling uses more intense colors
|
||||
icon_color = {
|
||||
"strongly_rising": "var(--error-color)", # Red for strongly rising (very expensive)
|
||||
"rising": "var(--warning-color)", # Orange/Yellow for rising prices
|
||||
"stable": "var(--state-icon-color)", # Default gray for stable prices
|
||||
"falling": "var(--success-color)", # Green for falling prices (cheaper)
|
||||
"strongly_falling": "var(--success-color)", # Green for strongly falling (great deal)
|
||||
}.get(trend_state, "var(--state-icon-color)")
|
||||
# Determine icon color via centralized mapping (same as colors.py)
|
||||
icon_color = get_icon_color(f"price_outlook_{hours}h", trend_state) or "var(--state-icon-color)"
|
||||
|
||||
# Convert prices to display currency unit based on configuration
|
||||
factor = get_display_unit_factor(self.config_entry)
|
||||
precision = get_display_precision(self.config_entry)
|
||||
|
||||
# Store attributes in sensor-specific dictionary AND cache the trend value
|
||||
# Show effective thresholds (after volatility adjustment) so users can understand
|
||||
|
|
@ -188,7 +178,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
|
|||
"timestamp": next_interval_start,
|
||||
"trend_value": trend_value,
|
||||
f"trend_{hours}h_%": round(diff_pct, 1),
|
||||
f"next_{hours}h_avg": round(future_mean * factor, 2),
|
||||
f"next_{hours}h_avg": round(future_mean * factor, precision),
|
||||
"interval_count": lookahead_intervals,
|
||||
"threshold_rising_%": round(threshold_rising * vol_factor, 1),
|
||||
"threshold_rising_strongly_%": round(threshold_strongly_rising * vol_factor, 1),
|
||||
|
|
@ -203,7 +193,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
|
|||
# Get second half average for longer periods
|
||||
later_half_avg = self._calculate_later_half_average(hours, next_interval_start)
|
||||
if later_half_avg is not None:
|
||||
self._trend_attributes[f"second_half_{hours}h_avg"] = round(later_half_avg * factor, 2)
|
||||
self._trend_attributes[f"second_half_{hours}h_avg"] = round(later_half_avg * factor, precision)
|
||||
|
||||
# Calculate incremental change: how much does the later half differ from current?
|
||||
# CRITICAL: Use abs() for negative prices and allow calculation for all non-zero prices
|
||||
|
|
@ -237,15 +227,17 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
|
|||
# price direction BEFORE the current trend (binary: rising/falling),
|
||||
# not the trend classification. next_price_trend_change uses "from_direction"
|
||||
# for the current 5-level trend state.
|
||||
current_trend_state = trend_info["current_trend_state"]
|
||||
self._current_trend_attributes = {
|
||||
"previous_direction": trend_info["from_direction"],
|
||||
"price_direction_duration_minutes": trend_info["trend_duration_minutes"],
|
||||
"price_direction_since": (
|
||||
trend_info["trend_start_time"].isoformat() if trend_info["trend_start_time"] else None
|
||||
),
|
||||
"icon_color": get_icon_color("current_price_trend", current_trend_state),
|
||||
}
|
||||
|
||||
return trend_info["current_trend_state"]
|
||||
return current_trend_state
|
||||
|
||||
def get_next_trend_change_value(self) -> datetime | None:
|
||||
"""
|
||||
|
|
@ -308,7 +300,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
|
|||
Trend state: "rising" | "falling" | "stable", or None if unavailable
|
||||
|
||||
"""
|
||||
if hours < 2: # noqa: PLR2004
|
||||
if hours < 2:
|
||||
return None
|
||||
|
||||
if not self.has_data():
|
||||
|
|
@ -378,6 +370,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
|
|||
)
|
||||
|
||||
factor = get_display_unit_factor(self.config_entry)
|
||||
precision = get_display_precision(self.config_entry)
|
||||
time_obj = self.coordinator.time
|
||||
total_intervals = time_obj.minutes_to_intervals(hours * 60)
|
||||
first_half_count = total_intervals // 2
|
||||
|
|
@ -387,8 +380,8 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
|
|||
"timestamp": next_interval_start,
|
||||
"trend_value": trend_value,
|
||||
f"trajectory_{hours}h_%": round(diff_pct, 1),
|
||||
f"first_half_{hours}h_avg": round(first_half_avg * factor, 2),
|
||||
f"second_half_{hours}h_avg": round(second_half_avg * factor, 2),
|
||||
f"first_half_{hours}h_avg": round(first_half_avg * factor, precision),
|
||||
f"second_half_{hours}h_avg": round(second_half_avg * factor, precision),
|
||||
f"first_half_{hours}h_diff_from_current_%": round(
|
||||
((first_half_avg - current_interval_price) / abs(current_interval_price)) * 100, 1
|
||||
)
|
||||
|
|
@ -402,6 +395,7 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
|
|||
"first_half_interval_count": first_half_count,
|
||||
"second_half_interval_count": second_half_count,
|
||||
"volatility_factor": vol_factor,
|
||||
"icon_color": get_icon_color(f"price_trajectory_{hours}h", trajectory_state),
|
||||
}
|
||||
|
||||
return trajectory_state
|
||||
|
|
@ -910,16 +904,17 @@ class TibberPricesTrendCalculator(TibberPricesBaseCalculator):
|
|||
change_price = float(change_interval["total"])
|
||||
minutes_until = time.minutes_until_rounded(change_time)
|
||||
factor = get_display_unit_factor(self.config_entry)
|
||||
precision = get_display_precision(self.config_entry)
|
||||
vf = first_change["vol_factor"]
|
||||
|
||||
self._trend_change_attributes = {
|
||||
"direction": first_change["trend"],
|
||||
"from_direction": current_trend_state,
|
||||
"minutes_until_change": minutes_until,
|
||||
"price_now": round(float(current_interval["total"]) * factor, 2),
|
||||
"price_at_change": round(change_price * factor, 2),
|
||||
"price_now": round(float(current_interval["total"]) * factor, precision),
|
||||
"price_at_change": round(change_price * factor, precision),
|
||||
"price_avg_after_change": (
|
||||
round(first_change["mean"] * factor, 2) if first_change["mean"] else None
|
||||
round(first_change["mean"] * factor, precision) if first_change["mean"] else None
|
||||
),
|
||||
"trend_diff_%": round(first_change["diff"], 1),
|
||||
"threshold_rising_%": round(thresholds["rising"] * vf, 1),
|
||||
|
|
|
|||
|
|
@ -12,14 +12,12 @@ from custom_components.tibber_prices.const import (
|
|||
DEFAULT_VOLATILITY_THRESHOLD_HIGH,
|
||||
DEFAULT_VOLATILITY_THRESHOLD_MODERATE,
|
||||
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
|
||||
get_display_precision,
|
||||
get_display_unit_factor,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
from custom_components.tibber_prices.entity_utils import add_icon_color_attribute, find_rolling_hour_center_index
|
||||
from custom_components.tibber_prices.sensor.attributes import (
|
||||
add_volatility_type_attributes,
|
||||
get_prices_for_volatility,
|
||||
)
|
||||
from custom_components.tibber_prices.sensor.attributes import add_volatility_type_attributes, get_prices_for_volatility
|
||||
from custom_components.tibber_prices.utils.average import calculate_mean
|
||||
from custom_components.tibber_prices.utils.price import (
|
||||
calculate_iqr_stats,
|
||||
|
|
@ -103,6 +101,7 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
|
|||
|
||||
# Convert to display currency unit based on configuration
|
||||
factor = get_display_unit_factor(self.config_entry)
|
||||
precision = get_display_precision(self.config_entry)
|
||||
spread_display = spread * factor
|
||||
|
||||
# Calculate volatility level AND coefficient of variation
|
||||
|
|
@ -116,18 +115,18 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
|
|||
attrs: dict[str, Any] = {
|
||||
"price_volatility": volatility.lower(),
|
||||
"price_coefficient_variation_%": round(cv, 2) if cv is not None else None,
|
||||
"price_spread": round(spread_display, 2),
|
||||
"price_min": round(price_min * factor, 2),
|
||||
"price_max": round(price_max * factor, 2),
|
||||
"price_mean": round(price_mean * factor, 2),
|
||||
"price_spread": round(spread_display, precision),
|
||||
"price_min": round(price_min * factor, precision),
|
||||
"price_max": round(price_max * factor, precision),
|
||||
"price_mean": round(price_mean * factor, precision),
|
||||
}
|
||||
|
||||
# Add IQR attributes when enough data is available (stay in price_* group)
|
||||
if iqr_stats is not None:
|
||||
attrs["price_median"] = round(iqr_stats["median"] * factor, 2)
|
||||
attrs["price_q25"] = round(iqr_stats["q25"] * factor, 2)
|
||||
attrs["price_q75"] = round(iqr_stats["q75"] * factor, 2)
|
||||
attrs["price_typical_spread"] = round(iqr_stats["iqr"] * factor, 2)
|
||||
attrs["price_median"] = round(iqr_stats["median"] * factor, precision)
|
||||
attrs["price_q25"] = round(iqr_stats["q25"] * factor, precision)
|
||||
attrs["price_q75"] = round(iqr_stats["q75"] * factor, precision)
|
||||
attrs["price_typical_spread"] = round(iqr_stats["iqr"] * factor, precision)
|
||||
if iqr_stats["iqr_pct"] is not None:
|
||||
attrs["price_typical_spread_%"] = round(iqr_stats["iqr_pct"], 2)
|
||||
attrs["price_spike_count"] = iqr_stats["outlier_count"]
|
||||
|
|
@ -208,15 +207,16 @@ class TibberPricesVolatilityCalculator(TibberPricesBaseCalculator):
|
|||
|
||||
# Convert to display units for attribute storage
|
||||
factor = get_display_unit_factor(self.config_entry)
|
||||
precision = get_display_precision(self.config_entry)
|
||||
price_attr_key = self._get_subject_price_attr_key(subject)
|
||||
|
||||
self._last_percentile_rank_attributes = {
|
||||
price_attr_key: round(subject_price * factor, 2),
|
||||
price_attr_key: round(subject_price * factor, precision),
|
||||
"prices_below_count": bisect.bisect_left(sorted(reference_prices), subject_price),
|
||||
"interval_count": len(reference_prices),
|
||||
"reference_min": round(min(reference_prices) * factor, 2),
|
||||
"reference_max": round(max(reference_prices) * factor, 2),
|
||||
"reference_mean": round(calculate_mean(reference_prices) * factor, 2),
|
||||
"reference_min": round(min(reference_prices) * factor, precision),
|
||||
"reference_max": round(max(reference_prices) * factor, precision),
|
||||
"reference_mean": round(calculate_mean(reference_prices) * factor, precision),
|
||||
}
|
||||
|
||||
return rank
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.tibber_prices.const import get_display_precision
|
||||
from custom_components.tibber_prices.entity_utils import get_price_value
|
||||
|
||||
from .base import TibberPricesBaseCalculator
|
||||
|
|
@ -52,9 +53,10 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator):
|
|||
if value is None:
|
||||
return None
|
||||
# Convert to display currency units based on config
|
||||
mean_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), 2)
|
||||
precision = get_display_precision(self.coordinator.config_entry)
|
||||
mean_result = round(get_price_value(value, config_entry=self.coordinator.config_entry), precision)
|
||||
median_result = (
|
||||
round(get_price_value(median, config_entry=self.coordinator.config_entry), 2)
|
||||
round(get_price_value(median, config_entry=self.coordinator.config_entry), precision)
|
||||
if median is not None
|
||||
else None
|
||||
)
|
||||
|
|
@ -65,6 +67,7 @@ class TibberPricesWindow24hCalculator(TibberPricesBaseCalculator):
|
|||
if value is None:
|
||||
return None
|
||||
|
||||
# Return in configured display currency units with 2 decimals
|
||||
# Return in configured display currency units
|
||||
precision = get_display_precision(self.coordinator.config_entry)
|
||||
result = get_price_value(value, config_entry=self.coordinator.config_entry)
|
||||
return round(result, 2)
|
||||
return round(result, precision)
|
||||
|
|
|
|||
|
|
@ -2,54 +2,35 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime # noqa: TC003 - Used at runtime for _get_data_timestamp()
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.binary_sensor.attributes import (
|
||||
get_price_intervals_attributes,
|
||||
)
|
||||
from custom_components.tibber_prices.binary_sensor.attributes import get_price_intervals_attributes
|
||||
from custom_components.tibber_prices.const import (
|
||||
CONF_AVERAGE_SENSOR_DISPLAY,
|
||||
CONF_CURRENCY_DISPLAY_MODE,
|
||||
CONF_PRICE_RATING_THRESHOLD_HIGH,
|
||||
CONF_PRICE_RATING_THRESHOLD_LOW,
|
||||
DEFAULT_AVERAGE_SENSOR_DISPLAY,
|
||||
DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
|
||||
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
|
||||
DISPLAY_MODE_BASE,
|
||||
DOMAIN,
|
||||
format_price_unit_base,
|
||||
get_display_precision,
|
||||
get_display_unit_factor,
|
||||
get_display_unit_string,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator import (
|
||||
MINUTE_UPDATE_ENTITY_KEYS,
|
||||
TIME_SENSITIVE_ENTITY_KEYS,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.helpers import (
|
||||
get_intervals_for_day_offsets,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator import MINUTE_UPDATE_ENTITY_KEYS, TIME_SENSITIVE_ENTITY_KEYS
|
||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
from custom_components.tibber_prices.entity import TibberPricesEntity
|
||||
from custom_components.tibber_prices.entity_utils import (
|
||||
add_icon_color_attribute,
|
||||
find_rolling_hour_center_index,
|
||||
get_price_value,
|
||||
)
|
||||
from custom_components.tibber_prices.entity_utils.icons import (
|
||||
TibberPricesIconContext,
|
||||
get_dynamic_icon,
|
||||
)
|
||||
from custom_components.tibber_prices.utils.average import (
|
||||
calculate_next_n_hours_mean,
|
||||
)
|
||||
from custom_components.tibber_prices.utils.price import (
|
||||
calculate_volatility_level,
|
||||
)
|
||||
from homeassistant.components.sensor import (
|
||||
RestoreSensor,
|
||||
SensorDeviceClass,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from custom_components.tibber_prices.entity_utils.icons import TibberPricesIconContext, get_dynamic_icon
|
||||
from custom_components.tibber_prices.utils.average import calculate_next_n_hours_mean
|
||||
from custom_components.tibber_prices.utils.price import calculate_volatility_level
|
||||
from homeassistant.components.sensor import RestoreSensor, SensorDeviceClass, SensorEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import callback
|
||||
|
||||
|
|
@ -70,11 +51,7 @@ from .calculators import (
|
|||
TibberPricesVolatilityCalculator,
|
||||
TibberPricesWindow24hCalculator,
|
||||
)
|
||||
from .chart_data import (
|
||||
build_chart_data_attributes,
|
||||
call_chartdata_service_async,
|
||||
get_chart_data_state,
|
||||
)
|
||||
from .chart_data import build_chart_data_attributes, call_chartdata_service_async, get_chart_data_state
|
||||
from .chart_metadata import (
|
||||
build_chart_metadata_attributes,
|
||||
call_chartdata_service_for_metadata_async,
|
||||
|
|
@ -86,9 +63,7 @@ from .value_getters import get_value_getter_mapping
|
|||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from custom_components.tibber_prices.coordinator import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
|
||||
HOURS_IN_DAY = 24
|
||||
|
|
@ -426,6 +401,15 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
# Coordinator updates bring new API data — always write to ensure fresh state.
|
||||
# Reset _last_written_value so timer-based handlers also write next cycle.
|
||||
self._last_written_value = _SENTINEL
|
||||
|
||||
# Sync suggested display precision with entity registry. HA only updates
|
||||
# this on entity add and registry entry changes, not on state writes. When
|
||||
# the user switches currency display mode via options flow, we must push
|
||||
# the new precision to the registry explicitly. The call is cheap (property
|
||||
# read + dict comparison) and returns early when values already match.
|
||||
if self.registry_entry:
|
||||
self._update_suggested_precision()
|
||||
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
def _get_value_getter(self) -> Callable | None:
|
||||
|
|
@ -581,9 +565,10 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
self._last_extreme_interval = pi["interval"]
|
||||
break
|
||||
|
||||
# Return in configured display currency units with 2 decimals
|
||||
# Return in configured display currency units
|
||||
precision = get_display_precision(self.coordinator.config_entry)
|
||||
result = get_price_value(value, config_entry=self.coordinator.config_entry)
|
||||
return round(result, 2)
|
||||
return round(result, precision)
|
||||
|
||||
def _get_daily_aggregated_value(
|
||||
self,
|
||||
|
|
@ -659,9 +644,10 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
if value is None:
|
||||
return None
|
||||
|
||||
# Return in configured display currency units with 2 decimals
|
||||
# Return in configured display currency units
|
||||
precision = get_display_precision(self.coordinator.config_entry)
|
||||
result = get_price_value(value, config_entry=self.coordinator.config_entry)
|
||||
return round(result, 2)
|
||||
return round(result, precision)
|
||||
|
||||
def _translate_rating_level(self, level: str) -> str:
|
||||
"""Translate the rating level using custom translations, falling back to English or the raw value."""
|
||||
|
|
@ -710,6 +696,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
|
||||
# Get display unit factor (100 for minor, 1 for major)
|
||||
factor = get_display_unit_factor(self.coordinator.config_entry)
|
||||
precision = get_display_precision(self.coordinator.config_entry)
|
||||
|
||||
# Get user preference for display (mean or median)
|
||||
display_pref = self.coordinator.config_entry.options.get(
|
||||
|
|
@ -717,14 +704,14 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
)
|
||||
|
||||
# Store both values for attributes
|
||||
self.cached_data[f"next_avg_{hours}h_mean"] = round(mean_price * factor, 2)
|
||||
self.cached_data[f"next_avg_{hours}h_mean"] = round(mean_price * factor, precision)
|
||||
if median_price is not None:
|
||||
self.cached_data[f"next_avg_{hours}h_median"] = round(median_price * factor, 2)
|
||||
self.cached_data[f"next_avg_{hours}h_median"] = round(median_price * factor, precision)
|
||||
|
||||
# Return the value chosen for state display
|
||||
if display_pref == "median" and median_price is not None:
|
||||
return round(median_price * factor, 2)
|
||||
return round(mean_price * factor, 2) # "mean"
|
||||
return round(median_price * factor, precision)
|
||||
return round(mean_price * factor, precision) # "mean"
|
||||
|
||||
def _get_data_timestamp(self) -> datetime | None:
|
||||
"""
|
||||
|
|
@ -916,7 +903,7 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
return True
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | str | datetime | None: # noqa: PLR0912
|
||||
def native_value(self) -> float | str | datetime | None:
|
||||
"""Return the native value of the sensor."""
|
||||
try:
|
||||
if not self.coordinator.data or not self._value_getter:
|
||||
|
|
@ -1043,28 +1030,6 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
key = self.entity_description.key
|
||||
value = self.native_value
|
||||
|
||||
# Icon mapping for trend directions (5-level scale)
|
||||
trend_icons = {
|
||||
"strongly_rising": "mdi:chevron-double-up",
|
||||
"rising": "mdi:trending-up",
|
||||
"stable": "mdi:trending-neutral",
|
||||
"falling": "mdi:trending-down",
|
||||
"strongly_falling": "mdi:chevron-double-down",
|
||||
}
|
||||
|
||||
# Special handling for next_price_trend_change: Icon based on direction attribute
|
||||
if key == "next_price_trend_change":
|
||||
trend_change_attrs = self._trend_calculator.get_trend_change_attributes()
|
||||
if trend_change_attrs:
|
||||
direction = trend_change_attrs.get("direction")
|
||||
if isinstance(direction, str):
|
||||
return trend_icons.get(direction, "mdi:help-circle-outline")
|
||||
return "mdi:help-circle-outline"
|
||||
|
||||
# Special handling for current_price_trend: Icon based on current state value
|
||||
if key == "current_price_trend" and isinstance(value, str):
|
||||
return trend_icons.get(value, "mdi:help-circle-outline")
|
||||
|
||||
# Create callback for period active state check (used by timing sensors)
|
||||
period_is_active_callback = None
|
||||
if key.startswith("best_price_"):
|
||||
|
|
@ -1072,6 +1037,13 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
elif key.startswith("peak_price_"):
|
||||
period_is_active_callback = self._is_peak_price_period_active
|
||||
|
||||
# For next_price_trend_change, pass direction from cached attributes via context
|
||||
trend_change_direction = None
|
||||
if key == "next_price_trend_change":
|
||||
trend_change_attrs = self._trend_calculator.get_trend_change_attributes()
|
||||
if trend_change_attrs:
|
||||
trend_change_direction = trend_change_attrs.get("direction")
|
||||
|
||||
# Use centralized icon logic with context
|
||||
icon = get_dynamic_icon(
|
||||
key=key,
|
||||
|
|
@ -1080,24 +1052,37 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
coordinator_data=self.coordinator.data,
|
||||
period_is_active_callback=period_is_active_callback,
|
||||
time=self.coordinator.time,
|
||||
trend_change_direction=trend_change_direction,
|
||||
),
|
||||
)
|
||||
|
||||
# Fall back to static icon from entity description
|
||||
return icon or self.entity_description.icon
|
||||
|
||||
# Interval price sensors that show individual quarter-hour prices.
|
||||
# These get full data precision (subunit→2, base→4) as display default
|
||||
# because users rely on exact values for automations and dashboards.
|
||||
_INTERVAL_PRICE_KEYS = frozenset(
|
||||
{
|
||||
"current_interval_price",
|
||||
"current_interval_price_base",
|
||||
"next_interval_price",
|
||||
"previous_interval_price",
|
||||
}
|
||||
)
|
||||
|
||||
@property
|
||||
def suggested_display_precision(self) -> int | None:
|
||||
"""
|
||||
Return suggested display precision based on currency display mode.
|
||||
|
||||
For MONETARY sensors:
|
||||
- Current/Next Interval Price: Show exact price with higher precision
|
||||
- Base currency (€/kr): 4 decimals (e.g., 0.1234 €)
|
||||
- Subunit currency (ct/øre): 2 decimals (e.g., 12.34 ct)
|
||||
- All other price sensors:
|
||||
- Base currency (€/kr): 2 decimals (e.g., 0.12 €)
|
||||
- Subunit currency (ct/øre): 1 decimal (e.g., 12.5 ct)
|
||||
- Interval price sensors: Full precision (subunit→2, base→4)
|
||||
- Energy Dashboard sensor (current_interval_price_base): Always 4
|
||||
- All other price sensors: Reduced precision (subunit→1, base→2)
|
||||
|
||||
The actual state value retains full rounded precision (2 or 4 decimals),
|
||||
so users can increase display precision in the HA UI to see more detail.
|
||||
|
||||
For non-MONETARY sensors, use static value from entity description.
|
||||
"""
|
||||
|
|
@ -1105,23 +1090,16 @@ class TibberPricesSensor(TibberPricesEntity, RestoreSensor):
|
|||
if self.entity_description.device_class != SensorDeviceClass.MONETARY:
|
||||
return self.entity_description.suggested_display_precision
|
||||
|
||||
# Check display mode configuration
|
||||
display_mode = self.coordinator.config_entry.options.get(CONF_CURRENCY_DISPLAY_MODE, DISPLAY_MODE_BASE)
|
||||
|
||||
# Special case: Energy Dashboard sensor always shows base currency with 4 decimals
|
||||
# regardless of display mode (it's always in base currency by design)
|
||||
# Energy Dashboard sensor: always base currency, always 4 decimals
|
||||
if self.entity_description.key == "current_interval_price_base":
|
||||
return 4
|
||||
|
||||
# Special case: Current and Next interval price sensors get higher precision
|
||||
# to show exact prices as received from API
|
||||
if self.entity_description.key in ("current_interval_price", "next_interval_price"):
|
||||
# Major: 4 decimals (0.1234 €), Minor: 2 decimals (12.34 ct)
|
||||
return 4 if display_mode == DISPLAY_MODE_BASE else 2
|
||||
# Interval price sensors: full data precision (subunit→2, base→4)
|
||||
if self.entity_description.key in self._INTERVAL_PRICE_KEYS:
|
||||
return get_display_precision(self.coordinator.config_entry)
|
||||
|
||||
# All other sensors: Standard precision
|
||||
# Major: 2 decimals (0.12 €), Minor: 1 decimal (12.5 ct)
|
||||
return 2 if display_mode == DISPLAY_MODE_BASE else 1
|
||||
# All other MONETARY sensors: reduced precision (subunit→1, base→2)
|
||||
return get_display_precision(self.coordinator.config_entry) // 2
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
|
|
|
|||
|
|
@ -18,19 +18,8 @@ Organization by calculation pattern:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
EntityCategory,
|
||||
UnitOfArea,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfEnergy,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription, SensorStateClass
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfElectricCurrent, UnitOfEnergy, UnitOfTime
|
||||
|
||||
# ============================================================================
|
||||
# SENSOR DEFINITIONS - Grouped by calculation method
|
||||
|
|
|
|||
|
|
@ -16,12 +16,9 @@ from __future__ import annotations
|
|||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from custom_components.tibber_prices.const import get_display_unit_factor
|
||||
from custom_components.tibber_prices.const import get_display_precision, get_display_unit_factor
|
||||
from custom_components.tibber_prices.utils.average import calculate_mean, calculate_median
|
||||
from custom_components.tibber_prices.utils.price import (
|
||||
aggregate_price_levels,
|
||||
aggregate_price_rating,
|
||||
)
|
||||
from custom_components.tibber_prices.utils.price import aggregate_price_levels, aggregate_price_rating
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
|
@ -51,7 +48,8 @@ def aggregate_average_data(
|
|||
median = calculate_median(prices)
|
||||
# Convert to display currency unit based on configuration
|
||||
factor = get_display_unit_factor(config_entry)
|
||||
return round(mean * factor, 2), round(median * factor, 2) if median is not None else None
|
||||
precision = get_display_precision(config_entry)
|
||||
return round(mean * factor, precision), round(median * factor, precision) if median is not None else None
|
||||
|
||||
|
||||
def aggregate_level_data(window_data: list[dict]) -> str | None:
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ if TYPE_CHECKING:
|
|||
from custom_components.tibber_prices.sensor.calculators.window_24h import TibberPricesWindow24hCalculator
|
||||
|
||||
|
||||
def get_value_getter_mapping( # noqa: PLR0913 - needs all calculators as parameters
|
||||
def get_value_getter_mapping(
|
||||
interval_calculator: TibberPricesIntervalCalculator,
|
||||
rolling_hour_calculator: TibberPricesRollingHourCalculator,
|
||||
daily_stat_calculator: TibberPricesDailyStatCalculator,
|
||||
|
|
|
|||
|
|
@ -50,11 +50,7 @@ from .find_most_expensive_hours import (
|
|||
FIND_MOST_EXPENSIVE_HOURS_SERVICE_SCHEMA,
|
||||
handle_find_most_expensive_hours,
|
||||
)
|
||||
from .get_apexcharts_yaml import (
|
||||
APEXCHARTS_SERVICE_SCHEMA,
|
||||
APEXCHARTS_YAML_SERVICE_NAME,
|
||||
handle_apexcharts_yaml,
|
||||
)
|
||||
from .get_apexcharts_yaml import APEXCHARTS_SERVICE_SCHEMA, APEXCHARTS_YAML_SERVICE_NAME, handle_apexcharts_yaml
|
||||
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 .refresh_user_data import (
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ After calling this service:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
|
|
|||
|
|
@ -8,25 +8,21 @@ machine, dryer).
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
DOMAIN,
|
||||
get_display_unit_factor,
|
||||
get_display_unit_string,
|
||||
)
|
||||
from custom_components.tibber_prices.const import DOMAIN, get_display_unit_factor, get_display_unit_string
|
||||
from custom_components.tibber_prices.utils.price_window import (
|
||||
calculate_window_statistics,
|
||||
find_cheapest_contiguous_window,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util import dt as dt_utils
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .helpers import (
|
||||
INTERVAL_MINUTES,
|
||||
|
|
@ -143,7 +139,7 @@ def _determine_no_window_reason(
|
|||
return "insufficient_contiguous_window"
|
||||
|
||||
|
||||
async def _handle_find_block( # noqa: PLR0915
|
||||
async def _handle_find_block(
|
||||
call: ServiceCall,
|
||||
*,
|
||||
reverse: bool = False,
|
||||
|
|
@ -187,7 +183,7 @@ async def _handle_find_block( # noqa: PLR0915
|
|||
home_tz = ZoneInfo(home_timezone)
|
||||
|
||||
# Resolve search range (priority: explicit datetime > time+offset > minutes offset > default)
|
||||
now = dt_utils.now().astimezone(home_tz)
|
||||
now = dt_util.now().astimezone(home_tz)
|
||||
search_start, search_end = resolve_search_range(call.data, now, home_tz)
|
||||
|
||||
duration_intervals = duration_minutes // INTERVAL_MINUTES
|
||||
|
|
|
|||
|
|
@ -8,25 +8,18 @@ Intervals need not be contiguous — designed for flexible loads
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
DOMAIN,
|
||||
get_display_unit_factor,
|
||||
get_display_unit_string,
|
||||
)
|
||||
from custom_components.tibber_prices.utils.price_window import (
|
||||
calculate_window_statistics,
|
||||
find_cheapest_n_intervals,
|
||||
)
|
||||
from custom_components.tibber_prices.const import DOMAIN, get_display_unit_factor, get_display_unit_string
|
||||
from custom_components.tibber_prices.utils.price_window import calculate_window_statistics, find_cheapest_n_intervals
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util import dt as dt_utils
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .helpers import (
|
||||
INTERVAL_MINUTES,
|
||||
|
|
@ -101,7 +94,7 @@ def _determine_no_intervals_reason(
|
|||
return "insufficient_intervals_for_constraints"
|
||||
|
||||
|
||||
def _build_found_response( # noqa: PLR0913
|
||||
def _build_found_response(
|
||||
*,
|
||||
result: dict,
|
||||
comparison_result: dict | None,
|
||||
|
|
@ -202,7 +195,7 @@ def _build_found_response( # noqa: PLR0913
|
|||
}
|
||||
|
||||
|
||||
async def _handle_find_hours( # noqa: PLR0915
|
||||
async def _handle_find_hours(
|
||||
call: ServiceCall,
|
||||
*,
|
||||
reverse: bool = False,
|
||||
|
|
@ -251,7 +244,7 @@ async def _handle_find_hours( # noqa: PLR0915
|
|||
home_tz = ZoneInfo(home_timezone)
|
||||
|
||||
# Resolve search range (priority: explicit datetime > time+offset > minutes offset > default)
|
||||
now = dt_utils.now().astimezone(home_tz)
|
||||
now = dt_util.now().astimezone(home_tz)
|
||||
search_start, search_end = resolve_search_range(call.data, now, home_tz)
|
||||
|
||||
total_intervals = total_minutes // INTERVAL_MINUTES
|
||||
|
|
|
|||
|
|
@ -8,25 +8,21 @@ each task claims the cheapest available contiguous window in the remaining pool.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import math
|
||||
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 custom_components.tibber_prices.const import DOMAIN, get_display_unit_factor, get_display_unit_string
|
||||
from custom_components.tibber_prices.utils.price_window import (
|
||||
calculate_window_statistics,
|
||||
find_cheapest_contiguous_window,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util import dt as dt_utils
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .helpers import (
|
||||
INTERVAL_MINUTES,
|
||||
|
|
@ -213,7 +209,7 @@ def _find_cheapest_window_in_pool(
|
|||
return (best_start, best_start + duration_intervals)
|
||||
|
||||
|
||||
async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse: # noqa: PLR0915
|
||||
async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle find_cheapest_schedule service call."""
|
||||
service_label = "find_cheapest_schedule"
|
||||
hass: HomeAssistant = call.hass
|
||||
|
|
@ -255,7 +251,7 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
|
|||
|
||||
home_tz = ZoneInfo(home_timezone)
|
||||
|
||||
now = dt_utils.now().astimezone(home_tz)
|
||||
now = dt_util.now().astimezone(home_tz)
|
||||
search_start, search_end = resolve_search_range(call.data, now, home_tz)
|
||||
|
||||
# Resolve task durations (round up to intervals)
|
||||
|
|
|
|||
|
|
@ -30,9 +30,7 @@ from custom_components.tibber_prices.const import (
|
|||
DEFAULT_PRICE_RATING_THRESHOLD_LOW,
|
||||
get_translation,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.helpers import (
|
||||
get_intervals_for_day_offsets,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
from custom_components.tibber_prices.sensor.helpers import aggregate_level_data, aggregate_rating_data
|
||||
from custom_components.tibber_prices.utils.average import calculate_mean, calculate_median
|
||||
|
||||
|
|
@ -51,7 +49,7 @@ def normalize_rating_level_filter(value: list[str] | None) -> list[str] | None:
|
|||
return [v.upper() for v in value]
|
||||
|
||||
|
||||
def aggregate_to_hourly( # noqa: PLR0912
|
||||
def aggregate_to_hourly(
|
||||
intervals: list[dict],
|
||||
coordinator: Any,
|
||||
threshold_low: float = DEFAULT_PRICE_RATING_THRESHOLD_LOW,
|
||||
|
|
@ -166,7 +164,7 @@ def aggregate_to_hourly( # noqa: PLR0912
|
|||
return hourly_data
|
||||
|
||||
|
||||
def aggregate_hourly_exact( # noqa: PLR0913, PLR0912, PLR0915
|
||||
def aggregate_hourly_exact(
|
||||
intervals: list[dict],
|
||||
start_time_field: str,
|
||||
price_field: str,
|
||||
|
|
@ -316,7 +314,7 @@ def aggregate_hourly_exact( # noqa: PLR0913, PLR0912, PLR0915
|
|||
return hourly_data
|
||||
|
||||
|
||||
def get_period_data( # noqa: PLR0913, PLR0912, PLR0915
|
||||
def get_period_data(
|
||||
*,
|
||||
coordinator: Any,
|
||||
period_filter: str,
|
||||
|
|
|
|||
|
|
@ -37,12 +37,7 @@ from custom_components.tibber_prices.const import (
|
|||
get_translation,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_registry import (
|
||||
EntityRegistry,
|
||||
)
|
||||
from homeassistant.helpers.entity_registry import (
|
||||
async_get as async_get_entity_registry,
|
||||
)
|
||||
from homeassistant.helpers.entity_registry import EntityRegistry, async_get as async_get_entity_registry
|
||||
|
||||
from .formatters import get_level_translation
|
||||
from .helpers import get_entry_and_data
|
||||
|
|
@ -265,7 +260,7 @@ def _get_missing_cards_notification(language: str, missing_cards: list[str]) ->
|
|||
return {"title": title, "message": message}
|
||||
|
||||
|
||||
async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: PLR0912, PLR0915, C901
|
||||
async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: # noqa: C901
|
||||
"""
|
||||
Return YAML snippet for ApexCharts card.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ Response: JSON with chart-ready data
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import voluptuous as vol
|
||||
|
|
@ -49,17 +49,10 @@ from custom_components.tibber_prices.const import (
|
|||
get_currency_info,
|
||||
get_currency_name,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.helpers import (
|
||||
get_intervals_for_day_offsets,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
|
||||
from .formatters import (
|
||||
aggregate_to_hourly,
|
||||
get_period_data,
|
||||
normalize_level_filter,
|
||||
normalize_rating_level_filter,
|
||||
)
|
||||
from .formatters import aggregate_to_hourly, get_period_data, normalize_level_filter, normalize_rating_level_filter
|
||||
from .helpers import get_entry_and_data, has_tomorrow_data
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -92,7 +85,7 @@ def _is_transition_to_more_expensive(
|
|||
return next_rank > current_rank
|
||||
|
||||
|
||||
def _calculate_metadata( # noqa: PLR0912, PLR0913, PLR0915
|
||||
def _calculate_metadata(
|
||||
chart_data: list[dict[str, Any]],
|
||||
price_field: str,
|
||||
start_time_field: str,
|
||||
|
|
@ -320,7 +313,7 @@ CHARTDATA_SERVICE_SCHEMA: Final = vol.Schema(
|
|||
)
|
||||
|
||||
|
||||
async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR0912, PLR0915, C901
|
||||
async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: C901
|
||||
"""
|
||||
Return price data in chart-friendly format.
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import voluptuous as vol
|
|||
from custom_components.tibber_prices.const import DOMAIN
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util import dt as dt_utils
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .helpers import get_entry_and_data
|
||||
|
||||
|
|
@ -116,13 +116,13 @@ async def handle_get_price(call: ServiceCall) -> ServiceResponse:
|
|||
|
||||
if start_time.tzinfo is None:
|
||||
# Step 1: Localize to HA server timezone
|
||||
start_time = dt_utils.as_local(start_time)
|
||||
start_time = dt_util.as_local(start_time)
|
||||
# Step 2: Convert to home timezone
|
||||
start_time = start_time.astimezone(home_tz)
|
||||
|
||||
if end_time.tzinfo is None:
|
||||
# Step 1: Localize to HA server timezone
|
||||
end_time = dt_utils.as_local(end_time)
|
||||
end_time = dt_util.as_local(end_time)
|
||||
# Step 2: Convert to home timezone
|
||||
end_time = end_time.astimezone(home_tz)
|
||||
|
||||
|
|
|
|||
|
|
@ -29,14 +29,13 @@ Used by:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import time as dt_time
|
||||
from datetime import datetime, time as dt_time, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.const import DOMAIN
|
||||
from custom_components.tibber_prices.coordinator.helpers import get_intervals_for_day_offsets
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.util import dt as dt_utils
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from zoneinfo import ZoneInfo
|
||||
|
|
@ -266,13 +265,13 @@ def localize_to_home_tz(dt_value: datetime, home_tz: ZoneInfo) -> datetime:
|
|||
2. Convert from HA timezone to home timezone
|
||||
"""
|
||||
if dt_value.tzinfo is None:
|
||||
dt_value = dt_utils.as_local(dt_value)
|
||||
dt_value = dt_util.as_local(dt_value)
|
||||
return dt_value.astimezone(home_tz)
|
||||
|
||||
|
||||
def calculate_end_of_tomorrow(home_tz: ZoneInfo) -> datetime:
|
||||
"""Calculate end of tomorrow in home timezone."""
|
||||
now_home = dt_utils.now().astimezone(home_tz)
|
||||
now_home = dt_util.now().astimezone(home_tz)
|
||||
tomorrow = (now_home + timedelta(days=1)).date()
|
||||
# End of tomorrow = midnight at start of day after tomorrow
|
||||
return now_home.replace(
|
||||
|
|
@ -295,9 +294,10 @@ def _resolve_time_with_day_offset(
|
|||
time_value: dt_time,
|
||||
day_offset: int,
|
||||
home_tz: ZoneInfo,
|
||||
now: datetime,
|
||||
) -> datetime:
|
||||
"""Resolve a time-of-day + day offset to a full datetime in home timezone."""
|
||||
now_home = dt_utils.now().astimezone(home_tz)
|
||||
now_home = now.astimezone(home_tz)
|
||||
target_date = (now_home + timedelta(days=day_offset)).date()
|
||||
return datetime(
|
||||
year=target_date.year,
|
||||
|
|
@ -477,7 +477,7 @@ def resolve_search_range(
|
|||
search_start = localize_to_home_tz(call_data["search_start"], home_tz)
|
||||
elif "search_start_time" in call_data:
|
||||
day_offset = call_data.get("search_start_day_offset", 0)
|
||||
search_start = _resolve_time_with_day_offset(call_data["search_start_time"], day_offset, home_tz)
|
||||
search_start = _resolve_time_with_day_offset(call_data["search_start_time"], day_offset, home_tz, now)
|
||||
elif "search_start_offset_minutes" in call_data:
|
||||
search_start = now + timedelta(minutes=call_data["search_start_offset_minutes"])
|
||||
if include_current:
|
||||
|
|
@ -490,7 +490,7 @@ def resolve_search_range(
|
|||
search_end = localize_to_home_tz(call_data["search_end"], home_tz)
|
||||
elif "search_end_time" in call_data:
|
||||
day_offset = call_data.get("search_end_day_offset", 0)
|
||||
search_end = _resolve_time_with_day_offset(call_data["search_end_time"], day_offset, home_tz)
|
||||
search_end = _resolve_time_with_day_offset(call_data["search_end_time"], day_offset, home_tz, now)
|
||||
elif "search_end_offset_minutes" in call_data:
|
||||
search_end = now + timedelta(minutes=call_data["search_end_offset_minutes"])
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -11,20 +11,14 @@ from __future__ import annotations
|
|||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from custom_components.tibber_prices.const import (
|
||||
DOMAIN,
|
||||
get_home_type_translation,
|
||||
get_translation,
|
||||
)
|
||||
from custom_components.tibber_prices.const import DOMAIN, get_home_type_translation, get_translation
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from custom_components.tibber_prices.coordinator import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator import TibberPricesDataUpdateCoordinator
|
||||
|
||||
from .definitions import TibberPricesSwitchEntityDescription
|
||||
|
||||
|
|
|
|||
|
|
@ -1153,6 +1153,10 @@
|
|||
"entity_migration": {
|
||||
"title": "Tibber Prices: Aktion nach Update erforderlich ({home_name})",
|
||||
"description": "Dieses Update enthält Breaking Changes, die automatisch angewendet wurden.\n\n**Umbenannte Entitäten ({count})**\n\nDie folgenden Entity-Keys wurden umbenannt. Deine bestehenden Entity-IDs und Automationen bleiben erhalten:\n\n{entity_list}\n\n**Geänderte Dauer-Sensorwerte**\n\nAlle Dauer-Sensoren (verbleibende Zeit, startet in, Periodendauer, Trendänderungs-Countdown) geben ihren Zustandswert jetzt in **Minuten** statt Stunden an. Die Anzeigeeinheit in Dashboards bleibt standardmäßig Stunden.\n\nWenn du Automationen mit numerischen Vergleichen auf diesen Sensoren hast, aktualisiere deine Schwellwerte:\n- Alt: `state < 0.25` (15 Minuten als Stunden)\n- Neu: `state < 15` (15 Minuten)\n\nSchließe diesen Hinweis, nachdem du deine Automationen überprüft hast."
|
||||
},
|
||||
"currency_display_mode_changed": {
|
||||
"title": "Währungsanzeigeeinheit für {home_name} geändert",
|
||||
"description": "Du hast den Währungsanzeigemodus für **{home_name}** geändert. Alle Preissensor-Werte und -Attribute verwenden jetzt die neue Einheit (z.B. 25,34 ct → 0,2534 € oder umgekehrt).\n\nDer Recorder von Home Assistant zeigt separat einen Dialog **„Die Einheit hat sich geändert“** für betroffene Sensoren — das kann einige Minuten dauern oder bis zum nächsten Statistik-Durchlauf (Warnungen im Log erscheinen früher). Wähle dann **Alle alten Statistikdaten löschen** für einen sauberen Neustart. Wähle nicht „Einheit aktualisieren ohne Konvertierung“: das benennt die alten Zahlen nur um, ohne die Werte anzupassen, und macht die historischen Daten inhaltlich falsch.\n\n**Manuell prüfen:**\n\n1. **Automationen & Templates:** Aktualisiere alle Automationen und Template-Sensoren mit numerischen Preis-Schwellwerten.\n2. **Dashboard-Karten:** Aktualisiere alle Karten mit fest codierten Schwellwerten oder Einheitenbezeichnungen.\n\nSchließe diesen Hinweis, nachdem du deine Automationen und Dashboards überprüft hast."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
|
|
|||
|
|
@ -1153,6 +1153,10 @@
|
|||
"entity_migration": {
|
||||
"title": "Tibber Prices: Action required after update ({home_name})",
|
||||
"description": "This update includes breaking changes that were applied automatically.\n\n**Renamed Entities ({count})**\n\nThe following entity keys were renamed. Your existing entity IDs and automations remain intact:\n\n{entity_list}\n\n**Duration Sensor Value Change**\n\nAll duration sensors (remaining time, starts in, period duration, trend change countdown) now report their state value in **minutes** instead of hours. The display unit in dashboards remains hours by default.\n\nIf you have automations using numeric comparisons on these sensors, update your thresholds:\n- Old: `state < 0.25` (15 minutes as hours)\n- New: `state < 15` (15 minutes)\n\nDismiss this notice after reviewing your automations."
|
||||
},
|
||||
"currency_display_mode_changed": {
|
||||
"title": "Currency display unit changed for {home_name}",
|
||||
"description": "You changed the currency display mode for **{home_name}**. All price sensor values and attributes now use the new unit (e.g. 25.34 ct → 0.2534 € or vice versa).\n\nHome Assistant’s Recorder will separately show a **“The unit has changed”** dialog for affected sensors — this may take a few minutes or until the next statistics run (log warnings appear earlier). When it appears, choose **Delete all old statistic data** to start fresh. Do not choose “Update the unit without converting”: that re-labels the old numbers without adjusting their values, making the historic data factually incorrect.\n\n**Review manually:**\n\n1. **Automations & Templates:** Update all automations and template sensors that use numeric price thresholds.\n2. **Dashboard Cards:** Update any cards with hardcoded thresholds or unit labels.\n\nDismiss this notice after reviewing your automations and dashboards."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
|
|
|||
|
|
@ -1153,6 +1153,10 @@
|
|||
"entity_migration": {
|
||||
"title": "Tibber Prices: Handling kreves etter oppdatering ({home_name})",
|
||||
"description": "Denne oppdateringen inkluderer endringer som ble brukt automatisk.\n\n**Omdøpte entiteter ({count})**\n\nFølgende entity-nøkler ble omdøpt. Dine eksisterende entity-ID-er og automatiseringer forblir intakte:\n\n{entity_list}\n\n**Endrede varighetssensorverdier**\n\nAlle varighetssensorer (gjenværende tid, starter om, periodevarighet, trendendrings-nedtelling) rapporterer nå tilstandsverdien i **minutter** i stedet for timer. Visningsenheten i dashboards forblir timer som standard.\n\nHvis du har automatiseringer med numeriske sammenligninger på disse sensorene, oppdater tersklene:\n- Gammelt: `state < 0.25` (15 minutter som timer)\n- Nytt: `state < 15` (15 minutter)\n\nAvvis dette varselet etter å ha gjennomgått automatiseringene dine."
|
||||
},
|
||||
"currency_display_mode_changed": {
|
||||
"title": "Valutavisningsenhet endret for {home_name}",
|
||||
"description": "Du endret valutavisningsmodusen for **{home_name}**. Alle prissensorverdier og -attributter bruker nå den nye enheten (f.eks. 25,34 øre → 0,2534 kr eller omvendt).\n\nHome Assistants Recorder viser separat en **„Enheten har endret seg“**-dialog for berørte sensorer — dette kan ta noen minutter eller til neste statistikkjøring (advarsler i loggen dukker opp tidligere). Når den vises, velg **Slett alle gamle statistikkdata** for en ren start. Ikke velg „Oppdater enheten uten konvertering“: det beholder de gamle tallene med ny enhet uten å justere verdiene, og gjør de historiske dataene faktisk feil.\n\n**Gjennomgå manuelt:**\n\n1. **Automatiseringer & maler:** Oppdater alle automatiseringer og malsensorer som bruker numeriske pristerskler.\n2. **Dashboard-kort:** Oppdater kort med hardkodede terskelverdier eller enhetsetiketter.\n\nAvvis dette varselet etter å ha gjennomgått automatiseringene og dashboardene dine."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
|
|
|||
|
|
@ -1153,6 +1153,10 @@
|
|||
"entity_migration": {
|
||||
"title": "Tibber Prices: Actie vereist na update ({home_name})",
|
||||
"description": "Deze update bevat wijzigingen die automatisch zijn toegepast.\n\n**Hernoemde entiteiten ({count})**\n\nDe volgende entity-sleutels zijn hernoemd. Je bestaande entity-ID's en automatiseringen blijven intact:\n\n{entity_list}\n\n**Gewijzigde duur-sensorwaarden**\n\nAlle duur-sensoren (resterende tijd, start over, periodeduur, trendwijzigings-aftelling) rapporteren hun statuswaarde nu in **minuten** in plaats van uren. De weergave-eenheid in dashboards blijft standaard uren.\n\nAls je automatiseringen hebt met numerieke vergelijkingen op deze sensoren, werk dan je drempelwaarden bij:\n- Oud: `state < 0.25` (15 minuten als uren)\n- Nieuw: `state < 15` (15 minuten)\n\nSluit deze melding nadat je je automatiseringen hebt gecontroleerd."
|
||||
},
|
||||
"currency_display_mode_changed": {
|
||||
"title": "Valutaweergave-eenheid gewijzigd voor {home_name}",
|
||||
"description": "Je hebt de valutaweergavemodus voor **{home_name}** gewijzigd. Alle prijssensorwaarden en -attributen gebruiken nu de nieuwe eenheid (bijv. 25,34 ct → 0,2534 € of andersom).\n\nHome Assistant’s Recorder toont afzonderlijk een **„De eenheid is gewijzigd“**-dialoogvenster voor getroffen sensoren — dit kan enkele minuten duren of tot de volgende statistiekenrun (logwaarschuwingen verschijnen eerder). Kies dan **Alle oude statistiekgegevens verwijderen** voor een schone start. Kies niet „Eenheid bijwerken zonder conversie“: dat hernoemt de oude getallen zonder de waarden aan te passen, waardoor de historische gegevens inhoudelijk onjuist worden.\n\n**Handmatig controleren:**\n\n1. **Automatiseringen & templates:** Werk alle automatiseringen en template-sensoren bij die numerieke prijsdrempels gebruiken.\n2. **Dashboard-kaarten:** Werk kaarten bij met hardgecodeerde drempelwaarden of eenheidslabels.\n\nSluit deze melding nadat je je automatiseringen en dashboards hebt gecontroleerd."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
|
|
|||
|
|
@ -1153,6 +1153,10 @@
|
|||
"entity_migration": {
|
||||
"title": "Tibber Prices: Åtgärd krävs efter uppdatering ({home_name})",
|
||||
"description": "Denna uppdatering innehåller ändringar som tillämpades automatiskt.\n\n**Omdöpta entiteter ({count})**\n\nFöljande entity-nycklar döptes om automatiskt. Dina befintliga entity-ID:n och automatiseringar förblir intakta:\n\n{entity_list}\n\n**Ändrade varaktighetssensorvärden**\n\nAlla varaktighetssensorer (återstående tid, startar om, periodvaraktighet, trendändrings-nedräkning) rapporterar nu sitt tillståndsvärde i **minuter** istället för timmar. Visningsenheten i dashboards förblir timmar som standard.\n\nOm du har automatiseringar med numeriska jämförelser på dessa sensorer, uppdatera dina tröskelvärden:\n- Gammalt: `state < 0.25` (15 minuter som timmar)\n- Nytt: `state < 15` (15 minuter)\n\nStäng detta meddelande efter att du har granskat dina automatiseringar."
|
||||
},
|
||||
"currency_display_mode_changed": {
|
||||
"title": "Valutavisningsenhet ändrad för {home_name}",
|
||||
"description": "Du ändrade valutavisningsläget för **{home_name}**. Alla prissensorvärden och -attribut använder nu den nya enheten (t.ex. 25,34 öre → 0,2534 kr eller tvärtom).\n\nHome Assistants Recorder visar separat en **„Enheten har ändrats“**-dialog för berörda sensorer — det kan ta några minuter eller till nästa statistikkörning (loggvarningar dyker upp tidigare). När den visas, välj **Ta bort alla gamla statistikdata** för en ren start. Välj inte „Uppdatera enheten utan konvertering“: det behåller de gamla talen med ny enhet utan att justera värdena, vilket gör historiska data faktiskt felaktiga.\n\n**Granska manuellt:**\n\n1. **Automatiseringar & mallar:** Uppdatera alla automatiseringar och mallsensorer som använder numeriska priströsklar.\n2. **Dashboard-kort:** Uppdatera kort med hårdkodade tröskelvärden eller enhetsetiketter.\n\nStäng detta meddelande efter att du har granskat dina automatiseringar och dashboards."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import statistics
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -353,7 +353,7 @@ def calculate_difference_percentage(
|
|||
return ((current_interval_price - trailing_average) / abs(trailing_average)) * 100
|
||||
|
||||
|
||||
def calculate_rating_level( # noqa: PLR0911 - Multiple returns justified by clear hysteresis state machine
|
||||
def calculate_rating_level(
|
||||
difference: float | None,
|
||||
threshold_low: float,
|
||||
threshold_high: float,
|
||||
|
|
@ -435,7 +435,7 @@ def calculate_rating_level( # noqa: PLR0911 - Multiple returns justified by cle
|
|||
return PRICE_RATING_NORMAL
|
||||
|
||||
|
||||
def _process_price_interval( # noqa: PLR0913 - Extra params needed for hysteresis
|
||||
def _process_price_interval(
|
||||
price_interval: dict[str, Any],
|
||||
all_prices: list[dict[str, Any]],
|
||||
threshold_low: float,
|
||||
|
|
@ -651,7 +651,7 @@ def _apply_rating_gap_tolerance(
|
|||
if interval.get("rating_level") is not None
|
||||
]
|
||||
|
||||
if len(rated_intervals) < 3: # noqa: PLR2004 - Minimum 3 for before/gap/after pattern
|
||||
if len(rated_intervals) < 3:
|
||||
return
|
||||
|
||||
# Iteratively merge small blocks until no more changes
|
||||
|
|
@ -720,7 +720,7 @@ def _apply_level_gap_tolerance(
|
|||
if interval.get("level") is not None
|
||||
]
|
||||
|
||||
if len(level_intervals) < 3: # noqa: PLR2004 - Minimum 3 for before/gap/after pattern
|
||||
if len(level_intervals) < 3:
|
||||
return
|
||||
|
||||
# Iteratively merge small blocks until no more changes
|
||||
|
|
@ -859,7 +859,7 @@ def _merge_small_level_blocks(
|
|||
return len(merge_decisions)
|
||||
|
||||
|
||||
def enrich_price_info_with_differences( # noqa: PLR0913 - Extra params for rating stabilization
|
||||
def enrich_price_info_with_differences(
|
||||
all_intervals: list[dict[str, Any]],
|
||||
*,
|
||||
threshold_low: float | None = None,
|
||||
|
|
@ -867,7 +867,7 @@ def enrich_price_info_with_differences( # noqa: PLR0913 - Extra params for rati
|
|||
hysteresis: float | None = None,
|
||||
gap_tolerance: int | None = None,
|
||||
level_gap_tolerance: int | None = None,
|
||||
time: TibberPricesTimeService | None = None, # noqa: ARG001 # Used in production (via coordinator), kept for compatibility
|
||||
time: TibberPricesTimeService | None = None, # Used in production (via coordinator), kept for compatibility
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Enrich price intervals with calculated 'difference' and 'rating_level' values.
|
||||
|
|
@ -1229,7 +1229,7 @@ def _calculate_lookahead_volatility_factor(
|
|||
return factor
|
||||
|
||||
|
||||
def calculate_price_trend( # noqa: PLR0913 - All parameters are necessary for volatility-adaptive calculation
|
||||
def calculate_price_trend(
|
||||
current_interval_price: float,
|
||||
future_average: float,
|
||||
threshold_rising: float = 3.0,
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ These are stateless pure functions with no Home Assistant dependencies.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from datetime import datetime, timedelta
|
||||
import statistics
|
||||
from typing import Any
|
||||
|
||||
|
||||
|
|
|
|||
88
docs/user/docs/config-best-price.md
Normal file
88
docs/user/docs/config-best-price.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
---
|
||||
sidebar_label: 💚 Best Price Period
|
||||
---
|
||||
|
||||
# 💚 Best Price Period
|
||||
|
||||
**Settings → Devices & Services → Tibber Prices → Configure → 💚 Best Price Period**
|
||||
|
||||
---
|
||||
|
||||
Best Price Period sensors detect windows of time when electricity is cheap enough to be worth scheduling loads (dishwasher, washing machine, EV charging, water heater). The binary sensor `is_best_price_period` is `on` during these windows.
|
||||
|
||||
See **[Period Calculation](period-calculation.md)** for an in-depth explanation of the detection algorithm and [Period Relaxation](period-relaxation.md) for how the relaxation strategy works.
|
||||
|
||||
## Settings
|
||||
|
||||
### Period Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Minimum period length** | 60 min | Shortest window to report as a period — filters out tiny sub-hour dips |
|
||||
| **Maximum price level** | CHEAP | Only intervals at this Tibber level or cheaper qualify |
|
||||
| **Gap tolerance** | 1 | Consecutive above-threshold intervals allowed inside a period — bridges small price bumps between two cheap windows |
|
||||
|
||||
### Flexibility Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Flex percentage** | 15% | How far above the daily minimum a price can be and still qualify. Higher = more intervals qualify |
|
||||
| **Minimum distance from average** | 5% | Qualifying intervals must be at least this far below the daily average — ensures periods are meaningfully cheap, not just "not expensive" |
|
||||
|
||||
### Relaxation & Target
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Enable minimum period target** | On | Automatically loosens criteria (relaxation) until the target count is reached |
|
||||
| **Target periods per day** | 2 | How many distinct periods the algorithm aims to find per day |
|
||||
| **Relaxation attempts** | 11 | How many times to loosen the criteria before giving up. 11 steps × 3% increment = up to ~48% flex |
|
||||
|
||||
:::tip Start with defaults
|
||||
The defaults are tuned for typical European electricity markets. If you're unsure, leave them as-is and observe the binary sensor over a few days.
|
||||
:::
|
||||
|
||||
## Runtime Override Entities
|
||||
|
||||
You can override these settings at runtime through automations — useful for seasonal adjustments or dynamic schedules — without opening the configuration menu.
|
||||
|
||||
These entities are **disabled by default**. Enable them in **Settings → Devices & Services → Tibber Prices → Entities**.
|
||||
|
||||
| Entity | Type | Range | Overrides |
|
||||
|--------|------|-------|-----------|
|
||||
| `number.<home_name>_best_price_flexibility` | Number | 0–50% | Flex percentage |
|
||||
| `number.<home_name>_best_price_minimum_distance` | Number | -50–0% | Minimum distance from average |
|
||||
| `number.<home_name>_best_price_minimum_period_length` | Number | 15–180 min | Minimum period length |
|
||||
| `number.<home_name>_best_price_minimum_periods` | Number | 1–10 | Target periods per day |
|
||||
| `number.<home_name>_best_price_relaxation_attempts` | Number | 1–12 | Relaxation attempts |
|
||||
| `number.<home_name>_best_price_gap_tolerance` | Number | 0–8 | Gap tolerance |
|
||||
| `switch.<home_name>_best_price_enable_relaxation` | Switch | On/Off | Enable relaxation |
|
||||
|
||||
When an override entity is **enabled**, its value takes precedence over the menu setting. When **disabled** (default), the menu setting is used.
|
||||
|
||||
Changing a value triggers immediate period recalculation. Entity values are restored automatically after HA restarts.
|
||||
|
||||
### Example: Stricter detection in winter
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Seasonal override automation</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Winter: Stricter Best Price Detection"
|
||||
trigger:
|
||||
- platform: time
|
||||
at: "00:00:00"
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: "{{ now().month in [11, 12, 1, 2] }}"
|
||||
action:
|
||||
- service: number.set_value
|
||||
target:
|
||||
entity_id: number.<home_name>_best_price_flexibility
|
||||
data:
|
||||
value: 10 # Stricter than default 15%
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
See **[Runtime Override Entities](config-runtime-overrides.md)** for more details, including how overrides work, how to view entity descriptions, and recorder optimization.
|
||||
23
docs/user/docs/config-chart-export.md
Normal file
23
docs/user/docs/config-chart-export.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
sidebar_label: 📊 Chart Data Export
|
||||
---
|
||||
|
||||
# 📊 Chart Data Export Sensor (Legacy)
|
||||
|
||||
**Settings → Devices & Services → Tibber Prices → Configure → 📊 Chart Data Export**
|
||||
|
||||
---
|
||||
|
||||
:::caution Legacy feature
|
||||
The Chart Data Export **sensor** is a legacy mechanism from early versions of this integration. For new setups, use the **[get_chartdata action](chart-actions.md)** instead — it is more flexible, does not require a dedicated sensor, and returns data on demand.
|
||||
:::
|
||||
|
||||
## What this page does
|
||||
|
||||
This configuration page controls whether the legacy chart data export sensor is active. If you already use this sensor in existing dashboards or automations and don't want to migrate yet, leave it enabled.
|
||||
|
||||
## Migration to actions
|
||||
|
||||
The [Chart Actions](chart-actions.md) page covers the recommended approach for fetching chart data via HA actions (formerly services), including ready-to-use examples for ApexCharts and other chart cards.
|
||||
|
||||
If you have existing automations or cards using the legacy sensor, the [Chart Data Export legacy reference](chart-actions.md) includes migration guidance.
|
||||
62
docs/user/docs/config-currency.md
Normal file
62
docs/user/docs/config-currency.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
sidebar_label: 💱 Currency Display
|
||||
---
|
||||
|
||||
# 💱 Currency Display
|
||||
|
||||
**Settings → Devices & Services → Tibber Prices → Configure → 💱 Currency Display**
|
||||
|
||||
---
|
||||
|
||||
## Display Mode
|
||||
|
||||
Choose whether price sensor states show values in **base currency** or **subunit**:
|
||||
|
||||
| Mode | Example | Smart default |
|
||||
|------|---------|---------------|
|
||||
| **Base currency** | 0.2534 €/kWh, 2.53 kr/kWh | NOK, SEK, DKK |
|
||||
| **Subunit** (default for EUR) | 25.34 ct/kWh, 25.3 øre/kWh | EUR |
|
||||
|
||||
The smart default is automatically applied when you first set up the integration based on your Tibber account currency.
|
||||
|
||||
:::caution Decide before building automations
|
||||
Switching the display mode later changes **all** price sensor state values (e.g., 25.34 → 0.2534). This will break:
|
||||
- Numeric thresholds in automations and conditions
|
||||
- Template sensors and conditional cards with hardcoded values
|
||||
|
||||
**If you do switch later:**
|
||||
|
||||
1. A **repair notification** from this integration appears immediately in your sidebar — it reminds you to update automations and dashboards.
|
||||
2. HA's Recorder detects the unit mismatch and shows a **"The unit has changed"** dialog (may take a few minutes or until the next statistics run). Choose **"Delete all old statistic data"** to start fresh. Do _not_ choose "Update the unit without converting" — that re-labels old numbers with the new unit, making historical values factually wrong.
|
||||
3. Update every **automation trigger and condition** with a numeric price value.
|
||||
4. Update **dashboard cards** with hardcoded thresholds or unit labels.
|
||||
:::
|
||||
|
||||
## Price Precision and Rounding
|
||||
|
||||
All prices are received from the Tibber API in base currency and processed without loss of precision. The sensor **state value** is rounded and stored as follows:
|
||||
|
||||
| Display Mode | Stored precision | Example |
|
||||
|---|---|---|
|
||||
| **Subunit** (ct, øre) | 2 decimal places | 25.34 ct/kWh |
|
||||
| **Base currency** (€, kr) | 4 decimal places | 0.2534 €/kWh |
|
||||
|
||||
This applies to both sensor states and attributes (e.g., `energy_price`, `price_mean`, `price_min`).
|
||||
|
||||
### Default display precision
|
||||
|
||||
Home Assistant shows fewer decimals than the stored value by default — enough for a quick glance. The integration sets these defaults per sensor type:
|
||||
|
||||
| Sensor type | Subunit default | Base currency default |
|
||||
|---|---|---|
|
||||
| **Current / Next / Previous interval price** | 2 decimals (25.34 ct) | 4 decimals (0.2534 €) |
|
||||
| **All other price sensors** (averages, min/max, …) | 1 decimal (25.3 ct) | 2 decimals (0.25 €) |
|
||||
| **Energy Dashboard sensor** | — | 4 decimals (always) |
|
||||
|
||||
You can override the displayed precision per entity in the HA UI:
|
||||
|
||||
1. Go to **Settings → Devices & Services → Entities**
|
||||
2. Select a price sensor → click the gear icon
|
||||
3. Change **Display precision** to your preference
|
||||
|
||||
**Practical ceiling:** Subunit values have exactly 2 decimal places stored — setting more than 2 shows trailing zeros. Base currency values have 4 decimal places stored — 3–4 decimals are meaningful.
|
||||
73
docs/user/docs/config-general.md
Normal file
73
docs/user/docs/config-general.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
---
|
||||
sidebar_label: ⚙️ General Settings
|
||||
---
|
||||
|
||||
# ⚙️ General Settings
|
||||
|
||||
**Settings → Devices & Services → Tibber Prices → Configure → ⚙️ General Settings**
|
||||
|
||||
---
|
||||
|
||||
## Extended Entity Descriptions
|
||||
|
||||
Controls whether sensor attributes include detailed explanations and usage tips.
|
||||
|
||||
| State | Attributes included |
|
||||
|-------|---------------------|
|
||||
| **Disabled** (default) | `description` only — brief one-liner |
|
||||
| **Enabled** | `description` + `long_description` + `usage_tips` |
|
||||
|
||||
Enable this while getting familiar with the integration. Once you know what each sensor does, disabling it reduces attribute clutter in your Developer Tools / history views.
|
||||
|
||||
## Average Sensor Display
|
||||
|
||||
Controls which statistical measure the sensor **state value** shows for all average price sensors. The other value is always available as an attribute regardless of this setting.
|
||||
|
||||
| Mode | Shows | Best for |
|
||||
|------|-------|----------|
|
||||
| **Median** (default) | Middle value when prices are sorted | Dashboards, typical price level |
|
||||
| **Arithmetic Mean** | Mathematical average of all prices | Cost calculations, budgeting |
|
||||
|
||||
### Why the difference matters
|
||||
|
||||
Consider a day with these prices: `10, 12, 13, 15, 80 ct/kWh`
|
||||
|
||||
- **Median = 13 ct/kWh** — "typical" price (ignores the expensive spike)
|
||||
- **Mean = 26 ct/kWh** — average cost if consuming evenly (spike included)
|
||||
|
||||
The median gives a better feel for what the day was like. The mean is more accurate for calculating what you actually paid on average.
|
||||
|
||||
### Both values always available
|
||||
|
||||
You can always access both values as attributes from any average sensor, regardless of this display setting:
|
||||
|
||||
```yaml
|
||||
{{ state_attr('sensor.<home_name>_price_today', 'price_median') }}
|
||||
{{ state_attr('sensor.<home_name>_price_today', 'price_mean') }}
|
||||
```
|
||||
|
||||
This means you can change the display setting at any time without breaking automations that use attributes.
|
||||
|
||||
### Affected sensors
|
||||
|
||||
This setting applies to:
|
||||
- Daily average sensors (today, tomorrow)
|
||||
- 24-hour rolling averages (trailing, leading)
|
||||
- Hourly smoothed prices (current hour, next hour)
|
||||
- Future forecast sensors (next 1h, 2h, 3h, … 12h)
|
||||
|
||||
See **[Average Sensors](sensors-average.md)** for detailed examples.
|
||||
|
||||
### Choosing your mode
|
||||
|
||||
**Choose Median if:**
|
||||
- 👥 You show prices to users ("What's today like?")
|
||||
- 📊 You want dashboard values representing typical conditions
|
||||
- 🎯 You compare price levels across days
|
||||
|
||||
**Choose Mean if:**
|
||||
- 💰 You calculate costs and budgets
|
||||
- 🧮 You need mathematical accuracy for financial planning
|
||||
- 📊 You track actual average costs over time
|
||||
|
||||
**Pro tip:** Most users prefer **Median** for displays, but use the `price_mean` attribute in cost calculation automations.
|
||||
56
docs/user/docs/config-peak-price.md
Normal file
56
docs/user/docs/config-peak-price.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
sidebar_label: 🔴 Peak Price Period
|
||||
---
|
||||
|
||||
# 🔴 Peak Price Period
|
||||
|
||||
**Settings → Devices & Services → Tibber Prices → Configure → 🔴 Peak Price Period**
|
||||
|
||||
---
|
||||
|
||||
Peak Price Period sensors detect windows of time when electricity is expensive enough that you should avoid or postpone consumption. The binary sensor `is_peak_price_period` is `on` during these windows.
|
||||
|
||||
The detection algorithm mirrors [Best Price Period](config-best-price.md), but in reverse — looking for expensive intervals rather than cheap ones.
|
||||
|
||||
See **[Period Calculation](period-calculation.md)** for an in-depth explanation of the detection algorithm and [Period Relaxation](period-relaxation.md) for how relaxation works.
|
||||
|
||||
## Settings
|
||||
|
||||
### Period Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Minimum period length** | 60 min | Shortest window to report as a period |
|
||||
| **Minimum price level** | EXPENSIVE | Only intervals at this Tibber level or more expensive qualify |
|
||||
| **Gap tolerance** | 1 | Consecutive below-threshold intervals allowed inside a period — bridges small price dips between two expensive windows |
|
||||
|
||||
### Flexibility Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Flex percentage** | -20% | How far below the daily maximum a price can be and still qualify (negative value = below maximum) |
|
||||
| **Minimum distance from average** | 5% | Qualifying intervals must be at least this far above the daily average |
|
||||
|
||||
### Relaxation & Target
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Enable minimum period target** | On | Automatically loosens criteria until the target count is reached |
|
||||
| **Target periods per day** | 2 | How many distinct peak periods the algorithm aims to find per day |
|
||||
| **Relaxation attempts** | 11 | How many times to loosen the criteria before giving up |
|
||||
|
||||
## Runtime Override Entities
|
||||
|
||||
Same concept as [Best Price overrides](config-best-price.md#runtime-override-entities) — disabled by default, enable individually in Entities.
|
||||
|
||||
| Entity | Type | Range | Overrides |
|
||||
|--------|------|-------|-----------|
|
||||
| `number.<home_name>_peak_price_flexibility` | Number | -50–0% | Flex percentage |
|
||||
| `number.<home_name>_peak_price_minimum_distance` | Number | 0–50% | Minimum distance from average |
|
||||
| `number.<home_name>_peak_price_minimum_period_length` | Number | 15–180 min | Minimum period length |
|
||||
| `number.<home_name>_peak_price_minimum_periods` | Number | 1–10 | Target periods per day |
|
||||
| `number.<home_name>_peak_price_relaxation_attempts` | Number | 1–12 | Relaxation attempts |
|
||||
| `number.<home_name>_peak_price_gap_tolerance` | Number | 0–8 | Gap tolerance |
|
||||
| `switch.<home_name>_peak_price_enable_relaxation` | Switch | On/Off | Enable relaxation |
|
||||
|
||||
See **[Runtime Override Entities](config-runtime-overrides.md)** for full details on how overrides work, viewing entity descriptions, and recorder optimization.
|
||||
37
docs/user/docs/config-price-level.md
Normal file
37
docs/user/docs/config-price-level.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
sidebar_label: 🏷️ Price Level
|
||||
---
|
||||
|
||||
# 🏷️ Price Level Gap Tolerance
|
||||
|
||||
**Settings → Devices & Services → Tibber Prices → Configure → 🏷️ Price Level**
|
||||
|
||||
---
|
||||
|
||||
Tibber's API assigns each interval a price level: VERY_CHEAP, CHEAP, NORMAL, EXPENSIVE, or VERY_EXPENSIVE. In practice, a single interval can jump to a different level briefly before jumping back — creating isolated "noise" intervals that make sensors flicker.
|
||||
|
||||
Gap tolerance smooths this out.
|
||||
|
||||
## Setting
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Gap tolerance** | 1 | Number of consecutive "mismatched" intervals to fill in automatically |
|
||||
|
||||
### Example
|
||||
|
||||
With gap tolerance = 1, a lone NORMAL interval surrounded by CHEAP on both sides is automatically corrected to CHEAP:
|
||||
|
||||
```
|
||||
Before: CHEAP CHEAP NORMAL CHEAP CHEAP
|
||||
After: CHEAP CHEAP CHEAP CHEAP CHEAP
|
||||
↑ filled in
|
||||
```
|
||||
|
||||
With gap tolerance = 0, no smoothing is applied and every interval uses the raw API level.
|
||||
|
||||
## Notes
|
||||
|
||||
- This applies to Tibber's own level classification (separate from the [Price Rating](config-price-rating.md) which is calculated by this integration)
|
||||
- Increasing gap tolerance beyond 2 is rarely useful — larger gaps usually represent genuine price differences
|
||||
- The gap tolerance here only affects level sensors; the separate gap tolerance in [Best Price](config-best-price.md) and [Peak Price](config-peak-price.md) settings controls period merging behavior
|
||||
44
docs/user/docs/config-price-rating.md
Normal file
44
docs/user/docs/config-price-rating.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
sidebar_label: 📊 Price Rating
|
||||
---
|
||||
|
||||
# 📊 Price Rating Thresholds
|
||||
|
||||
**Settings → Devices & Services → Tibber Prices → Configure → 📊 Price Rating**
|
||||
|
||||
---
|
||||
|
||||
Price ratings classify each 15-minute interval as **LOW**, **NORMAL**, or **HIGH** relative to the 24-hour trailing average. Sensors and automations can use these ratings to decide when to run appliances.
|
||||
|
||||
See **[Ratings & Levels](sensors-ratings-levels.md)** for a full explanation of how ratings work and which sensors expose them.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Low threshold** | -10% | Prices this far below the trailing average → rated **LOW** |
|
||||
| **High threshold** | +10% | Prices this far above the trailing average → rated **HIGH** |
|
||||
| **Hysteresis** | 2% | Buffer zone around thresholds — prevents rapid flickering when a price hovers right at the boundary |
|
||||
| **Gap tolerance** | 1 | Smooths isolated rating blocks: a lone NORMAL interval surrounded by LOW on both sides gets corrected to LOW |
|
||||
|
||||
## How thresholds are applied
|
||||
|
||||
```
|
||||
Trailing 24h average: 20 ct/kWh
|
||||
Low threshold: -10% → prices ≤ 18 ct → LOW
|
||||
High threshold: +10% → prices ≥ 22 ct → HIGH
|
||||
Everything else → NORMAL
|
||||
```
|
||||
|
||||
Hysteresis adds an inner dead-band: once a rating is set to LOW, it stays LOW until the price rises above `18 ct + 2% = 18.36 ct`. This prevents sensors from flickering between LOW and NORMAL when prices are right at the boundary.
|
||||
|
||||
## Adjusting for your market
|
||||
|
||||
**Markets with low daily price variation** (e.g., day typically stays within ±5%):
|
||||
- Lower the thresholds: try -5% / +5%
|
||||
- This keeps meaningful LOW/HIGH periods even on calm days
|
||||
|
||||
**Markets with high daily variation** (e.g., ±30% swings):
|
||||
- Raise the thresholds: try -15% / +15%
|
||||
- This reserves LOW/HIGH for genuinely exceptional periods only
|
||||
- Consider using [Volatility](config-volatility.md) sensors alongside ratings on such days
|
||||
40
docs/user/docs/config-price-trend.md
Normal file
40
docs/user/docs/config-price-trend.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
sidebar_label: 📈 Price Trend
|
||||
---
|
||||
|
||||
# 📈 Price Trend Thresholds
|
||||
|
||||
**Settings → Devices & Services → Tibber Prices → Configure → 📈 Price Trend**
|
||||
|
||||
---
|
||||
|
||||
Price trend sensors compare the upcoming price average to the current price and report whether prices are rising, falling, or stable. These thresholds define how much of a change is required before the trend sensor changes state.
|
||||
|
||||
See **[Trend Sensors](sensors-trends.md)** for a full explanation of all trend sensors, how volatility-adaption works, and automation examples.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Rising** | +3% | Future average this much above current → `rising` |
|
||||
| **Strongly rising** | +9% | Future average far above current → `strongly_rising` |
|
||||
| **Falling** | -3% | Future average this much below current → `falling` |
|
||||
| **Strongly falling** | -9% | Future average far below current → `strongly_falling` |
|
||||
|
||||
Prices within the rising/falling range are reported as `stable`.
|
||||
|
||||
## Volatility-adaptive thresholds
|
||||
|
||||
On high-volatility days, the thresholds automatically widen to prevent the trend sensor from flickering constantly due to natural price variation. The effective threshold is scaled based on the day's [volatility level](config-volatility.md):
|
||||
|
||||
- **Low volatility**: Thresholds used as-is
|
||||
- **Moderate volatility**: Thresholds slightly widened
|
||||
- **High / Very High volatility**: Thresholds significantly widened
|
||||
|
||||
This means the same `rising` threshold (3%) may correspond to a 5% effective threshold on a volatile day. The scaling is automatic — you only need to configure the baseline values here.
|
||||
|
||||
## Adjusting for your market
|
||||
|
||||
- If trend sensors flicker too often on typical days → increase all thresholds slightly (e.g., 4% / 12%)
|
||||
- If trend sensors rarely change even on obviously moving price days → decrease thresholds (e.g., 2% / 6%)
|
||||
- For markets with structural day/night patterns, consider using the `strongly_*` states in automations to ensure only major movements trigger actions
|
||||
110
docs/user/docs/config-runtime-overrides.md
Normal file
110
docs/user/docs/config-runtime-overrides.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
---
|
||||
sidebar_label: 🔁 Runtime Override Entities
|
||||
---
|
||||
|
||||
# Runtime Override Entities
|
||||
|
||||
The integration provides optional **number** and **switch** entities that let you change Best Price and Peak Price detection settings at runtime — through automations or the HA UI — without going into the configuration menu.
|
||||
|
||||
These entities are **disabled by default**. Enable them individually in:
|
||||
|
||||
**Settings → Devices & Services → Tibber Prices → Entities**
|
||||
|
||||
---
|
||||
|
||||
## How overrides work
|
||||
|
||||
1. **Entity disabled** (default): The configuration menu setting is used
|
||||
2. **Entity enabled**: The entity value overrides the menu setting
|
||||
3. **Value changes**: Trigger immediate period recalculation
|
||||
4. **HA restart**: Entity values are restored automatically
|
||||
|
||||
This lets you write automations that adjust detection criteria seasonally, based on weather forecasts, or based on other conditions — without manual configuration changes.
|
||||
|
||||
## Available entities
|
||||
|
||||
### Best Price Period
|
||||
|
||||
| Entity | Type | Range | Overrides |
|
||||
|--------|------|-------|-----------|
|
||||
| `number.<home_name>_best_price_flexibility` | Number | 0–50% | [Flex percentage](config-best-price.md) |
|
||||
| `number.<home_name>_best_price_minimum_distance` | Number | -50–0% | [Minimum distance from average](config-best-price.md) |
|
||||
| `number.<home_name>_best_price_minimum_period_length` | Number | 15–180 min | [Minimum period length](config-best-price.md) |
|
||||
| `number.<home_name>_best_price_minimum_periods` | Number | 1–10 | [Target periods per day](config-best-price.md) |
|
||||
| `number.<home_name>_best_price_relaxation_attempts` | Number | 1–12 | [Relaxation attempts](config-best-price.md) |
|
||||
| `number.<home_name>_best_price_gap_tolerance` | Number | 0–8 | [Gap tolerance](config-best-price.md) |
|
||||
| `switch.<home_name>_best_price_enable_relaxation` | Switch | On/Off | [Enable relaxation](config-best-price.md) |
|
||||
|
||||
### Peak Price Period
|
||||
|
||||
| Entity | Type | Range | Overrides |
|
||||
|--------|------|-------|-----------|
|
||||
| `number.<home_name>_peak_price_flexibility` | Number | -50–0% | [Flex percentage](config-peak-price.md) |
|
||||
| `number.<home_name>_peak_price_minimum_distance` | Number | 0–50% | [Minimum distance from average](config-peak-price.md) |
|
||||
| `number.<home_name>_peak_price_minimum_period_length` | Number | 15–180 min | [Minimum period length](config-peak-price.md) |
|
||||
| `number.<home_name>_peak_price_minimum_periods` | Number | 1–10 | [Target periods per day](config-peak-price.md) |
|
||||
| `number.<home_name>_peak_price_relaxation_attempts` | Number | 1–12 | [Relaxation attempts](config-peak-price.md) |
|
||||
| `number.<home_name>_peak_price_gap_tolerance` | Number | 0–8 | [Gap tolerance](config-peak-price.md) |
|
||||
| `switch.<home_name>_peak_price_enable_relaxation` | Switch | On/Off | [Enable relaxation](config-peak-price.md) |
|
||||
|
||||
## Viewing entity descriptions
|
||||
|
||||
Each override entity has a `description` attribute explaining what the setting does — the same text shown in the configuration menu.
|
||||
|
||||
**Note for Number entities:** Home Assistant shows a history graph by default in the entity detail view, which hides the attributes panel. To see the description:
|
||||
|
||||
1. Go to **Developer Tools → States**
|
||||
2. Search for the entity (e.g., `number.<home_name>_best_price_flexibility`)
|
||||
3. Expand the attributes section
|
||||
|
||||
Switch entities show their attributes normally in the entity details view.
|
||||
|
||||
## Example: Seasonal adjustment
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Stricter detection in winter months</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Winter: Stricter Best Price Detection"
|
||||
trigger:
|
||||
- platform: time
|
||||
at: "00:00:00"
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: "{{ now().month in [11, 12, 1, 2] }}"
|
||||
action:
|
||||
- service: number.set_value
|
||||
target:
|
||||
entity_id: number.<home_name>_best_price_flexibility
|
||||
data:
|
||||
value: 10 # Stricter than default 15%
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Recorder optimization (optional)
|
||||
|
||||
These entities are already designed to minimize database impact:
|
||||
- **EntityCategory.CONFIG** — excluded from Long-Term Statistics
|
||||
- All attributes excluded from history recording
|
||||
- Only state value (the number/switch state) is recorded
|
||||
|
||||
If you want to **completely exclude** these entities from the recorder (no history graph, no database entries at all):
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Exclude from recorder</summary>
|
||||
|
||||
```yaml
|
||||
recorder:
|
||||
exclude:
|
||||
entity_globs:
|
||||
- number.*_best_price_*
|
||||
- number.*_peak_price_*
|
||||
- switch.*_best_price_*
|
||||
- switch.*_peak_price_*
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
This is useful if you rarely change these settings and want the smallest possible database footprint.
|
||||
36
docs/user/docs/config-volatility.md
Normal file
36
docs/user/docs/config-volatility.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
sidebar_label: 💨 Price Volatility
|
||||
---
|
||||
|
||||
# 💨 Price Volatility Thresholds
|
||||
|
||||
**Settings → Devices & Services → Tibber Prices → Configure → 💨 Price Volatility**
|
||||
|
||||
---
|
||||
|
||||
Volatility sensors measure how much prices vary throughout the day using the **Coefficient of Variation (CV)** — the ratio of the standard deviation to the mean. A higher CV means more extreme price swings and greater optimization potential.
|
||||
|
||||
See **[Volatility Sensors](sensors-volatility.md)** for a full explanation of all volatility sensors and how to use them in automations.
|
||||
|
||||
## Thresholds
|
||||
|
||||
These thresholds define the boundaries between volatility levels:
|
||||
|
||||
| Level | Default CV | Meaning |
|
||||
|-------|-----------|---------|
|
||||
| **Moderate** | ≥ 15% | Noticeable variation — some optimization potential |
|
||||
| **High** | ≥ 30% | Significant swings — good for timing optimization |
|
||||
| **Very High** | ≥ 50% | Extreme volatility — maximum optimization benefit |
|
||||
|
||||
Days below the Moderate threshold are classified as **Low** volatility.
|
||||
|
||||
## Adjusting for your market
|
||||
|
||||
The defaults work well for most European electricity markets. You may want to adjust if:
|
||||
|
||||
- **Your market rarely exceeds 20% CV**: Lower the Moderate threshold to 10% so you still get meaningful classifications
|
||||
- **Your market routinely hits 50%+ CV**: Raise the Very High threshold to 70%+ to distinguish truly exceptional days
|
||||
|
||||
:::tip Volatility affects Trend thresholds too
|
||||
The [Price Trend](config-price-trend.md) thresholds automatically widen on high-volatility days to prevent constant state changes. Changes here indirectly affect trend sensitivity.
|
||||
:::
|
||||
|
|
@ -26,309 +26,43 @@ If you have multiple Tibber homes (e.g., different locations):
|
|||
3. Select the additional home from the dropdown
|
||||
4. Each home gets its own set of sensors with unique entity IDs
|
||||
|
||||
## Options Flow (Configuration Wizard)
|
||||
## Options Menu
|
||||
|
||||
After initial setup, configure the integration through a multi-step wizard:
|
||||
After initial setup, open the configuration menu at:
|
||||
|
||||
**Settings → Devices & Services → Tibber Prices → Configure**
|
||||
|
||||
A menu appears with all configuration sections. Pick any section, adjust settings, then return to the menu — there is no required order. All sections have sensible defaults and can be revisited independently at any time.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
S1["① General"] --> S2["② Currency"]
|
||||
S2 --> S3["③ Ratings"]
|
||||
S3 --> S4["④ Levels"]
|
||||
S4 --> S5["⑤ Volatility"]
|
||||
S5 --> S6["⑥ Best Price"]
|
||||
S6 --> S7["⑦ Peak Price"]
|
||||
S7 --> S8["⑧ Trends"]
|
||||
S8 --> S9["⑨ Chart"]
|
||||
graph LR
|
||||
Menu["⚙️ Configure"]
|
||||
Menu --> GS["⚙️ General Settings"]
|
||||
Menu --> DS["💱 Currency Display"]
|
||||
Menu --> PR["📊 Price Rating"]
|
||||
Menu --> PL["🏷️ Price Level"]
|
||||
Menu --> VO["💨 Price Volatility"]
|
||||
Menu --> BP["💚 Best Price Period"]
|
||||
Menu --> PP["🔴 Peak Price Period"]
|
||||
Menu --> PT["📈 Price Trend"]
|
||||
Menu --> CD["📊 Chart Data Export"]
|
||||
Menu --> RD["🔄 Reset to Defaults"]
|
||||
|
||||
style S1 fill:#e6f7ff,stroke:#00b9e7,stroke-width:2px
|
||||
style S6 fill:#e6fff5,stroke:#00c853,stroke-width:2px
|
||||
style S7 fill:#fff0f0,stroke:#ff5252,stroke-width:2px
|
||||
style Menu fill:#e6f7ff,stroke:#00b9e7,stroke-width:2px
|
||||
style BP fill:#e6fff5,stroke:#00c853,stroke-width:2px
|
||||
style PP fill:#fff0f0,stroke:#ff5252,stroke-width:2px
|
||||
```
|
||||
|
||||
All steps have sensible defaults — you can click through without changes and fine-tune later.
|
||||
|
||||
### Step 1: General Settings
|
||||
|
||||
- **Extended entity descriptions**: Show `description`, `long_description`, and `usage_tips` attributes on all sensors (useful for learning, can be disabled later to reduce attribute clutter)
|
||||
- **Average sensor display**: Choose **Median** (typical price, spike-resistant) or **Mean** (mathematical average for cost calculations)
|
||||
|
||||
### Step 2: Currency Display
|
||||
|
||||
- **Base currency**: Shows prices as €/kWh, kr/kWh (e.g., 0.25 €/kWh)
|
||||
- **Subunit**: Shows prices as ct/kWh, øre/kWh (e.g., 25.00 ct/kWh)
|
||||
- Smart defaults: EUR → subunit (cents), NOK/SEK/DKK → base currency (kroner)
|
||||
|
||||
### Step 3: Price Rating Thresholds
|
||||
|
||||
Configure how the integration classifies prices relative to the 24-hour trailing average:
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Low threshold** | -10% | Prices this much below average → **LOW** rating |
|
||||
| **High threshold** | +10% | Prices this much above average → **HIGH** rating |
|
||||
| **Hysteresis** | 2% | Prevents flickering at threshold boundaries |
|
||||
| **Gap tolerance** | 1 | Smooth isolated rating blocks (e.g., lone NORMAL between two LOWs) |
|
||||
|
||||
### Step 4: Price Level Gap Tolerance
|
||||
|
||||
- **Gap tolerance** for Tibber's API-provided levels (VERY_CHEAP through VERY_EXPENSIVE)
|
||||
- Smooths isolated level flickers: a single NORMAL surrounded by CHEAP → corrected to CHEAP
|
||||
- Default: 1 interval tolerance
|
||||
|
||||
### Step 5: Price Volatility Thresholds
|
||||
|
||||
Configure the Coefficient of Variation (CV) boundaries:
|
||||
|
||||
| Level | Default | Meaning |
|
||||
|-------|---------|---------|
|
||||
| **Moderate** | 15% | Noticeable price variation, some optimization potential |
|
||||
| **High** | 30% | Significant price swings, good for timing optimization |
|
||||
| **Very High** | 50% | Extreme volatility, maximum optimization benefit |
|
||||
|
||||
### Step 6: Best Price Period
|
||||
|
||||
Configure detection of favorable price windows. Three collapsible sections:
|
||||
|
||||
**Period Settings:**
|
||||
- Minimum period length (default: 60 min)
|
||||
- Maximum price level to include (default: CHEAP)
|
||||
- Gap tolerance: how many expensive intervals to bridge (default: 1)
|
||||
|
||||
**Flexibility Settings:**
|
||||
- Flex percentage (default: 15%): how far above the daily minimum a price can be to qualify
|
||||
- Minimum distance from daily average (default: 5%): ensures periods are meaningfully cheaper
|
||||
|
||||
**Relaxation & Target:**
|
||||
- Enable minimum period target (default: on)
|
||||
- Target periods per day (default: 2)
|
||||
- Relaxation attempts (default: 11): steps to loosen criteria if target not met
|
||||
|
||||
See [Period Calculation](period-calculation.md) for an in-depth explanation.
|
||||
|
||||
### Step 7: Peak Price Period
|
||||
|
||||
Mirrors Best Price configuration but for expensive windows. Detects periods to **avoid** consumption.
|
||||
|
||||
### Step 8: Price Trend Thresholds
|
||||
|
||||
Configure when trend sensors report rising/falling:
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| **Rising** | 3% | Future average this much above current → "rising" |
|
||||
| **Strongly rising** | 9% | Future average far above current → "strongly_rising" |
|
||||
| **Falling** | -3% | Future average this much below current → "falling" |
|
||||
| **Strongly falling** | -9% | Future average far below current → "strongly_falling" |
|
||||
|
||||
Thresholds are [volatility-adaptive](sensors-trends.md): automatically widened on volatile days to prevent constant state changes.
|
||||
|
||||
### Step 9: Chart Data Export (Legacy)
|
||||
|
||||
Information page for the legacy chart data export sensor. For new setups, use the [get_chartdata action](chart-actions.md) instead.
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Average Sensor Display Settings
|
||||
|
||||
**Location:** Settings → Devices & Services → Tibber Prices → Configure → **General Settings**
|
||||
|
||||
The integration allows you to choose how average price sensors display their values. This setting affects all average sensors (daily, 24h rolling, hourly smoothed, and future forecasts).
|
||||
|
||||
#### Display Modes
|
||||
|
||||
**Median (Default):**
|
||||
- Shows the "middle value" when all prices are sorted
|
||||
- **Resistant to extreme spikes** - one expensive hour doesn't skew the result
|
||||
- Best for understanding **typical price levels**
|
||||
- Example: "What was the typical price today?"
|
||||
|
||||
**Arithmetic Mean:**
|
||||
- Shows the mathematical average of all prices
|
||||
- **Includes effect of spikes** - reflects actual cost if consuming evenly
|
||||
- Best for **cost calculations and budgeting**
|
||||
- Example: "What was my average cost per kWh today?"
|
||||
|
||||
#### Why This Matters
|
||||
|
||||
Consider a day with these hourly prices:
|
||||
<details>
|
||||
<summary>Show example: Mean vs Median</summary>
|
||||
|
||||
```
|
||||
10, 12, 13, 15, 80 ct/kWh
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
- **Median = 13 ct/kWh** ← "Typical" price (middle value, ignores spike)
|
||||
- **Mean = 26 ct/kWh** ← Average cost (spike pulls it up)
|
||||
|
||||
The median tells you the price was **typically** around 13 ct/kWh (4 out of 5 hours). The mean tells you if you consumed evenly, your **average cost** was 26 ct/kWh.
|
||||
|
||||
#### Automation-Friendly Design
|
||||
|
||||
**Both values are always available as attributes**, regardless of your display choice:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Median and Mean Attributes</summary>
|
||||
|
||||
```yaml
|
||||
# These attributes work regardless of display setting:
|
||||
{{ state_attr('sensor.<home_name>_price_today', 'price_median') }}
|
||||
{{ state_attr('sensor.<home_name>_price_today', 'price_mean') }}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
This means:
|
||||
- ✅ You can change the display anytime without breaking automations
|
||||
- ✅ Automations can use both values for different purposes
|
||||
- ✅ No need to create template sensors for the "other" value
|
||||
|
||||
#### Affected Sensors
|
||||
|
||||
This setting applies to:
|
||||
- Daily average sensors (today, tomorrow)
|
||||
- 24-hour rolling averages (trailing, leading)
|
||||
- Hourly smoothed prices (current hour, next hour)
|
||||
- Future forecast sensors (next 1h, 2h, 3h, ... 12h)
|
||||
|
||||
See the **[Average Sensors](sensors-average.md)** for detailed examples.
|
||||
|
||||
#### Choosing Your Display
|
||||
|
||||
**Choose Median if:**
|
||||
- 👥 You show prices to users ("What's today like?")
|
||||
- 📊 You want dashboard values that represent typical conditions
|
||||
- 🎯 You compare price levels across days
|
||||
- 🔍 You analyze volatility (comparing typical vs extremes)
|
||||
|
||||
**Choose Mean if:**
|
||||
- 💰 You calculate costs and budgets
|
||||
- 📈 You forecast energy expenses
|
||||
- 🧮 You need mathematical accuracy for financial planning
|
||||
- 📊 You track actual average costs over time
|
||||
|
||||
**Pro Tip:** Most users prefer **Median** for displays (more intuitive), but use `price_mean` attribute in cost calculation automations.
|
||||
|
||||
## Runtime Configuration Entities
|
||||
|
||||
The integration provides optional configuration entities that allow you to override period calculation settings at runtime through automations. These entities are **disabled by default** and can be enabled individually as needed.
|
||||
|
||||
### Available Configuration Entities
|
||||
|
||||
When enabled, these entities override the corresponding Options Flow settings:
|
||||
|
||||
#### Best Price Period Settings
|
||||
|
||||
| Entity | Type | Range | Description |
|
||||
|--------|------|-------|-------------|
|
||||
| <EntityRef id="best_price_flex_override">Best Price: Flexibility</EntityRef> | Number | 0-50% | Maximum above daily minimum for "best price" intervals |
|
||||
| <EntityRef id="best_price_min_distance_override">Best Price: Minimum Distance</EntityRef> | Number | -50-0% | Required distance below daily average |
|
||||
| <EntityRef id="best_price_min_period_length_override">Best Price: Minimum Period Length</EntityRef> | Number | 15-180 min | Shortest period duration to consider |
|
||||
| <EntityRef id="best_price_min_periods_override">Best Price: Minimum Periods</EntityRef> | Number | 1-10 | Target number of periods per day |
|
||||
| <EntityRef id="best_price_relaxation_attempts_override">Best Price: Relaxation Attempts</EntityRef> | Number | 1-12 | Steps to try when relaxing criteria |
|
||||
| <EntityRef id="best_price_gap_tolerance_override">Best Price: Gap Tolerance</EntityRef> | Number | 0-8 | Consecutive intervals allowed above threshold |
|
||||
| <EntityRef id="best_price_achieve_min_count_override">Best Price: Achieve Minimum Count</EntityRef> | Switch | On/Off | Enable relaxation algorithm |
|
||||
|
||||
#### Peak Price Period Settings
|
||||
|
||||
| Entity | Type | Range | Description |
|
||||
|--------|------|-------|-------------|
|
||||
| <EntityRef id="peak_price_flex_override">Peak Price: Flexibility</EntityRef> | Number | -50-0% | Maximum below daily maximum for "peak price" intervals |
|
||||
| <EntityRef id="peak_price_min_distance_override">Peak Price: Minimum Distance</EntityRef> | Number | 0-50% | Required distance above daily average |
|
||||
| <EntityRef id="peak_price_min_period_length_override">Peak Price: Minimum Period Length</EntityRef> | Number | 15-180 min | Shortest period duration to consider |
|
||||
| <EntityRef id="peak_price_min_periods_override">Peak Price: Minimum Periods</EntityRef> | Number | 1-10 | Target number of periods per day |
|
||||
| <EntityRef id="peak_price_relaxation_attempts_override">Peak Price: Relaxation Attempts</EntityRef> | Number | 1-12 | Steps to try when relaxing criteria |
|
||||
| <EntityRef id="peak_price_gap_tolerance_override">Peak Price: Gap Tolerance</EntityRef> | Number | 0-8 | Consecutive intervals allowed below threshold |
|
||||
| <EntityRef id="peak_price_achieve_min_count_override">Peak Price: Achieve Minimum Count</EntityRef> | Switch | On/Off | Enable relaxation algorithm |
|
||||
|
||||
### How Runtime Overrides Work
|
||||
|
||||
1. **Disabled (default):** The Options Flow setting is used
|
||||
2. **Enabled:** The entity value overrides the Options Flow setting
|
||||
3. **Value changes:** Trigger immediate period recalculation
|
||||
4. **HA restart:** Entity values are restored automatically
|
||||
|
||||
### Viewing Entity Descriptions
|
||||
|
||||
Each configuration entity includes a detailed description attribute explaining what the setting does - the same information shown in the Options Flow.
|
||||
|
||||
**Note:** For **Number entities**, Home Assistant displays a history graph by default, which hides the attributes panel. To view the `description` attribute:
|
||||
|
||||
1. Go to **Developer Tools → States**
|
||||
2. Search for the entity (e.g., `number.<home_name>_best_price_flexibility`)
|
||||
3. Expand the attributes section to see the full description
|
||||
|
||||
**Switch entities** display their attributes normally in the entity details view.
|
||||
|
||||
### Example: Seasonal Automation
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Seasonal Runtime Override</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Winter: Stricter Best Price Detection"
|
||||
trigger:
|
||||
- platform: time
|
||||
at: "00:00:00"
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: "{{ now().month in [11, 12, 1, 2] }}"
|
||||
action:
|
||||
- service: number.set_value
|
||||
target:
|
||||
entity_id: number.<home_name>_best_price_flexibility
|
||||
data:
|
||||
value: 10 # Stricter than default 15%
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Recorder Optimization (Optional)
|
||||
|
||||
These configuration entities are designed to minimize database impact:
|
||||
- **EntityCategory.CONFIG** - Excluded from Long-Term Statistics
|
||||
- All attributes excluded from history recording
|
||||
- Only state value changes are recorded
|
||||
|
||||
If you frequently adjust these settings via automations or want to track configuration changes over time, the default behavior is fine.
|
||||
|
||||
However, if you prefer to **completely exclude** these entities from the recorder (no history graph, no database entries), add this to your `configuration.yaml`:
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Exclude Runtime Config Entities</summary>
|
||||
|
||||
```yaml
|
||||
recorder:
|
||||
exclude:
|
||||
entity_globs:
|
||||
# Exclude all Tibber Prices configuration entities
|
||||
- number.*_best_price_*
|
||||
- number.*_peak_price_*
|
||||
- switch.*_best_price_*
|
||||
- switch.*_peak_price_*
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
This is especially useful if:
|
||||
- You rarely change these settings
|
||||
- You want the smallest possible database footprint
|
||||
- You don't need to see the history graph for these entities
|
||||
|
||||
#### Price Sensor Statistics
|
||||
|
||||
The integration also minimizes long-term statistics growth for price sensors. Only 3 sensors write to the HA statistics database (which is never auto-purged):
|
||||
|
||||
- **Current Electricity Price** — Long-term price trend over weeks/months
|
||||
- **Current Electricity Price (Energy Dashboard)** — Required for Energy Dashboard integration
|
||||
- **Today's Average Price** — Seasonal price comparison
|
||||
|
||||
All other price sensors (forecasts, rolling averages, daily min/max, future averages) have long-term statistics disabled. Their **state history** (the step chart in the History panel) still works normally for ~10 days — only the smooth statistics line-chart on the entity detail page is absent for these sensors.
|
||||
|
||||
No configuration changes are needed — this optimization is built into the integration.
|
||||
| Section | What you configure |
|
||||
|---------|-------------------|
|
||||
| [⚙️ General Settings](config-general.md) | Extended descriptions, average sensor display mode (Median / Mean) |
|
||||
| [💱 Currency Display](config-currency.md) | Base currency vs. subunit display, price precision |
|
||||
| [📊 Price Rating](config-price-rating.md) | LOW / HIGH thresholds, hysteresis, gap tolerance |
|
||||
| [🏷️ Price Level](config-price-level.md) | Gap tolerance for Tibber's API-provided level classifications |
|
||||
| [💨 Price Volatility](config-volatility.md) | CV thresholds for Moderate / High / Very High volatility |
|
||||
| [💚 Best Price Period](config-best-price.md) | Cheap window detection: flex, distance, relaxation, runtime overrides |
|
||||
| [🔴 Peak Price Period](config-peak-price.md) | Expensive window detection: same settings, opposite direction |
|
||||
| [📈 Price Trend](config-price-trend.md) | Rising / Falling thresholds for trend sensors |
|
||||
| [📊 Chart Data Export](config-chart-export.md) | Legacy chart export sensor (new setups: use [Chart Actions](chart-actions.md)) |
|
||||
|
||||
Advanced: [🔁 Runtime Override Entities](config-runtime-overrides.md) — number and switch entities for automating configuration changes at runtime.
|
||||
|
|
|
|||
|
|
@ -96,9 +96,10 @@ This means **all intervals meet your criteria** (very cheap day!):
|
|||
**Display mode** (base vs. subunit) is configurable:
|
||||
- Configure in: `Settings > Devices & Services > Tibber Prices > Configure`
|
||||
- Options:
|
||||
- **Base currency**: €/kWh, kr/kWh (decimal values like 0.25)
|
||||
- **Subunit**: ct/kWh, øre/kWh (larger values like 25.00)
|
||||
- **Base currency**: €/kWh, kr/kWh (stored at 4 decimal places, default display: 2 decimals, e.g., 0.25)
|
||||
- **Subunit**: ct/kWh, øre/kWh (stored at 2 decimal places, default display: 1 decimal, e.g., 25.3)
|
||||
- Smart defaults: EUR → subunit, NOK/SEK/DKK → base currency
|
||||
- You can increase the displayed decimals per entity in the HA UI (see [Currency Display](configuration.md#step-2-currency-display))
|
||||
|
||||
If you see unexpected units, check your configuration in the integration options.
|
||||
|
||||
|
|
|
|||
|
|
@ -156,6 +156,15 @@ Price rank (percentile rank) = (number of intervals strictly cheaper than subjec
|
|||
|
||||
The cheapest interval always returns 0% — you can use `state == 0` to detect the absolute cheapest moment.
|
||||
|
||||
:::note 100% is never reached
|
||||
By design, **the most expensive interval of the day will never show 100%**. The formula counts how many intervals are *strictly cheaper* than the subject price. For the daily maximum, every other interval is cheaper — but the interval itself is not counted. With 96 quarter-hour intervals per day, the maximum value is 95 ÷ 96 × 100 = **99.0%**.
|
||||
|
||||
This means:
|
||||
- `state == 0` → cheapest interval of the reference set ✅
|
||||
- `state == 100` → **never true** ❌
|
||||
- `state >= 99` → most expensive interval of the day ✅ (use this instead)
|
||||
:::
|
||||
|
||||
### Available Sensors
|
||||
|
||||
**Current interval** (price of the active quarter-hour):
|
||||
|
|
@ -259,6 +268,23 @@ automation:
|
|||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Avoid running devices at the most expensive time of day</summary>
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Pause non-essential devices at peak price"
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id: sensor.<home_name>_current_price_rank_today
|
||||
above: 99 # 100 is never reached — use >= 99 to catch the daily maximum
|
||||
action:
|
||||
- service: switch.turn_off
|
||||
entity_id: switch.dishwasher
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Show YAML: Pre-heat when the next interval is cheap</summary>
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,28 @@ const sidebars: SidebarsConfig = {
|
|||
type: 'category',
|
||||
label: '🚀 Getting Started',
|
||||
link: { type: 'doc', id: 'installation' },
|
||||
items: ['installation', 'configuration'],
|
||||
items: [
|
||||
'installation',
|
||||
{
|
||||
type: 'category',
|
||||
label: '⚙️ Configuration',
|
||||
link: { type: 'doc', id: 'configuration' },
|
||||
items: [
|
||||
'config-general',
|
||||
'config-currency',
|
||||
'config-price-rating',
|
||||
'config-price-level',
|
||||
'config-volatility',
|
||||
'config-best-price',
|
||||
'config-peak-price',
|
||||
'config-price-trend',
|
||||
'config-chart-export',
|
||||
'config-runtime-overrides',
|
||||
],
|
||||
collapsible: true,
|
||||
collapsed: true,
|
||||
},
|
||||
],
|
||||
collapsible: true,
|
||||
collapsed: false,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
|
||||
[project]
|
||||
name = "tibber_prices"
|
||||
version = "0.0.0" # Version is managed in manifest.json only
|
||||
requires-python = ">=3.14"
|
||||
version = "0.0.0" # Version is managed in manifest.json only
|
||||
requires-python = ">=3.14.2"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["custom_components.tibber_prices"]
|
||||
|
|
|
|||
|
|
@ -11,9 +11,11 @@ if TYPE_CHECKING:
|
|||
|
||||
import pytest
|
||||
|
||||
from custom_components.tibber_prices.services import find_cheapest_block as block_module
|
||||
from custom_components.tibber_prices.services import find_cheapest_hours as hours_module
|
||||
from custom_components.tibber_prices.services import find_cheapest_schedule as schedule_module
|
||||
from custom_components.tibber_prices.services import (
|
||||
find_cheapest_block as block_module,
|
||||
find_cheapest_hours as hours_module,
|
||||
find_cheapest_schedule as schedule_module,
|
||||
)
|
||||
from custom_components.tibber_prices.services.find_cheapest_block import (
|
||||
_determine_no_window_reason,
|
||||
handle_find_cheapest_block,
|
||||
|
|
|
|||
|
|
@ -11,22 +11,15 @@ Also validates schema boundaries for all 4 services.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import time as dt_time
|
||||
from datetime import datetime, time as dt_time, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from custom_components.tibber_prices.services.find_cheapest_block import (
|
||||
_COMMON_BLOCK_SCHEMA,
|
||||
)
|
||||
from custom_components.tibber_prices.services.find_cheapest_hours import (
|
||||
_COMMON_HOURS_SCHEMA,
|
||||
)
|
||||
from custom_components.tibber_prices.services.helpers import (
|
||||
resolve_search_range,
|
||||
)
|
||||
from custom_components.tibber_prices.services.find_cheapest_block import _COMMON_BLOCK_SCHEMA
|
||||
from custom_components.tibber_prices.services.find_cheapest_hours import _COMMON_HOURS_SCHEMA
|
||||
from custom_components.tibber_prices.services.helpers import resolve_search_range
|
||||
|
||||
BERLIN = ZoneInfo("Europe/Berlin")
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,8 @@ from datetime import UTC, datetime, timedelta
|
|||
|
||||
import pytest
|
||||
|
||||
from custom_components.tibber_prices.coordinator.time_service import (
|
||||
TibberPricesTimeService,
|
||||
)
|
||||
from custom_components.tibber_prices.utils.average import (
|
||||
calculate_leading_24h_mean,
|
||||
calculate_trailing_24h_mean,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
from custom_components.tibber_prices.utils.average import calculate_leading_24h_mean, calculate_trailing_24h_mean
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -21,9 +21,7 @@ from custom_components.tibber_prices.coordinator.period_handlers import (
|
|||
TibberPricesPeriodConfig,
|
||||
calculate_periods_with_relaxation,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.time_service import (
|
||||
TibberPricesTimeService,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|||
import pytest
|
||||
|
||||
# Import at module level to avoid PLC0415
|
||||
from custom_components.tibber_prices.coordinator.core import (
|
||||
TibberPricesDataUpdateCoordinator,
|
||||
)
|
||||
from custom_components.tibber_prices.coordinator.core import TibberPricesDataUpdateCoordinator
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue