fix(translations): resolve hassfest selector key validation errors

Changed all selector option keys from uppercase to lowercase to comply
with Home Assistant's hassfest validation pattern [a-z0-9-_]+.

Fixed inconsistency in PEAK_PRICE_MIN_LEVEL_OPTIONS where some values
were uppercase while others were lowercase.

Changes:
- translations/*.json: All selector keys now lowercase (volatility, price_level)
- const.py: Added .lower() to all PEAK_PRICE_MIN_LEVEL_OPTIONS values
- binary_sensor.py: Added .upper() conversion when looking up price levels
  in PRICE_LEVEL_MAPPING to handle lowercase config values

Impact: Config flow now works correctly with translated selector options.
Hassfest validation passes without selector key errors.
This commit is contained in:
Julian Pawlowski 2025-11-09 15:31:37 +00:00
parent df79afc87e
commit 532a91be58
7 changed files with 93 additions and 101 deletions

View file

@ -30,7 +30,6 @@ from .const import (
CONF_BEST_PRICE_MAX_LEVEL, CONF_BEST_PRICE_MAX_LEVEL,
CONF_BEST_PRICE_MIN_VOLATILITY, CONF_BEST_PRICE_MIN_VOLATILITY,
CONF_EXTENDED_DESCRIPTIONS, CONF_EXTENDED_DESCRIPTIONS,
CONF_MIN_VOLATILITY_FOR_PERIODS,
CONF_PEAK_PRICE_MIN_LEVEL, CONF_PEAK_PRICE_MIN_LEVEL,
CONF_PEAK_PRICE_MIN_VOLATILITY, CONF_PEAK_PRICE_MIN_VOLATILITY,
CONF_VOLATILITY_THRESHOLD_HIGH, CONF_VOLATILITY_THRESHOLD_HIGH,
@ -39,7 +38,6 @@ from .const import (
DEFAULT_BEST_PRICE_MAX_LEVEL, DEFAULT_BEST_PRICE_MAX_LEVEL,
DEFAULT_BEST_PRICE_MIN_VOLATILITY, DEFAULT_BEST_PRICE_MIN_VOLATILITY,
DEFAULT_EXTENDED_DESCRIPTIONS, DEFAULT_EXTENDED_DESCRIPTIONS,
DEFAULT_MIN_VOLATILITY_FOR_PERIODS,
DEFAULT_PEAK_PRICE_MIN_LEVEL, DEFAULT_PEAK_PRICE_MIN_LEVEL,
DEFAULT_PEAK_PRICE_MIN_VOLATILITY, DEFAULT_PEAK_PRICE_MIN_VOLATILITY,
DEFAULT_VOLATILITY_THRESHOLD_HIGH, DEFAULT_VOLATILITY_THRESHOLD_HIGH,
@ -47,7 +45,6 @@ from .const import (
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH, DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
PRICE_LEVEL_MAPPING, PRICE_LEVEL_MAPPING,
VOLATILITY_HIGH, VOLATILITY_HIGH,
VOLATILITY_LOW,
VOLATILITY_MODERATE, VOLATILITY_MODERATE,
VOLATILITY_VERY_HIGH, VOLATILITY_VERY_HIGH,
async_get_entity_description, async_get_entity_description,
@ -420,29 +417,21 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
# Peak price sensor # Peak price sensor
min_volatility = self.coordinator.config_entry.options.get( min_volatility = self.coordinator.config_entry.options.get(
CONF_PEAK_PRICE_MIN_VOLATILITY, CONF_PEAK_PRICE_MIN_VOLATILITY,
# Migration: fall back to old global filter, then to new default DEFAULT_PEAK_PRICE_MIN_VOLATILITY,
self.coordinator.config_entry.options.get(
CONF_MIN_VOLATILITY_FOR_PERIODS,
DEFAULT_PEAK_PRICE_MIN_VOLATILITY,
),
) )
else: else:
# Best price sensor # Best price sensor
min_volatility = self.coordinator.config_entry.options.get( min_volatility = self.coordinator.config_entry.options.get(
CONF_BEST_PRICE_MIN_VOLATILITY, CONF_BEST_PRICE_MIN_VOLATILITY,
# Migration: fall back to old global filter, then to new default DEFAULT_BEST_PRICE_MIN_VOLATILITY,
self.coordinator.config_entry.options.get(
CONF_MIN_VOLATILITY_FOR_PERIODS,
DEFAULT_BEST_PRICE_MIN_VOLATILITY,
),
) )
# "LOW" means no filtering (show at any volatility ≥0ct) # "low" means no filtering (show at any volatility ≥0ct)
if min_volatility == VOLATILITY_LOW: if min_volatility == "low":
return True return True
# Legacy migration: "ANY" also means no filtering # "any" is legacy alias for "low" (no filtering)
if min_volatility == "ANY": if min_volatility == "any":
return True return True
# Get today's price data to calculate volatility # Get today's price data to calculate volatility
@ -506,8 +495,8 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
DEFAULT_BEST_PRICE_MAX_LEVEL, DEFAULT_BEST_PRICE_MAX_LEVEL,
) )
# "ANY" means no level filtering # "any" means no level filtering
if level_config == "ANY": if level_config == "any":
return True return True
# Get today's intervals # Get today's intervals
@ -518,7 +507,8 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
return True # If no data, don't filter return True # If no data, don't filter
# Check if ANY interval today meets the level requirement # Check if ANY interval today meets the level requirement
level_order = PRICE_LEVEL_MAPPING.get(level_config, 0) # Note: level_config is lowercase from selector, but PRICE_LEVEL_MAPPING uses uppercase
level_order = PRICE_LEVEL_MAPPING.get(level_config.upper(), 0)
if reverse_sort: if reverse_sort:
# Peak price: level >= min_level (show if ANY interval is expensive enough) # Peak price: level >= min_level (show if ANY interval is expensive enough)
@ -563,10 +553,17 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
A dict with empty periods and a reason attribute explaining why. A dict with empty periods and a reason attribute explaining why.
""" """
min_volatility = self.coordinator.config_entry.options.get( # Get appropriate volatility config based on sensor type
CONF_MIN_VOLATILITY_FOR_PERIODS, if reverse_sort:
DEFAULT_MIN_VOLATILITY_FOR_PERIODS, min_volatility = self.coordinator.config_entry.options.get(
) CONF_PEAK_PRICE_MIN_VOLATILITY,
DEFAULT_PEAK_PRICE_MIN_VOLATILITY,
)
else:
min_volatility = self.coordinator.config_entry.options.get(
CONF_BEST_PRICE_MIN_VOLATILITY,
DEFAULT_BEST_PRICE_MIN_VOLATILITY,
)
# Get appropriate level config based on sensor type # Get appropriate level config based on sensor type
if reverse_sort: if reverse_sort:
@ -584,10 +581,10 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
# Build reason string explaining which filter(s) prevented display # Build reason string explaining which filter(s) prevented display
reasons = [] reasons = []
if min_volatility != "ANY" and not self._check_volatility_filter(reverse_sort=reverse_sort): if min_volatility != "any" and not self._check_volatility_filter(reverse_sort=reverse_sort):
reasons.append(f"volatility_below_{min_volatility.lower()}") reasons.append(f"volatility_below_{min_volatility}")
if level_config != "ANY" and not self._check_level_filter(reverse_sort=reverse_sort): if level_config != "any" and not self._check_level_filter(reverse_sort=reverse_sort):
reasons.append(f"level_{level_filter_type}_{level_config.lower()}") reasons.append(f"level_{level_filter_type}_{level_config}")
# Join multiple reasons with "and" # Join multiple reasons with "and"
reason = "_and_".join(reasons) if reasons else "filtered" reason = "_and_".join(reasons) if reasons else "filtered"

View file

@ -33,7 +33,6 @@ CONF_VOLATILITY_THRESHOLD_HIGH = "volatility_threshold_high"
CONF_VOLATILITY_THRESHOLD_VERY_HIGH = "volatility_threshold_very_high" CONF_VOLATILITY_THRESHOLD_VERY_HIGH = "volatility_threshold_very_high"
CONF_BEST_PRICE_MIN_VOLATILITY = "best_price_min_volatility" CONF_BEST_PRICE_MIN_VOLATILITY = "best_price_min_volatility"
CONF_PEAK_PRICE_MIN_VOLATILITY = "peak_price_min_volatility" CONF_PEAK_PRICE_MIN_VOLATILITY = "peak_price_min_volatility"
CONF_MIN_VOLATILITY_FOR_PERIODS = "min_volatility_for_periods" # Deprecated: Use CONF_BEST_PRICE_MIN_VOLATILITY
CONF_BEST_PRICE_MAX_LEVEL = "best_price_max_level" CONF_BEST_PRICE_MAX_LEVEL = "best_price_max_level"
CONF_PEAK_PRICE_MIN_LEVEL = "peak_price_min_level" CONF_PEAK_PRICE_MIN_LEVEL = "peak_price_min_level"
@ -55,11 +54,10 @@ DEFAULT_PRICE_TREND_THRESHOLD_FALLING = -5 # Default trend threshold for fallin
DEFAULT_VOLATILITY_THRESHOLD_MODERATE = 5.0 # Default threshold for MODERATE volatility (ct/øre) DEFAULT_VOLATILITY_THRESHOLD_MODERATE = 5.0 # Default threshold for MODERATE volatility (ct/øre)
DEFAULT_VOLATILITY_THRESHOLD_HIGH = 15.0 # Default threshold for HIGH volatility (ct/øre) DEFAULT_VOLATILITY_THRESHOLD_HIGH = 15.0 # Default threshold for HIGH volatility (ct/øre)
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH = 30.0 # Default threshold for VERY_HIGH volatility (ct/øre) DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH = 30.0 # Default threshold for VERY_HIGH volatility (ct/øre)
DEFAULT_BEST_PRICE_MIN_VOLATILITY = "LOW" # Show best price at any volatility (optimization always useful) DEFAULT_BEST_PRICE_MIN_VOLATILITY = "low" # Show best price at any volatility (optimization always useful)
DEFAULT_PEAK_PRICE_MIN_VOLATILITY = "LOW" # Always show peak price (warning relevant even at low spreads) DEFAULT_PEAK_PRICE_MIN_VOLATILITY = "low" # Always show peak price (warning relevant even at low spreads)
DEFAULT_MIN_VOLATILITY_FOR_PERIODS = "LOW" # Deprecated: Use DEFAULT_BEST_PRICE_MIN_VOLATILITY DEFAULT_BEST_PRICE_MAX_LEVEL = "any" # Default: show best price periods regardless of price level
DEFAULT_BEST_PRICE_MAX_LEVEL = "ANY" # Default: show best price periods regardless of price level DEFAULT_PEAK_PRICE_MIN_LEVEL = "any" # Default: show peak price periods regardless of price level
DEFAULT_PEAK_PRICE_MIN_LEVEL = "ANY" # Default: show peak price periods regardless of price level
# Home types # Home types
HOME_TYPE_APARTMENT = "APARTMENT" HOME_TYPE_APARTMENT = "APARTMENT"
@ -227,35 +225,32 @@ VOLATILITY_OPTIONS = [
# Valid options for minimum volatility filter for periods # Valid options for minimum volatility filter for periods
MIN_VOLATILITY_FOR_PERIODS_OPTIONS = [ MIN_VOLATILITY_FOR_PERIODS_OPTIONS = [
VOLATILITY_LOW, # Show at any volatility (≥0ct spread) - no filter VOLATILITY_LOW.lower(), # Show at any volatility (≥0ct spread) - no filter
VOLATILITY_MODERATE, # Only show periods when volatility ≥ MODERATE (≥5ct) VOLATILITY_MODERATE.lower(), # Only show periods when volatility ≥ MODERATE (≥5ct)
VOLATILITY_HIGH, # Only show periods when volatility ≥ HIGH (≥15ct) VOLATILITY_HIGH.lower(), # Only show periods when volatility ≥ HIGH (≥15ct)
VOLATILITY_VERY_HIGH, # Only show periods when volatility ≥ VERY_HIGH (≥30ct) VOLATILITY_VERY_HIGH.lower(), # Only show periods when volatility ≥ VERY_HIGH (≥30ct)
] ]
# Valid options for best price maximum level filter (AND-linked with volatility filter) # Valid options for best price maximum level filter (AND-linked with volatility filter)
# Sorted from cheap to expensive: user selects "up to how expensive" # Sorted from cheap to expensive: user selects "up to how expensive"
BEST_PRICE_MAX_LEVEL_OPTIONS = [ BEST_PRICE_MAX_LEVEL_OPTIONS = [
"ANY", # No filter, allow all price levels "any", # No filter, allow all price levels
PRICE_LEVEL_VERY_CHEAP, # Only show if level ≤ VERY_CHEAP PRICE_LEVEL_VERY_CHEAP.lower(), # Only show if level ≤ VERY_CHEAP
PRICE_LEVEL_CHEAP, # Only show if level ≤ CHEAP PRICE_LEVEL_CHEAP.lower(), # Only show if level ≤ CHEAP
PRICE_LEVEL_NORMAL, # Only show if level ≤ NORMAL PRICE_LEVEL_NORMAL.lower(), # Only show if level ≤ NORMAL
PRICE_LEVEL_EXPENSIVE, # Only show if level ≤ EXPENSIVE PRICE_LEVEL_EXPENSIVE.lower(), # Only show if level ≤ EXPENSIVE
] ]
# Valid options for peak price minimum level filter (AND-linked with volatility filter) # Valid options for peak price minimum level filter (AND-linked with volatility filter)
# Sorted from expensive to cheap: user selects "starting from how expensive" # Sorted from expensive to cheap: user selects "starting from how expensive"
PEAK_PRICE_MIN_LEVEL_OPTIONS = [ PEAK_PRICE_MIN_LEVEL_OPTIONS = [
"ANY", # No filter, allow all price levels "any", # No filter, allow all price levels
PRICE_LEVEL_EXPENSIVE, # Only show if level ≥ EXPENSIVE PRICE_LEVEL_EXPENSIVE.lower(), # Only show if level ≥ EXPENSIVE
PRICE_LEVEL_NORMAL, # Only show if level ≥ NORMAL PRICE_LEVEL_NORMAL.lower(), # Only show if level ≥ NORMAL
PRICE_LEVEL_CHEAP, # Only show if level ≥ CHEAP PRICE_LEVEL_CHEAP.lower(), # Only show if level ≥ CHEAP
PRICE_LEVEL_VERY_CHEAP, # Only show if level ≥ VERY_CHEAP PRICE_LEVEL_VERY_CHEAP.lower(), # Only show if level ≥ VERY_CHEAP
] ]
# Deprecated: Use BEST_PRICE_MAX_LEVEL_OPTIONS or PEAK_PRICE_MIN_LEVEL_OPTIONS instead
MAX_LEVEL_FOR_PERIODS_OPTIONS = BEST_PRICE_MAX_LEVEL_OPTIONS
# Mapping for comparing price levels (used for sorting) # Mapping for comparing price levels (used for sorting)
PRICE_LEVEL_MAPPING = { PRICE_LEVEL_MAPPING = {
PRICE_LEVEL_VERY_CHEAP: -2, PRICE_LEVEL_VERY_CHEAP: -2,

View file

@ -479,20 +479,20 @@
"selector": { "selector": {
"volatility": { "volatility": {
"options": { "options": {
"LOW": "Niedrig", "low": "Niedrig",
"MODERATE": "Moderat", "moderate": "Moderat",
"HIGH": "Hoch", "high": "Hoch",
"VERY_HIGH": "Sehr hoch" "very_high": "Sehr hoch"
} }
}, },
"price_level": { "price_level": {
"options": { "options": {
"ANY": "Beliebig", "any": "Beliebig",
"VERY_CHEAP": "Sehr günstig", "very_cheap": "Sehr günstig",
"CHEAP": "Günstig", "cheap": "Günstig",
"NORMAL": "Normal", "normal": "Normal",
"EXPENSIVE": "Teuer", "expensive": "Teuer",
"VERY_EXPENSIVE": "Sehr teuer" "very_expensive": "Sehr teuer"
} }
} }
}, },

View file

@ -475,20 +475,20 @@
"selector": { "selector": {
"volatility": { "volatility": {
"options": { "options": {
"LOW": "Low", "low": "Low",
"MODERATE": "Moderate", "moderate": "Moderate",
"HIGH": "High", "high": "High",
"VERY_HIGH": "Very high" "very_high": "Very high"
} }
}, },
"price_level": { "price_level": {
"options": { "options": {
"ANY": "Any", "any": "Any",
"VERY_CHEAP": "Very cheap", "very_cheap": "Very cheap",
"CHEAP": "Cheap", "cheap": "Cheap",
"NORMAL": "Normal", "normal": "Normal",
"EXPENSIVE": "Expensive", "expensive": "Expensive",
"VERY_EXPENSIVE": "Very expensive" "very_expensive": "Very expensive"
} }
} }
}, },

View file

@ -475,20 +475,20 @@
"selector": { "selector": {
"volatility": { "volatility": {
"options": { "options": {
"LOW": "Lav", "low": "Lav",
"MODERATE": "Moderat", "moderate": "Moderat",
"HIGH": "Høy", "high": "Høy",
"VERY_HIGH": "Svært høy" "very_high": "Svært høy"
} }
}, },
"price_level": { "price_level": {
"options": { "options": {
"ANY": "Alle", "any": "Alle",
"VERY_CHEAP": "Svært billig", "very_cheap": "Svært billig",
"CHEAP": "Billig", "cheap": "Billig",
"NORMAL": "Normal", "normal": "Normal",
"EXPENSIVE": "Dyr", "expensive": "Dyr",
"VERY_EXPENSIVE": "Svært dyr" "very_expensive": "Svært dyr"
} }
} }
}, },

View file

@ -475,20 +475,20 @@
"selector": { "selector": {
"volatility": { "volatility": {
"options": { "options": {
"LOW": "Laag", "low": "Laag",
"MODERATE": "Matig", "moderate": "Matig",
"HIGH": "Hoog", "high": "Hoog",
"VERY_HIGH": "Zeer hoog" "very_high": "Zeer hoog"
} }
}, },
"price_level": { "price_level": {
"options": { "options": {
"ANY": "Alle", "any": "Alle",
"VERY_CHEAP": "Zeer goedkoop", "very_cheap": "Zeer goedkoop",
"CHEAP": "Goedkoop", "cheap": "Goedkoop",
"NORMAL": "Normaal", "normal": "Normaal",
"EXPENSIVE": "Duur", "expensive": "Duur",
"VERY_EXPENSIVE": "Zeer duur" "very_expensive": "Zeer duur"
} }
} }
}, },

View file

@ -475,20 +475,20 @@
"selector": { "selector": {
"volatility": { "volatility": {
"options": { "options": {
"LOW": "Låg", "low": "Låg",
"MODERATE": "Måttlig", "moderate": "Måttlig",
"HIGH": "Hög", "high": "Hög",
"VERY_HIGH": "Mycket hög" "very_high": "Mycket hög"
} }
}, },
"price_level": { "price_level": {
"options": { "options": {
"ANY": "Alla", "any": "Alla",
"VERY_CHEAP": "Mycket billigt", "very_cheap": "Mycket billigt",
"CHEAP": "Billigt", "cheap": "Billigt",
"NORMAL": "Normalt", "normal": "Normalt",
"EXPENSIVE": "Dyrt", "expensive": "Dyrt",
"VERY_EXPENSIVE": "Mycket dyrt" "very_expensive": "Mycket dyrt"
} }
} }
}, },