Compare commits

...

9 commits

Author SHA1 Message Date
Julian Pawlowski
ee9adce9d5 feat(docs): add comprehensive configuration documentation for Tibber Prices integration
Some checks failed
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
Auto-Tag on Version Bump / Check and create version tag (push) Has been cancelled
Introduced new documentation files covering various configuration aspects such as chart data export, currency display, general settings, peak price periods, price levels, price ratings, price trends, and volatility. Each section provides detailed explanations of settings, their impacts, and migration guidance for legacy features.

Impact: Users gain clear guidance on configuring the Tibber Prices integration, enhancing usability and understanding of features.

### Notes
- New files include config-chart-export.md, config-currency.md, config-general.md, config-peak-price.md, config-price-level.md, config-price-rating.md, config-price-trend.md, config-runtime-overrides.md, and config-volatility.md.
- Updated sidebar for improved navigation within the documentation.
2026-04-14 21:11:37 +00:00
Julian Pawlowski
240acac00a refactor(AGENTS.md): clarify legacy migration guidelines
Updated the legacy migration section to specify that only changes released under a public version tag require migration code. Added clarifications regarding uncommitted changes and new features not needing migration.

Impact: Provides clearer guidance for developers on handling legacy migrations, reducing potential confusion.
2026-04-14 21:10:47 +00:00
Julian Pawlowski
33fa536198 chore: bump version 2026-04-14 20:42:08 +00:00
Julian Pawlowski
1d065b11cd fix(services): use injected now in resolve_search_range day offset
_resolve_time_with_day_offset() was calling dt_util.now() internally
instead of using the injected now parameter. This caused incorrect date
calculations in tests and any caller that passes a specific reference time.

Also add missing price_rank_* sensor keys to TIME_SENSITIVE_ENTITY_KEYS
in coordinator/constants.py so quarter-hour refresh is registered for all
11 price rank sensors (current/next/previous interval and hour variants).

Rename dt as dt_utils → dt as dt_util (ICN001) across 11 files to follow
the project-wide import alias convention. Apply ruff auto-fixes for import
ordering and collapsing single-item imports throughout the codebase.

Released-Bug: no
2026-04-14 19:33:24 +00:00
Julian Pawlowski
07788a57ea chore(pyproject): update Python version requirement to 3.14.2
Adjusted the required Python version in the project configuration to ensure compatibility with the latest features and improvements.

Impact: Users must have Python 3.14.2 or higher to run the project.
2026-04-14 19:30:57 +00:00
Julian Pawlowski
ccf1d6185d docs(configuration): clarify stored vs display precision and statistics guidance
Restructure configuration.md: separate "stored precision" from "default display
precision" tables to avoid confusion between internal representation and what is
shown in dashboards. Add note that HA shows its own unit-change dialog (delayed);
our repair issue appears immediately as step 1. Recommend deleting old statistics
data rather than re-labeling, which would leave wrong values with the new unit.

Update faq.md examples to use display precision values for consistency with the
documentation.
2026-04-14 19:28:57 +00:00
Julian Pawlowski
061b42b8f3 feat(options): show persistent repair issue after currency mode change
Add DATA_STATISTICS_REVIEW_REQUIRED flag to config_entry.data. Set on
currency mode change, cleared on same-mode save. On every async_setup_entry
with flag set, delete and recreate the repair issue so it reappears after
HA restart even if previously dismissed.

Repair issue text explains that HA Recorder shows its own unit-change
dialog (delayed) and recommends deleting old statistic data rather than
re-labeling, which would leave wrong values with the new unit.

Impact: Users are notified to review statistics and automations after
switching between base/subunit currency mode. Notification persists across
HA restarts until acknowledged by saving display settings again.
2026-04-14 19:28:47 +00:00
Julian Pawlowski
a4ad506e01 feat(sensor): use dynamic precision for price rounding and display
Add get_display_precision() to const.py returning DISPLAY_PRECISION_SUBUNIT (2)
or DISPLAY_PRECISION_BASE (4) based on config. Replace hardcoded round(..., 2)
with get_display_precision() in all calculators and attribute builders.
Add _update_suggested_precision() to sensor core; syncs entity registry
suggested_display_precision on every coordinator update.

Interval price sensors get full precision (2 or 4 dp); other MONETARY sensors
get half precision (1 or 2 dp) as sensible default.

