feat(config_flow): add trend confirmation and noise floor settings

Added 3 new config fields to price trend options step:
- Trend Change Confirmation (2-6 intervals slider)
- Min Price Change for trend (display-unit-aware slider)
- Min Price Change for strong trend (display-unit-aware slider)

Price change sliders scale between base currency (EUR/NOK) storage and
display unit (ct/øre) presentation using get_display_unit_factor().
Added migration in __init__.py to convert old display-unit values to
base currency format.

Impact: Users can tune trend sensitivity: higher confirmation = fewer
false changes, higher min price change = no trends from tiny fluctuations.
This commit is contained in:
Julian Pawlowski 2026-04-07 13:44:47 +00:00
parent 91efeed90f
commit 798de5946d
3 changed files with 135 additions and 2 deletions

View file

@ -21,11 +21,15 @@ from homeassistant.loader import async_get_loaded_integration
from .api import TibberPricesApiClient from .api import TibberPricesApiClient
from .const import ( from .const import (
CONF_CURRENCY_DISPLAY_MODE, CONF_CURRENCY_DISPLAY_MODE,
CONF_PRICE_TREND_MIN_PRICE_CHANGE,
CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
DATA_CHART_CONFIG, DATA_CHART_CONFIG,
DATA_CHART_METADATA_CONFIG, DATA_CHART_METADATA_CONFIG,
DISPLAY_MODE_SUBUNIT, DISPLAY_MODE_SUBUNIT,
DOMAIN, DOMAIN,
LOGGER, LOGGER,
MAX_PRICE_TREND_MIN_PRICE_CHANGE,
MAX_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
async_load_standard_translations, async_load_standard_translations,
async_load_translations, async_load_translations,
) )
@ -141,6 +145,29 @@ async def _migrate_config_options(hass: HomeAssistant, entry: ConfigEntry) -> No
DISPLAY_MODE_SUBUNIT, DISPLAY_MODE_SUBUNIT,
) )
# Migration: Convert min_price_change from display currency (ct/øre) to base currency (EUR/NOK)
# Before this change, values were stored in display units. Now always stored in base currency.
# Detection: If either value exceeds its new max, both are in old format and need conversion.
# Old range: 0-5.0 ct / 0-10.0 ct, New range: 0-0.05 EUR / 0-0.10 EUR
normal_val = migrated.get(CONF_PRICE_TREND_MIN_PRICE_CHANGE)
strongly_val = migrated.get(CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY)
old_format_detected = (normal_val is not None and normal_val > MAX_PRICE_TREND_MIN_PRICE_CHANGE) or (
strongly_val is not None and strongly_val > MAX_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY
)
if old_format_detected:
for key in (CONF_PRICE_TREND_MIN_PRICE_CHANGE, CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY):
if key in migrated and migrated[key] > 0:
old_val = migrated[key]
migrated[key] = round(old_val / 100, 6)
migration_performed = True
LOGGER.info(
"[%s] Migrated config: %s = %s -> %s (converted to base currency)",
entry.title,
key,
old_val,
migrated[key],
)
# Save migrated options if any changes were made # Save migrated options if any changes were made
if migration_performed: if migration_performed:
hass.config_entries.async_update_entry(entry, options=migrated) hass.config_entries.async_update_entry(entry, options=migrated)

View file

