hass.tibber_prices/custom_components/tibber_prices/sensor/attributes/timing.py
Julian Pawlowski 0a4af0de2f feat(sensor): convert timing sensors to hour-based display with minute attributes
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).
2025-12-26 16:03:00 +00:00

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)