Impact: Price sensor states and attributes now correctly use 4 decimal places
in base-currency mode (was always 2). Display precision in dashboards updates
automatically when currency mode changes.
2026-04-14 19:28:19 +00:00
Julian Pawlowski
6d22ea7151 docs(sensors-volatility): clarify price rank calculation and provide automation example
Enhance the documentation for volatility sensors by explaining that the maximum price rank will never reach 100% and providing a YAML automation example for managing device usage during peak pricing.

Impact: Users gain a clearer understanding of price rank behavior and practical automation guidance.
2026-04-14 19:18:25 +00:00
120 changed files with 1204 additions and 1019 deletions

View file

@ -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).

View file

@ -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")

View file

@ -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:
"""

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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()

View file

@ -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:
"""

View file

@ -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 []

View file

@ -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.

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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],
*,

View file

@ -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"

View file

@ -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)

View file

@ -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)

View file

@ -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__)

View file

@ -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.

View file

@ -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:
"""

View file

@ -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

View file

@ -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."""

View file

@ -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,

View file

@ -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,

View file

@ -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)

View file

@ -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(

View file

@ -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)

View file

@ -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):

View file

@ -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:

View file

@ -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()

View file

@ -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)

View file

@ -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"
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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:

View file

@ -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(
{

View file

@ -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.

View file

@ -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,

View file

@ -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(

View file

@ -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

View file

@ -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()

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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")

View file

@ -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),

View file

@ -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

View file

@ -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)

View file

@ -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 (subunit2, base4)
- Energy Dashboard sensor (current_interval_price_base): Always 4
- All other price sensors: Reduced precision (subunit1, base2)
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:

View file

@ -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

View file

@ -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:

View file

@ -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,

View file

@ -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 (

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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.

View file

@ -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.

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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": {

View file

@ -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 Assistants 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": {

View file

@ -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": {

View file

@ -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 Assistants 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": {

View file

@ -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": {

View file

@ -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,

View file

@ -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

View 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 | 050% | Flex percentage |
| `number.<home_name>_best_price_minimum_distance` | Number | -500% | Minimum distance from average |
| `number.<home_name>_best_price_minimum_period_length` | Number | 15180 min | Minimum period length |
| `number.<home_name>_best_price_minimum_periods` | Number | 110 | Target periods per day |
| `number.<home_name>_best_price_relaxation_attempts` | Number | 112 | Relaxation attempts |
| `number.<home_name>_best_price_gap_tolerance` | Number | 08 | 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.

View 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.

View 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 — 34 decimals are meaningful.

View 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.

View 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 | -500% | Flex percentage |
| `number.<home_name>_peak_price_minimum_distance` | Number | 050% | Minimum distance from average |
| `number.<home_name>_peak_price_minimum_period_length` | Number | 15180 min | Minimum period length |
| `number.<home_name>_peak_price_minimum_periods` | Number | 110 | Target periods per day |
| `number.<home_name>_peak_price_relaxation_attempts` | Number | 112 | Relaxation attempts |
| `number.<home_name>_peak_price_gap_tolerance` | Number | 08 | 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.

View 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

View 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

View 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

View 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 | 050% | [Flex percentage](config-best-price.md) |
| `number.<home_name>_best_price_minimum_distance` | Number | -500% | [Minimum distance from average](config-best-price.md) |
| `number.<home_name>_best_price_minimum_period_length` | Number | 15180 min | [Minimum period length](config-best-price.md) |
| `number.<home_name>_best_price_minimum_periods` | Number | 110 | [Target periods per day](config-best-price.md) |
| `number.<home_name>_best_price_relaxation_attempts` | Number | 112 | [Relaxation attempts](config-best-price.md) |
| `number.<home_name>_best_price_gap_tolerance` | Number | 08 | [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 | -500% | [Flex percentage](config-peak-price.md) |
| `number.<home_name>_peak_price_minimum_distance` | Number | 050% | [Minimum distance from average](config-peak-price.md) |
| `number.<home_name>_peak_price_minimum_period_length` | Number | 15180 min | [Minimum period length](config-peak-price.md) |
| `number.<home_name>_peak_price_minimum_periods` | Number | 110 | [Target periods per day](config-peak-price.md) |
| `number.<home_name>_peak_price_relaxation_attempts` | Number | 112 | [Relaxation attempts](config-peak-price.md) |
| `number.<home_name>_peak_price_gap_tolerance` | Number | 08 | [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.

View 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.
:::

View file

@ -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.

View file

@ -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.

View file

@ -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>

View file

@ -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,
},

View file

@ -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"]

View file

@ -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,

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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