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_MIN_VOLATILITY,
CONF_EXTENDED_DESCRIPTIONS,
CONF_MIN_VOLATILITY_FOR_PERIODS,
CONF_PEAK_PRICE_MIN_LEVEL,
CONF_PEAK_PRICE_MIN_VOLATILITY,
CONF_VOLATILITY_THRESHOLD_HIGH,
@ -39,7 +38,6 @@ from .const import (
DEFAULT_BEST_PRICE_MAX_LEVEL,
DEFAULT_BEST_PRICE_MIN_VOLATILITY,
DEFAULT_EXTENDED_DESCRIPTIONS,
DEFAULT_MIN_VOLATILITY_FOR_PERIODS,
DEFAULT_PEAK_PRICE_MIN_LEVEL,
DEFAULT_PEAK_PRICE_MIN_VOLATILITY,
DEFAULT_VOLATILITY_THRESHOLD_HIGH,
@ -47,7 +45,6 @@ from .const import (
DEFAULT_VOLATILITY_THRESHOLD_VERY_HIGH,
PRICE_LEVEL_MAPPING,
VOLATILITY_HIGH,
VOLATILITY_LOW,
VOLATILITY_MODERATE,
VOLATILITY_VERY_HIGH,
async_get_entity_description,
@ -420,29 +417,21 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
# Peak price sensor
min_volatility = self.coordinator.config_entry.options.get(
CONF_PEAK_PRICE_MIN_VOLATILITY,
# Migration: fall back to old global filter, then to new default
self.coordinator.config_entry.options.get(
CONF_MIN_VOLATILITY_FOR_PERIODS,
DEFAULT_PEAK_PRICE_MIN_VOLATILITY,
),
)
else:
# Best price sensor
min_volatility = self.coordinator.config_entry.options.get(
CONF_BEST_PRICE_MIN_VOLATILITY,
# Migration: fall back to old global filter, then to new default
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)
if min_volatility == VOLATILITY_LOW:
# "low" means no filtering (show at any volatility ≥0ct)
if min_volatility == "low":
return True
# Legacy migration: "ANY" also means no filtering
if min_volatility == "ANY":
# "any" is legacy alias for "low" (no filtering)
if min_volatility == "any":
return True
# Get today's price data to calculate volatility
@ -506,8 +495,8 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
DEFAULT_BEST_PRICE_MAX_LEVEL,
)
# "ANY" means no level filtering
if level_config == "ANY":
# "any" means no level filtering
if level_config == "any":
return True
# Get today's intervals
@ -518,7 +507,8 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
return True # If no data, don't filter
# 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:
# Peak price: level >= min_level (show if ANY interval is expensive enough)
@ -563,9 +553,16 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
A dict with empty periods and a reason attribute explaining why.
"""
# Get appropriate volatility config based on sensor type
if reverse_sort:
min_volatility = self.coordinator.config_entry.options.get(
CONF_MIN_VOLATILITY_FOR_PERIODS,
DEFAULT_MIN_VOLATILITY_FOR_PERIODS,
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
@ -584,10 +581,10 @@ class TibberPricesBinarySensor(TibberPricesEntity, BinarySensorEntity):
# Build reason string explaining which filter(s) prevented display
reasons = []
if min_volatility != "ANY" and not self._check_volatility_filter(reverse_sort=reverse_sort):
reasons.append(f"volatility_below_{min_volatility.lower()}")
if level_config != "ANY" and not self._check_level_filter(reverse_sort=reverse_sort):
reasons.append(f"level_{level_filter_type}_{level_config.lower()}")
if min_volatility != "any" and not self._check_volatility_filter(reverse_sort=reverse_sort):
reasons.append(f"volatility_below_{min_volatility}")
if level_config != "any" and not self._check_level_filter(reverse_sort=reverse_sort):
reasons.append(f"level_{level_filter_type}_{level_config}")
# Join multiple reasons with "and"
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_BEST_PRICE_MIN_VOLATILITY = "best_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_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_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_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_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_PEAK_PRICE_MIN_LEVEL = "ANY" # Default: show peak price periods regardless of price level
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_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
# Home types
HOME_TYPE_APARTMENT = "APARTMENT"
@ -227,35 +225,32 @@ VOLATILITY_OPTIONS = [
# Valid options for minimum volatility filter for periods
MIN_VOLATILITY_FOR_PERIODS_OPTIONS = [
VOLATILITY_LOW, # Show at any volatility (≥0ct spread) - no filter
VOLATILITY_MODERATE, # Only show periods when volatility ≥ MODERATE (≥5ct)
VOLATILITY_HIGH, # Only show periods when volatility ≥ HIGH (≥15ct)
VOLATILITY_VERY_HIGH, # Only show periods when volatility ≥ VERY_HIGH (≥30ct)
VOLATILITY_LOW.lower(), # Show at any volatility (≥0ct spread) - no filter
VOLATILITY_MODERATE.lower(), # Only show periods when volatility ≥ MODERATE (≥5ct)
VOLATILITY_HIGH.lower(), # Only show periods when volatility ≥ HIGH (≥15ct)
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)
# Sorted from cheap to expensive: user selects "up to how expensive"
BEST_PRICE_MAX_LEVEL_OPTIONS = [
"ANY", # No filter, allow all price levels
PRICE_LEVEL_VERY_CHEAP, # Only show if level ≤ VERY_CHEAP
PRICE_LEVEL_CHEAP, # Only show if level ≤ CHEAP
PRICE_LEVEL_NORMAL, # Only show if level ≤ NORMAL
PRICE_LEVEL_EXPENSIVE, # Only show if level ≤ EXPENSIVE
"any", # No filter, allow all price levels
PRICE_LEVEL_VERY_CHEAP.lower(), # Only show if level ≤ VERY_CHEAP
PRICE_LEVEL_CHEAP.lower(), # Only show if level ≤ CHEAP
PRICE_LEVEL_NORMAL.lower(), # Only show if level ≤ NORMAL
PRICE_LEVEL_EXPENSIVE.lower(), # Only show if level ≤ EXPENSIVE
]
# Valid options for peak price minimum level filter (AND-linked with volatility filter)
# Sorted from expensive to cheap: user selects "starting from how expensive"
PEAK_PRICE_MIN_LEVEL_OPTIONS = [
"ANY", # No filter, allow all price levels
PRICE_LEVEL_EXPENSIVE, # Only show if level ≥ EXPENSIVE
PRICE_LEVEL_NORMAL, # Only show if level ≥ NORMAL
PRICE_LEVEL_CHEAP, # Only show if level ≥ CHEAP
PRICE_LEVEL_VERY_CHEAP, # Only show if level ≥ VERY_CHEAP
"any", # No filter, allow all price levels
PRICE_LEVEL_EXPENSIVE.lower(), # Only show if level ≥ EXPENSIVE
PRICE_LEVEL_NORMAL.lower(), # Only show if level ≥ NORMAL
PRICE_LEVEL_CHEAP.lower(), # Only show if level ≥ 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)
PRICE_LEVEL_MAPPING = {
PRICE_LEVEL_VERY_CHEAP: -2,

View file

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

View file

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

View file

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

View file

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

View file

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