@ -60,6 +60,8 @@ from custom_components.tibber_prices.const import (
CONF_PEAK_PRICE_MIN_PERIOD_LENGTH, CONF_PEAK_PRICE_MIN_PERIOD_LENGTH,
CONF_PRICE_RATING_THRESHOLD_HIGH, CONF_PRICE_RATING_THRESHOLD_HIGH,
CONF_PRICE_RATING_THRESHOLD_LOW, CONF_PRICE_RATING_THRESHOLD_LOW,
CONF_PRICE_TREND_MIN_PRICE_CHANGE,
CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
CONF_PRICE_TREND_THRESHOLD_FALLING, CONF_PRICE_TREND_THRESHOLD_FALLING,
CONF_PRICE_TREND_THRESHOLD_RISING, CONF_PRICE_TREND_THRESHOLD_RISING,
CONF_PRICE_TREND_THRESHOLD_STRONGLY_FALLING, CONF_PRICE_TREND_THRESHOLD_STRONGLY_FALLING,
@ -74,7 +76,10 @@ from custom_components.tibber_prices.const import (
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH, DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
DOMAIN, DOMAIN,
async_get_translation, async_get_translation,
format_price_unit_base,
format_price_unit_subunit,
get_default_options, get_default_options,
get_display_unit_factor,
) )
from homeassistant.config_entries import ConfigFlowResult, OptionsFlow from homeassistant.config_entries import ConfigFlowResult, OptionsFlow
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
@ -730,6 +735,9 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
"""Configure price trend thresholds.""" """Configure price trend thresholds."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
# Get display factor for currency conversion
display_factor = get_display_unit_factor(self.config_entry)
if user_input is not None: if user_input is not None:
# Schema is now flattened - fields come directly in user_input # Schema is now flattened - fields come directly in user_input
# Store them flat in options (no nested structure) # Store them flat in options (no nested structure)
@ -775,6 +783,15 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
) )
if not errors: if not errors:
# Convert min_price_change values from display unit to base currency for storage
# (dividing by 1 is a no-op for base currency mode)
user_input[CONF_PRICE_TREND_MIN_PRICE_CHANGE] = round(
user_input[CONF_PRICE_TREND_MIN_PRICE_CHANGE] / display_factor, 6
)
user_input[CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY] = round(
user_input[CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY] / display_factor, 6
)
# Store flat data directly in options (no section wrapping) # Store flat data directly in options (no section wrapping)
self._options.update(user_input) self._options.update(user_input)
# async_create_entry automatically handles change detection and listener triggering # async_create_entry automatically handles change detection and listener triggering
@ -782,9 +799,20 @@ class TibberPricesOptionsFlowHandler(OptionsFlow):
# Return to menu for more changes # Return to menu for more changes
return await self.async_step_init() return await self.async_step_init()
# Get currency code for unit label on sliders
currency_code = self.config_entry.data.get("currency", None)
if display_factor > 1:
price_unit = format_price_unit_subunit(currency_code)
else:
price_unit = format_price_unit_base(currency_code)
return self.async_show_form( return self.async_show_form(
step_id="price_trend", step_id="price_trend",
data_schema=get_price_trend_schema(self.config_entry.options), data_schema=get_price_trend_schema(
self.config_entry.options,
display_factor=display_factor,
price_unit=price_unit,
),
errors=errors, errors=errors,
description_placeholders=self._get_entity_warning_placeholders("price_trend"), description_placeholders=self._get_entity_warning_placeholders("price_trend"),
) )

View file

@ -33,6 +33,9 @@ from custom_components.tibber_prices.const import (
CONF_PRICE_RATING_HYSTERESIS, CONF_PRICE_RATING_HYSTERESIS,
CONF_PRICE_RATING_THRESHOLD_HIGH, CONF_PRICE_RATING_THRESHOLD_HIGH,
CONF_PRICE_RATING_THRESHOLD_LOW, CONF_PRICE_RATING_THRESHOLD_LOW,
CONF_PRICE_TREND_CHANGE_CONFIRMATION,
CONF_PRICE_TREND_MIN_PRICE_CHANGE,
CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
CONF_PRICE_TREND_THRESHOLD_FALLING, CONF_PRICE_TREND_THRESHOLD_FALLING,
CONF_PRICE_TREND_THRESHOLD_RISING, CONF_PRICE_TREND_THRESHOLD_RISING,
CONF_PRICE_TREND_THRESHOLD_STRONGLY_FALLING, CONF_PRICE_TREND_THRESHOLD_STRONGLY_FALLING,
@ -66,6 +69,9 @@ from custom_components.tibber_prices.const import (
DEFAULT_PRICE_RATING_HYSTERESIS, DEFAULT_PRICE_RATING_HYSTERESIS,
DEFAULT_PRICE_RATING_THRESHOLD_HIGH, DEFAULT_PRICE_RATING_THRESHOLD_HIGH,
DEFAULT_PRICE_RATING_THRESHOLD_LOW, DEFAULT_PRICE_RATING_THRESHOLD_LOW,
DEFAULT_PRICE_TREND_CHANGE_CONFIRMATION,
DEFAULT_PRICE_TREND_MIN_PRICE_CHANGE,
DEFAULT_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
DEFAULT_PRICE_TREND_THRESHOLD_FALLING, DEFAULT_PRICE_TREND_THRESHOLD_FALLING,
DEFAULT_PRICE_TREND_THRESHOLD_RISING, DEFAULT_PRICE_TREND_THRESHOLD_RISING,
DEFAULT_PRICE_TREND_THRESHOLD_STRONGLY_FALLING, DEFAULT_PRICE_TREND_THRESHOLD_STRONGLY_FALLING,
@ -88,7 +94,10 @@ from custom_components.tibber_prices.const import (
MAX_PRICE_RATING_HYSTERESIS, MAX_PRICE_RATING_HYSTERESIS,
MAX_PRICE_RATING_THRESHOLD_HIGH, MAX_PRICE_RATING_THRESHOLD_HIGH,
MAX_PRICE_RATING_THRESHOLD_LOW, MAX_PRICE_RATING_THRESHOLD_LOW,
MAX_PRICE_TREND_CHANGE_CONFIRMATION,
MAX_PRICE_TREND_FALLING, MAX_PRICE_TREND_FALLING,
MAX_PRICE_TREND_MIN_PRICE_CHANGE,
MAX_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
MAX_PRICE_TREND_RISING, MAX_PRICE_TREND_RISING,
MAX_PRICE_TREND_STRONGLY_FALLING, MAX_PRICE_TREND_STRONGLY_FALLING,
MAX_PRICE_TREND_STRONGLY_RISING, MAX_PRICE_TREND_STRONGLY_RISING,
@ -103,7 +112,10 @@ from custom_components.tibber_prices.const import (
MIN_PRICE_RATING_HYSTERESIS, MIN_PRICE_RATING_HYSTERESIS,
MIN_PRICE_RATING_THRESHOLD_HIGH, MIN_PRICE_RATING_THRESHOLD_HIGH,
MIN_PRICE_RATING_THRESHOLD_LOW, MIN_PRICE_RATING_THRESHOLD_LOW,
MIN_PRICE_TREND_CHANGE_CONFIRMATION,
MIN_PRICE_TREND_FALLING, MIN_PRICE_TREND_FALLING,
MIN_PRICE_TREND_MIN_PRICE_CHANGE,
MIN_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
MIN_PRICE_TREND_RISING, MIN_PRICE_TREND_RISING,
MIN_PRICE_TREND_STRONGLY_FALLING, MIN_PRICE_TREND_STRONGLY_FALLING,
MIN_PRICE_TREND_STRONGLY_RISING, MIN_PRICE_TREND_STRONGLY_RISING,
@ -907,8 +919,16 @@ def get_peak_price_schema(
) )
def get_price_trend_schema(options: Mapping[str, Any]) -> vol.Schema: def get_price_trend_schema(
options: Mapping[str, Any],
*,
display_factor: int = 1,
price_unit: str = "",
) -> vol.Schema:
"""Return schema for price trend thresholds configuration.""" """Return schema for price trend thresholds configuration."""
# Scale min_price_change values for display (stored in base currency, shown in display unit)
step = 0.1 if display_factor > 1 else 0.001
return vol.Schema( return vol.Schema(
{ {
vol.Optional( vol.Optional(
@ -979,6 +999,64 @@ def get_price_trend_schema(options: Mapping[str, Any]) -> vol.Schema:
mode=NumberSelectorMode.SLIDER, mode=NumberSelectorMode.SLIDER,
), ),
), ),
vol.Optional(
CONF_PRICE_TREND_CHANGE_CONFIRMATION,
default=int(
options.get(
CONF_PRICE_TREND_CHANGE_CONFIRMATION,
DEFAULT_PRICE_TREND_CHANGE_CONFIRMATION,
)
),
): NumberSelector(
NumberSelectorConfig(
min=MIN_PRICE_TREND_CHANGE_CONFIRMATION,
max=MAX_PRICE_TREND_CHANGE_CONFIRMATION,
step=1,
mode=NumberSelectorMode.SLIDER,
),
),
vol.Optional(
CONF_PRICE_TREND_MIN_PRICE_CHANGE,
default=round(
float(
options.get(
CONF_PRICE_TREND_MIN_PRICE_CHANGE,
DEFAULT_PRICE_TREND_MIN_PRICE_CHANGE,
)
)
* display_factor,
3,
),
): NumberSelector(
NumberSelectorConfig(
min=MIN_PRICE_TREND_MIN_PRICE_CHANGE * display_factor,
max=MAX_PRICE_TREND_MIN_PRICE_CHANGE * display_factor,
step=step,
unit_of_measurement=price_unit,
mode=NumberSelectorMode.SLIDER,
),
),
vol.Optional(
CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
default=round(
float(
options.get(
CONF_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
DEFAULT_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY,
)
)
* display_factor,
3,
),
): NumberSelector(
NumberSelectorConfig(
min=MIN_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY * display_factor,
max=MAX_PRICE_TREND_MIN_PRICE_CHANGE_STRONGLY * display_factor,
step=step,
unit_of_measurement=price_unit,
mode=NumberSelectorMode.SLIDER,
),
),
} }
) )