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).
95 lines
3.3 KiB
Python
95 lines
3.3 KiB
Python
"""Period timing attribute builders for Tibber Prices sensors."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from custom_components.tibber_prices.entity_utils import add_icon_color_attribute
|
|
|
|
if TYPE_CHECKING:
|
|
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
|
|
|
|
# Timer #3 triggers every 30 seconds
|
|
TIMER_30_SEC_BOUNDARY = 30
|
|
|
|
|
|
def _hours_to_minutes(state_value: Any) -> int | None:
|
|
"""Convert hour-based state back to rounded minutes for attributes."""
|
|
if state_value is None:
|
|
return None
|
|
|
|
try:
|
|
return round(float(state_value) * 60)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _is_timing_or_volatility_sensor(key: str) -> bool:
|
|
"""Check if sensor is a timing or volatility sensor."""
|
|
return key.endswith("_volatility") or (
|
|
key.startswith(("best_price_", "peak_price_"))
|
|
and any(
|
|
suffix in key
|
|
for suffix in [
|
|
"end_time",
|
|
"remaining_minutes",
|
|
"progress",
|
|
"next_start_time",
|
|
"next_in_minutes",
|
|
]
|
|
)
|
|
)
|
|
|
|
|
|
def add_period_timing_attributes(
|
|
attributes: dict,
|
|
key: str,
|
|
state_value: Any = None,
|
|
*,
|
|
time: TibberPricesTimeService,
|
|
) -> None:
|
|
"""
|
|
Add timestamp and icon_color attributes for best_price/peak_price timing sensors.
|
|
|
|
The timestamp indicates when the sensor value was calculated:
|
|
- Quarter-hour sensors (end_time, next_start_time): Rounded to 15-min boundary (:00, :15, :30, :45)
|
|
- 30-second update sensors (remaining_minutes, progress, next_in_minutes): Current time with seconds
|
|
|
|
Args:
|
|
attributes: Dictionary to add attributes to
|
|
key: The sensor entity key (e.g., "best_price_end_time")
|
|
state_value: Current sensor value for icon_color calculation
|
|
time: TibberPricesTimeService instance (required)
|
|
|
|
"""
|
|
# Determine if this is a quarter-hour or 30-second update sensor
|
|
is_quarter_hour_sensor = key.endswith(("_end_time", "_next_start_time"))
|
|
|
|
now = time.now()
|
|
|
|
if is_quarter_hour_sensor:
|
|
# Quarter-hour sensors: Use timestamp of current 15-minute interval
|
|
# Round down to the nearest quarter hour (:00, :15, :30, :45)
|
|
minute = (now.minute // 15) * 15
|
|
timestamp = now.replace(minute=minute, second=0, microsecond=0)
|
|
else:
|
|
# 30-second update sensors: Round to nearest 30-second boundary (:00 or :30)
|
|
# Timer triggers at :00 and :30, so round current time to these boundaries
|
|
second = 0 if now.second < TIMER_30_SEC_BOUNDARY else TIMER_30_SEC_BOUNDARY
|
|
timestamp = now.replace(second=second, microsecond=0)
|
|
|
|
attributes["timestamp"] = timestamp
|
|
|
|
# Add minute-precision attributes for hour-based states to keep automation-friendly values
|
|
minute_value = _hours_to_minutes(state_value)
|
|
|
|
if minute_value is not None:
|
|
if key.endswith("period_duration"):
|
|
attributes["period_duration_minutes"] = minute_value
|
|
elif key.endswith("remaining_minutes"):
|
|
attributes["remaining_minutes"] = minute_value
|
|
elif key.endswith("next_in_minutes"):
|
|
attributes["next_in_minutes"] = minute_value
|
|
|
|
# Add icon_color for dynamic styling
|
|
add_icon_color_attribute(attributes, key=key, state_value=state_value)
|