mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-30 05:13:40 +00:00
Convert best_price and peak_price timing sensors to display in hours (UI-friendly)
while retaining minute values in attributes (automation-friendly). This improves
readability in dashboards by using Home Assistant's automatic duration formatting
"1 h 35 min" instead of decimal "1.58 h".
BREAKING CHANGE: State unit changed from minutes to hours for 6 timing sensors.
Affected sensors:
* best_price_period_duration, best_price_remaining_minutes, best_price_next_in_minutes
* peak_price_period_duration, peak_price_remaining_minutes, peak_price_next_in_minutes
Migration guide for users:
- If your automations use {{ state_attr(..., 'remaining_time') }} or similar:
No action needed - attribute values remain in minutes
- If your automations use {{ states('sensor.best_price_remaining_minutes') }} directly:
Update to use the minute attribute instead: {{ state_attr('sensor.best_price_remaining_minutes', 'remaining_minutes') }}
- If your dashboards display the state value:
Values now show as "1 h 35 min" instead of "95" - this is the intended improvement
- If your templates do math with the state: multiply by 60 to convert hours back to minutes
Before: remaining * 60
After: remaining_minutes (use attribute directly)
Implementation details:
- Timing sensors now use device_class=DURATION, unit=HOURS, precision=2
- State values converted from minutes to hours via _minutes_to_hours()
- New minute-precision attributes added for automation compatibility:
* period_duration_minutes (for checking if period is long enough)
* remaining_minutes (for countdown-based automation logic)
* next_in_minutes (for time-to-event automation triggers)
- Translation improvements across all 5 languages (en, de, nb, nl, sv):
* Descriptions now clarify state in hours vs attributes in minutes
* Long descriptions explain dual-format architecture
* Usage tips updated to reference minute attributes for automations
* All translation files synchronized (fixed order, removed duplicates)
- Type safety: Added type assertions (cast) for timing calculator results to
satisfy Pyright type checking (handles both float and datetime return types)
Home Assistant now automatically formats these durations as "1 h 35 min" for improved
UX, matching the behavior of battery.remaining_time and other duration sensors.
Rationale for breaking change:
The previous minute-based state was unintuitive for users ("95 minutes" doesn't
immediately convey "1.5 hours") and didn't match Home Assistant's standard duration
formatting. The new hour-based state with minute attributes provides:
- Better UX: Automatic "1 h 35 min" formatting in UI
- Full automation compatibility: Minute attributes for all calculation needs
- Consistency: Matches HA's duration sensor pattern (battery, timer, etc.)
Impact: Timing sensors now display in human-readable hours with full backward
compatibility via minute attributes. Users relying on direct state access must
migrate to minute attributes (simple change, documented above).
459 lines
16 KiB
Python
459 lines
16 KiB
Python
"""
|
|
Test sensor-to-timer assignment correctness.
|
|
|
|
This tests the CRITICAL mapping between sensor entities and update timers:
|
|
- TIME_SENSITIVE sensors → Timer #2 (quarter-hour: :00, :15, :30, :45)
|
|
- MINUTE_UPDATE sensors → Timer #3 (minute: :00, :30)
|
|
- All other sensors → No timer (only update on API data arrival)
|
|
|
|
Ensures:
|
|
1. Each sensor is assigned to the correct timer
|
|
2. Timer constants match sensor definitions
|
|
3. No sensors are missing from or incorrectly added to timer groups
|
|
"""
|
|
|
|
from custom_components.tibber_prices.binary_sensor.definitions import (
|
|
ENTITY_DESCRIPTIONS as BINARY_SENSOR_ENTITY_DESCRIPTIONS,
|
|
)
|
|
from custom_components.tibber_prices.coordinator.constants import (
|
|
MINUTE_UPDATE_ENTITY_KEYS,
|
|
TIME_SENSITIVE_ENTITY_KEYS,
|
|
)
|
|
from custom_components.tibber_prices.sensor.definitions import ENTITY_DESCRIPTIONS
|
|
|
|
|
|
def test_time_sensitive_sensors_are_valid() -> None:
|
|
"""
|
|
Test that all TIME_SENSITIVE_ENTITY_KEYS correspond to actual sensors.
|
|
|
|
Timer #2 (quarter-hour) should only trigger for sensors that exist.
|
|
"""
|
|
all_sensor_keys = {desc.key for desc in ENTITY_DESCRIPTIONS}
|
|
all_binary_sensor_keys = {desc.key for desc in BINARY_SENSOR_ENTITY_DESCRIPTIONS}
|
|
all_entity_keys = all_sensor_keys | all_binary_sensor_keys
|
|
|
|
for entity_key in TIME_SENSITIVE_ENTITY_KEYS:
|
|
assert entity_key in all_entity_keys, (
|
|
f"TIME_SENSITIVE key '{entity_key}' not found in sensor/binary_sensor definitions"
|
|
)
|
|
|
|
|
|
def test_minute_update_sensors_are_valid() -> None:
|
|
"""
|
|
Test that all MINUTE_UPDATE_ENTITY_KEYS correspond to actual sensors.
|
|
|
|
Timer #3 (minute) should only trigger for sensors that exist.
|
|
"""
|
|
all_sensor_keys = {desc.key for desc in ENTITY_DESCRIPTIONS}
|
|
all_binary_sensor_keys = {desc.key for desc in BINARY_SENSOR_ENTITY_DESCRIPTIONS}
|
|
all_entity_keys = all_sensor_keys | all_binary_sensor_keys
|
|
|
|
for entity_key in MINUTE_UPDATE_ENTITY_KEYS:
|
|
assert entity_key in all_entity_keys, (
|
|
f"MINUTE_UPDATE key '{entity_key}' not found in sensor/binary_sensor definitions"
|
|
)
|
|
|
|
|
|
def test_no_overlap_between_timer_groups() -> None:
|
|
"""
|
|
Test that TIME_SENSITIVE and MINUTE_UPDATE groups are mutually exclusive.
|
|
|
|
A sensor should never be in both timer groups simultaneously.
|
|
This would cause duplicate updates and wasted resources.
|
|
"""
|
|
overlap = TIME_SENSITIVE_ENTITY_KEYS & MINUTE_UPDATE_ENTITY_KEYS
|
|
|
|
assert not overlap, (
|
|
f"Sensors should not be in both TIME_SENSITIVE and MINUTE_UPDATE: {overlap}\n"
|
|
"Each sensor should use only ONE timer for updates."
|
|
)
|
|
|
|
|
|
def test_interval_sensors_use_quarter_hour_timer() -> None:
|
|
"""
|
|
Test that interval-based sensors (current/next/previous) use Timer #2.
|
|
|
|
These sensors need updates every 15 minutes because they reference
|
|
specific 15-minute intervals that change at quarter-hour boundaries.
|
|
"""
|
|
interval_sensors = [
|
|
"current_interval_price",
|
|
"next_interval_price",
|
|
"previous_interval_price",
|
|
"current_interval_price_level",
|
|
"next_interval_price_level",
|
|
"previous_interval_price_level",
|
|
"current_interval_price_rating",
|
|
"next_interval_price_rating",
|
|
"previous_interval_price_rating",
|
|
]
|
|
|
|
for sensor_key in interval_sensors:
|
|
assert sensor_key in TIME_SENSITIVE_ENTITY_KEYS, (
|
|
f"Interval sensor '{sensor_key}' should be TIME_SENSITIVE (Timer #2)"
|
|
)
|
|
|
|
|
|
def test_rolling_hour_sensors_use_quarter_hour_timer() -> None:
|
|
"""
|
|
Test that rolling hour sensors (5-interval windows) use Timer #2.
|
|
|
|
Rolling hour calculations depend on current interval position,
|
|
which changes every 15 minutes.
|
|
"""
|
|
rolling_hour_sensors = [
|
|
"current_hour_average_price",
|
|
"next_hour_average_price",
|
|
"current_hour_price_level",
|
|
"next_hour_price_level",
|
|
"current_hour_price_rating",
|
|
"next_hour_price_rating",
|
|
]
|
|
|
|
for sensor_key in rolling_hour_sensors:
|
|
assert sensor_key in TIME_SENSITIVE_ENTITY_KEYS, (
|
|
f"Rolling hour sensor '{sensor_key}' should be TIME_SENSITIVE (Timer #2)"
|
|
)
|
|
|
|
|
|
def test_future_mean_sensors_use_quarter_hour_timer() -> None:
|
|
"""
|
|
Test that future N-hour mean sensors use Timer #2.
|
|
|
|
Future means calculate rolling windows starting from "next interval",
|
|
which changes every 15 minutes.
|
|
"""
|
|
future_mean_sensors = [
|
|
"next_avg_1h",
|
|
"next_avg_2h",
|
|
"next_avg_3h",
|
|
"next_avg_4h",
|
|
"next_avg_5h",
|
|
"next_avg_6h",
|
|
"next_avg_8h",
|
|
"next_avg_12h",
|
|
]
|
|
|
|
for sensor_key in future_mean_sensors:
|
|
assert sensor_key in TIME_SENSITIVE_ENTITY_KEYS, (
|
|
f"Future mean sensor '{sensor_key}' should be TIME_SENSITIVE (Timer #2)"
|
|
)
|
|
|
|
|
|
def test_trend_sensors_use_quarter_hour_timer() -> None:
|
|
"""
|
|
Test that price trend sensors use Timer #2.
|
|
|
|
Trend analysis depends on current interval position and
|
|
needs updates at quarter-hour boundaries.
|
|
"""
|
|
trend_sensors = [
|
|
"current_price_trend",
|
|
"next_price_trend_change",
|
|
"price_trend_1h",
|
|
"price_trend_2h",
|
|
"price_trend_3h",
|
|
"price_trend_4h",
|
|
"price_trend_5h",
|
|
"price_trend_6h",
|
|
"price_trend_8h",
|
|
"price_trend_12h",
|
|
]
|
|
|
|
for sensor_key in trend_sensors:
|
|
assert sensor_key in TIME_SENSITIVE_ENTITY_KEYS, (
|
|
f"Trend sensor '{sensor_key}' should be TIME_SENSITIVE (Timer #2)"
|
|
)
|
|
|
|
|
|
def test_window_24h_sensors_use_quarter_hour_timer() -> None:
|
|
"""
|
|
Test that trailing/leading 24h window sensors use Timer #2.
|
|
|
|
24h windows are calculated relative to current interval,
|
|
which changes every 15 minutes.
|
|
"""
|
|
window_24h_sensors = [
|
|
"trailing_price_average",
|
|
"leading_price_average",
|
|
"trailing_price_min",
|
|
"trailing_price_max",
|
|
"leading_price_min",
|
|
"leading_price_max",
|
|
]
|
|
|
|
for sensor_key in window_24h_sensors:
|
|
assert sensor_key in TIME_SENSITIVE_ENTITY_KEYS, (
|
|
f"24h window sensor '{sensor_key}' should be TIME_SENSITIVE (Timer #2)"
|
|
)
|
|
|
|
|
|
def test_period_binary_sensors_use_quarter_hour_timer() -> None:
|
|
"""
|
|
Test that best/peak price period binary sensors use Timer #2.
|
|
|
|
Binary sensors check if current time is within a period.
|
|
Periods can only change at quarter-hour interval boundaries.
|
|
"""
|
|
period_binary_sensors = [
|
|
"best_price_period",
|
|
"peak_price_period",
|
|
]
|
|
|
|
for sensor_key in period_binary_sensors:
|
|
assert sensor_key in TIME_SENSITIVE_ENTITY_KEYS, (
|
|
f"Period binary sensor '{sensor_key}' should be TIME_SENSITIVE (Timer #2)"
|
|
)
|
|
|
|
|
|
def test_period_timestamp_sensors_use_quarter_hour_timer() -> None:
|
|
"""
|
|
Test that period timestamp sensors (end_time, next_start_time) use Timer #2.
|
|
|
|
Timestamp sensors report when periods end/start. Since periods can only
|
|
change at quarter-hour boundaries (intervals), they only need quarter-hour updates.
|
|
"""
|
|
timestamp_sensors = [
|
|
"best_price_end_time",
|
|
"best_price_next_start_time",
|
|
"peak_price_end_time",
|
|
"peak_price_next_start_time",
|
|
]
|
|
|
|
for sensor_key in timestamp_sensors:
|
|
assert sensor_key in TIME_SENSITIVE_ENTITY_KEYS, (
|
|
f"Timestamp sensor '{sensor_key}' should be TIME_SENSITIVE (Timer #2)"
|
|
)
|
|
|
|
|
|
def test_timing_sensors_use_minute_timer() -> None:
|
|
"""
|
|
Test that countdown/progress timing sensors use Timer #3.
|
|
|
|
These sensors track time remaining and progress percentage within periods.
|
|
They need minute-by-minute updates for accurate countdown displays.
|
|
|
|
IMPORTANT: Timestamp sensors (end_time, next_start_time) do NOT use Timer #3
|
|
because periods can only change at quarter-hour boundaries.
|
|
"""
|
|
timing_sensors = [
|
|
"best_price_remaining_minutes",
|
|
"best_price_progress",
|
|
"best_price_next_in_minutes", # Corrected from best_price_next_start_minutes
|
|
"peak_price_remaining_minutes",
|
|
"peak_price_progress",
|
|
"peak_price_next_in_minutes", # Corrected from peak_price_next_start_minutes
|
|
]
|
|
|
|
for sensor_key in timing_sensors:
|
|
assert sensor_key in MINUTE_UPDATE_ENTITY_KEYS, (
|
|
f"Timing sensor '{sensor_key}' should be MINUTE_UPDATE (Timer #3)"
|
|
)
|
|
|
|
# Also verify it's NOT in TIME_SENSITIVE (no double updates)
|
|
assert sensor_key not in TIME_SENSITIVE_ENTITY_KEYS, (
|
|
f"Timing sensor '{sensor_key}' should NOT be in TIME_SENSITIVE\n"
|
|
"Minute updates are sufficient for countdown/progress tracking."
|
|
)
|
|
|
|
|
|
def test_lifecycle_sensor_uses_quarter_hour_timer_with_state_filter() -> None:
|
|
"""
|
|
Test that data lifecycle status sensor uses Timer #2 WITH state-change filtering.
|
|
|
|
The lifecycle sensor needs quarter-hour precision for detecting:
|
|
- 23:45: turnover_pending (last interval before midnight)
|
|
- 00:00: turnover complete (after midnight API update)
|
|
- 13:00: searching_tomorrow (when tomorrow data search begins)
|
|
|
|
To prevent recorder spam, it uses state-change filtering in both:
|
|
- _handle_coordinator_update() (Timer #1)
|
|
- _handle_time_sensitive_update() (Timer #2)
|
|
|
|
State is only written to recorder if it actually changed.
|
|
This reduces recorder entries from ~96/day to ~10-15/day.
|
|
"""
|
|
# Lifecycle sensor MUST be in TIME_SENSITIVE_ENTITY_KEYS for quarter-hour precision
|
|
assert "data_lifecycle_status" in TIME_SENSITIVE_ENTITY_KEYS, (
|
|
"Lifecycle sensor needs quarter-hour updates for precise state transitions\n"
|
|
"at 23:45 (turnover_pending), 00:00 (turnover complete), 13:00 (searching_tomorrow).\n"
|
|
"State-change filter in _handle_time_sensitive_update() prevents recorder spam."
|
|
)
|
|
|
|
|
|
def test_daily_stat_sensors_not_in_timers() -> None:
|
|
"""
|
|
Test that daily statistic sensors (min/max/avg) do NOT use timers.
|
|
|
|
Daily stats don't depend on current time - they represent full-day aggregates.
|
|
They only need updates when new API data arrives (not time-dependent).
|
|
"""
|
|
daily_stat_sensors = [
|
|
# Today/tomorrow min prices
|
|
"daily_min_price_today",
|
|
"daily_min_price_tomorrow",
|
|
# Today/tomorrow max prices
|
|
"daily_max_price_today",
|
|
"daily_max_price_tomorrow",
|
|
# Today/tomorrow averages
|
|
"daily_average_price_today",
|
|
"daily_average_price_tomorrow",
|
|
# Daily price levels
|
|
"daily_price_level_today",
|
|
"daily_price_level_tomorrow",
|
|
# Daily price ratings
|
|
"daily_price_rating_today",
|
|
"daily_price_rating_tomorrow",
|
|
]
|
|
|
|
for sensor_key in daily_stat_sensors:
|
|
assert sensor_key not in TIME_SENSITIVE_ENTITY_KEYS, (
|
|
f"Daily stat sensor '{sensor_key}' should NOT use Timer #2\n"
|
|
"Daily statistics don't depend on current time - only on API data arrival."
|
|
)
|
|
assert sensor_key not in MINUTE_UPDATE_ENTITY_KEYS, (
|
|
f"Daily stat sensor '{sensor_key}' should NOT use Timer #3\n"
|
|
"Daily statistics don't need minute-by-minute updates."
|
|
)
|
|
|
|
|
|
def test_volatility_sensors_not_in_timers() -> None:
|
|
"""
|
|
Test that volatility sensors do NOT use timers.
|
|
|
|
Volatility analyzes price variation over fixed time windows.
|
|
Values only change when new API data arrives (not time-dependent).
|
|
"""
|
|
volatility_sensors = [
|
|
"today_volatility_level",
|
|
"tomorrow_volatility_level",
|
|
"yesterday_volatility_level",
|
|
"next_24h_volatility_level",
|
|
]
|
|
|
|
for sensor_key in volatility_sensors:
|
|
assert sensor_key not in TIME_SENSITIVE_ENTITY_KEYS, (
|
|
f"Volatility sensor '{sensor_key}' should NOT use Timer #2\n"
|
|
"Volatility calculates over fixed time windows - not time-dependent."
|
|
)
|
|
assert sensor_key not in MINUTE_UPDATE_ENTITY_KEYS, (
|
|
f"Volatility sensor '{sensor_key}' should NOT use Timer #3\n"
|
|
"Volatility doesn't need minute-by-minute updates."
|
|
)
|
|
|
|
|
|
def test_diagnostic_sensors_not_in_timers() -> None:
|
|
"""
|
|
Test that diagnostic/metadata sensors do NOT use timers.
|
|
|
|
Diagnostic sensors report static metadata or system state.
|
|
They only update when configuration changes or new API data arrives.
|
|
"""
|
|
diagnostic_sensors = [
|
|
"data_last_updated",
|
|
"home_id",
|
|
"currency_code",
|
|
"price_unit",
|
|
"grid_company",
|
|
"price_level",
|
|
"address_line1",
|
|
"address_line2",
|
|
"address_line3",
|
|
"zip_code",
|
|
"city",
|
|
"country",
|
|
"latitude",
|
|
"longitude",
|
|
"time_zone",
|
|
"estimated_annual_consumption",
|
|
"subscription_status",
|
|
"chart_data_export",
|
|
]
|
|
|
|
for sensor_key in diagnostic_sensors:
|
|
# Skip data_lifecycle_status - it needs quarter-hour updates
|
|
if sensor_key == "data_lifecycle_status":
|
|
continue
|
|
|
|
assert sensor_key not in TIME_SENSITIVE_ENTITY_KEYS, (
|
|
f"Diagnostic sensor '{sensor_key}' should NOT use Timer #2\nDiagnostic data doesn't depend on current time."
|
|
)
|
|
assert sensor_key not in MINUTE_UPDATE_ENTITY_KEYS, (
|
|
f"Diagnostic sensor '{sensor_key}' should NOT use Timer #3\n"
|
|
"Diagnostic data doesn't need minute-by-minute updates."
|
|
)
|
|
|
|
|
|
def test_timer_constants_are_comprehensive() -> None:
|
|
"""
|
|
Test that timer constants account for all time-dependent sensors.
|
|
|
|
Verifies no time-dependent sensors are missing from timer groups.
|
|
This is a safety check to catch sensors that need timers but don't have them.
|
|
"""
|
|
all_sensor_keys = {desc.key for desc in ENTITY_DESCRIPTIONS}
|
|
all_binary_sensor_keys = {desc.key for desc in BINARY_SENSOR_ENTITY_DESCRIPTIONS}
|
|
all_entity_keys = all_sensor_keys | all_binary_sensor_keys
|
|
sensors_with_timers = TIME_SENSITIVE_ENTITY_KEYS | MINUTE_UPDATE_ENTITY_KEYS
|
|
|
|
# Expected time-dependent sensor patterns
|
|
time_dependent_patterns = [
|
|
"current_",
|
|
"next_",
|
|
"previous_",
|
|
"trailing_",
|
|
"leading_",
|
|
"_remaining_",
|
|
"_progress",
|
|
"_next_in_", # Corrected from _next_start_
|
|
"_end_time",
|
|
"_period", # Binary sensors checking if NOW is in period
|
|
"price_trend_",
|
|
"next_avg_",
|
|
]
|
|
|
|
# Known exceptions that look time-dependent but aren't
|
|
known_exceptions = {
|
|
"data_last_updated", # Timestamp of last update, not time-dependent
|
|
"next_24h_volatility", # Uses fixed 24h window from current time, updated on API data
|
|
"current_interval_price_base", # Duplicate of current_interval_price (just different unit)
|
|
"best_price_period_duration", # Duration state in hours (static per period), no minute-by-minute timer
|
|
"peak_price_period_duration", # Duration state in hours (static per period), no minute-by-minute timer
|
|
}
|
|
|
|
potentially_missing = [
|
|
sensor_key
|
|
for sensor_key in all_entity_keys
|
|
if (
|
|
any(pattern in sensor_key for pattern in time_dependent_patterns)
|
|
and sensor_key not in sensors_with_timers
|
|
and sensor_key not in known_exceptions
|
|
)
|
|
]
|
|
|
|
assert not potentially_missing, (
|
|
f"These sensors appear time-dependent but aren't in any timer group:\n"
|
|
f"{potentially_missing}\n\n"
|
|
"If they truly need time-based updates, add them to TIME_SENSITIVE_ENTITY_KEYS\n"
|
|
"or MINUTE_UPDATE_ENTITY_KEYS in coordinator/constants.py"
|
|
)
|
|
|
|
|
|
def test_timer_group_sizes() -> None:
|
|
"""
|
|
Test timer group sizes as documentation/regression detection.
|
|
|
|
This isn't a strict requirement, but significant changes in group sizes
|
|
might indicate accidental additions/removals.
|
|
"""
|
|
# As of Nov 2025
|
|
expected_time_sensitive_min = 40 # At least 40 sensors
|
|
expected_minute_update = 6 # Exactly 6 timing sensors
|
|
|
|
assert len(TIME_SENSITIVE_ENTITY_KEYS) >= expected_time_sensitive_min, (
|
|
f"Expected at least {expected_time_sensitive_min} TIME_SENSITIVE sensors, got {len(TIME_SENSITIVE_ENTITY_KEYS)}"
|
|
)
|
|
|
|
assert len(MINUTE_UPDATE_ENTITY_KEYS) == expected_minute_update, (
|
|
f"Expected exactly {expected_minute_update} MINUTE_UPDATE sensors, got {len(MINUTE_UPDATE_ENTITY_KEYS)}"
|
|
)
|