feat(sensors): add timing sensors for best_price and peak_price periods

Added 10 new timing sensors (5 for best_price, 5 for peak_price) to track
period timing and progress:

Timestamp sensors (quarter-hour updates):
- best_price_end_time / peak_price_end_time
  Shows when current/next period ends (always useful reference time)
- best_price_next_start_time / peak_price_next_start_time
  Shows when next period starts (even during active periods)

Countdown sensors (minute updates):
- best_price_remaining_minutes / peak_price_remaining_minutes
  Minutes left in current period (0 when inactive)
- best_price_next_in_minutes / peak_price_next_in_minutes
  Minutes until next period starts
- best_price_progress / peak_price_progress
  Progress percentage through current period (0-100%)

Smart fallback behavior:
- Sensors always show useful values (no 'Unknown' during normal operation)
- Timestamp sensors show current OR next period end/start times
- Countdown sensors return 0 when no period is active
- Grace period: Progress stays at 100% for 60 seconds after period ends

Dynamic visual feedback:
- Progress icons differentiate 3 states at 0%:
  * No data: mdi:help-circle-outline (gray)
  * Waiting for next period: mdi:timer-pause-outline
  * Period just started: mdi:circle-outline
- Progress 1-99%: mdi:circle-slice-1 to mdi:circle-slice-8 (pie chart)
- Timer icons based on urgency (alert/timer/timer-sand/timer-outline)
- Dynamic colors: green (best_price), orange/red (peak_price), gray (disabled)
- icon_color attribute for UI styling

Implementation details:
- Dual update mechanism: quarter-hour (timestamps) + minute (countdowns)
- Period state callbacks: Check if period is currently active
- IconContext dataclass: Reduced function parameters from 6 to 3
- Unit constants: UnitOfTime.MINUTES, PERCENTAGE from homeassistant.const
- Complete translations for 5 languages (de, en, nb, nl, sv)

Impact: Users can now build sophisticated automations based on period timing
('start dishwasher if remaining_minutes > 60'), display countdowns in
dashboards, and get clear visual feedback about period states. All sensors
provide meaningful values at all times, making automation logic simpler.
This commit is contained in:
Julian Pawlowski 2025-11-15 17:11:28 +00:00
parent 22165d038d
commit decca432df
16 changed files with 1107 additions and 25 deletions

View file

@ -153,6 +153,28 @@ TIME_SENSITIVE_ENTITY_KEYS = frozenset(
# Binary sensors that check if current time is in a period
"peak_price_period",
"best_price_period",
# Best/Peak price timestamp sensors (periods only change at interval boundaries)
"best_price_end_time",
"best_price_next_start_time",
"peak_price_end_time",
"peak_price_next_start_time",
}
)
# Entities that require minute-by-minute updates (separate from quarter-hour updates)
# These are timing sensors that track countdown/progress within best/peak price periods
# Timestamp sensors (end_time, next_start_time) only need quarter-hour updates since periods
# can only change at interval boundaries
MINUTE_UPDATE_ENTITY_KEYS = frozenset(
{
# Best Price countdown/progress sensors (need minute updates)
"best_price_remaining_minutes",
"best_price_progress",
"best_price_next_in_minutes",
# Peak Price countdown/progress sensors (need minute updates)
"peak_price_remaining_minutes",
"peak_price_progress",
"peak_price_next_in_minutes",
}
)
@ -206,11 +228,19 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Quarter-hour entity refresh timer (runs at :00, :15, :30, :45)
self._quarter_hour_timer_cancel: CALLBACK_TYPE | None = None
# Minute-by-minute entity refresh timer (runs every minute for timing sensors)
self._minute_timer_cancel: CALLBACK_TYPE | None = None
# Selective listener system for time-sensitive entities
# Regular listeners update on API data changes, time-sensitive listeners update every 15 minutes
self._time_sensitive_listeners: list[CALLBACK_TYPE] = []
# Minute-update listener system for timing sensors
# These listeners update every minute to track progress/remaining time in periods
self._minute_update_listeners: list[CALLBACK_TYPE] = []
self._schedule_quarter_hour_refresh()
self._schedule_minute_refresh()
def _log(self, level: str, message: str, *args: Any, **kwargs: Any) -> None:
"""Log with coordinator-specific prefix."""
@ -250,6 +280,39 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
len(self._time_sensitive_listeners),
)
@callback
def async_add_minute_update_listener(self, update_callback: CALLBACK_TYPE) -> CALLBACK_TYPE:
"""
Listen for minute-by-minute updates for timing sensors.
Timing sensors (like best_price_remaining_minutes, peak_price_progress, etc.) should use this
method to receive updates every minute for accurate countdown/progress tracking.
Returns:
Callback that can be used to remove the listener
"""
self._minute_update_listeners.append(update_callback)
def remove_listener() -> None:
"""Remove update listener."""
if update_callback in self._minute_update_listeners:
self._minute_update_listeners.remove(update_callback)
return remove_listener
@callback
def _async_update_minute_listeners(self) -> None:
"""Update all minute-update entities without triggering a full coordinator update."""
for update_callback in self._minute_update_listeners:
update_callback()
self._log(
"debug",
"Updated %d minute-update entities",
len(self._minute_update_listeners),
)
def _schedule_quarter_hour_refresh(self) -> None:
"""Schedule the next quarter-hour entity refresh using Home Assistant's time tracking."""
# Cancel any existing timer
@ -293,6 +356,35 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Static entities (statistics, diagnostics) only update when new API data arrives
self._async_update_time_sensitive_listeners()
def _schedule_minute_refresh(self) -> None:
"""Schedule minute-by-minute entity refresh for timing sensors."""
# Cancel any existing timer
if self._minute_timer_cancel:
self._minute_timer_cancel()
self._minute_timer_cancel = None
# Use Home Assistant's async_track_utc_time_change to trigger every minute at second=1
# This ensures timing sensors (remaining_minutes, progress) update accurately
self._minute_timer_cancel = async_track_utc_time_change(
self.hass,
self._handle_minute_refresh,
second=1,
)
self._log(
"debug",
"Scheduled minute-by-minute refresh for timing sensors (every minute at second=1)",
)
@callback
def _handle_minute_refresh(self, _now: datetime | None = None) -> None:
"""Handle minute-by-minute entity refresh for timing sensors."""
# Only log at debug level to avoid log spam (this runs every minute)
self._log("debug", "Minute refresh triggered for timing sensors")
# Update only minute-update entities (remaining_minutes, progress, etc.)
self._async_update_minute_listeners()
@callback
def _check_and_handle_midnight_turnover(self, now: datetime) -> bool:
"""
@ -357,6 +449,10 @@ class TibberPricesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._quarter_hour_timer_cancel()
self._quarter_hour_timer_cancel = None
if self._minute_timer_cancel:
self._minute_timer_cancel()
self._minute_timer_cancel = None
def _has_existing_main_coordinator(self) -> bool:
"""Check if there's already a main coordinator in hass.data."""
domain_data = self.hass.data.get(DOMAIN, {})

View file

@ -283,6 +283,56 @@
"description": "Prognose kommender Strompreise",
"long_description": "Zeigt kommende Strompreise für zukünftige Intervalle in einem Format, das einfach in Dashboards verwendet werden kann",
"usage_tips": "Verwenden Sie die Attribute dieser Entität, um kommende Preise in Diagrammen oder benutzerdefinierten Karten anzuzeigen. Greifen Sie entweder auf 'intervals' für alle zukünftigen Intervalle oder auf 'hours' für stündliche Zusammenfassungen zu."
},
"best_price_end_time": {
"description": "Wann die aktuelle oder nächste günstige Periode endet",
"long_description": "Zeigt den Endzeitstempel der aktuellen günstigen Periode wenn aktiv, oder das Ende der nächsten Periode wenn keine Periode aktiv ist. Zeigt immer eine nützliche Zeitreferenz zur Planung. Gibt nur 'Unbekannt' zurück, wenn keine Perioden konfiguriert sind.",
"usage_tips": "Nutze dies, um einen Countdown wie 'Günstige Periode endet in 2 Stunden' (wenn aktiv) oder 'Nächste günstige Periode endet um 14:00' (wenn inaktiv) anzuzeigen. Home Assistant zeigt automatisch relative Zeit für Zeitstempel-Sensoren an."
},
"best_price_remaining_minutes": {
"description": "Verbleibende Minuten in aktueller günstiger Periode (0 wenn inaktiv)",
"long_description": "Zeigt, wie viele Minuten in der aktuellen günstigen Periode noch verbleiben. Gibt 0 zurück, wenn keine Periode aktiv ist. Aktualisiert sich jede Minute. Prüfe binary_sensor.best_price_period um zu sehen, ob eine Periode aktuell aktiv ist.",
"usage_tips": "Perfekt für Automatisierungen: 'Wenn remaining_minutes > 0 UND remaining_minutes < 30, starte Waschmaschine jetzt'. Der Wert 0 macht es einfach zu prüfen, ob eine Periode aktiv ist (Wert > 0) oder nicht (Wert = 0)."
},
"best_price_progress": {
"description": "Fortschritt durch aktuelle günstige Periode (0% wenn inaktiv)",
"long_description": "Zeigt den Fortschritt durch die aktuelle günstige Periode als 0-100%. Gibt 0% zurück, wenn keine Periode aktiv ist. Aktualisiert sich jede Minute. 0% bedeutet Periode gerade gestartet, 100% bedeutet sie endet gleich.",
"usage_tips": "Super für visuelle Fortschrittsbalken. Nutze in Automatisierungen: 'Wenn progress > 0 UND progress > 75, sende Benachrichtigung, dass günstige Periode bald endet'. Wert 0 zeigt keine aktive Periode an."
},
"best_price_next_start_time": {
"description": "Wann die nächste günstige Periode startet",
"long_description": "Zeigt, wann die nächste kommende günstige Periode startet. Während einer aktiven Periode zeigt dies den Start der NÄCHSTEN Periode nach der aktuellen. Gibt nur 'Unbekannt' zurück, wenn keine zukünftigen Perioden konfiguriert sind.",
"usage_tips": "Immer nützlich für Vorausplanung: 'Nächste günstige Periode startet in 3 Stunden' (egal ob du gerade in einer Periode bist oder nicht). Kombiniere mit Automatisierungen: 'Wenn nächste Startzeit in 10 Minuten ist, sende Benachrichtigung zur Vorbereitung der Waschmaschine'."
},
"best_price_next_in_minutes": {
"description": "Minuten bis nächste günstige Periode startet (0 beim Übergang)",
"long_description": "Zeigt Minuten bis die nächste günstige Periode startet. Während einer aktiven Periode zeigt dies die Zeit bis zur Periode NACH der aktuellen. Gibt 0 während kurzer Übergangsphasen zurück. Aktualisiert sich jede Minute.",
"usage_tips": "Perfekt für 'warte bis günstige Periode' Automatisierungen: 'Wenn next_in_minutes > 0 UND next_in_minutes < 15, warte bevor Geschirrspüler gestartet wird'. Wert > 0 zeigt immer an, dass eine zukünftige Periode geplant ist."
},
"peak_price_end_time": {
"description": "Wann die aktuelle oder nächste teure Periode endet",
"long_description": "Zeigt den Endzeitstempel der aktuellen teuren Periode wenn aktiv, oder das Ende der nächsten Periode wenn keine Periode aktiv ist. Zeigt immer eine nützliche Zeitreferenz zur Planung. Gibt nur 'Unbekannt' zurück, wenn keine Perioden konfiguriert sind.",
"usage_tips": "Nutze dies, um 'Teure Periode endet in 1 Stunde' (wenn aktiv) oder 'Nächste teure Periode endet um 18:00' (wenn inaktiv) anzuzeigen. Kombiniere mit Automatisierungen, um Betrieb nach Spitze fortzusetzen."
},
"peak_price_remaining_minutes": {
"description": "Verbleibende Minuten in aktueller teurer Periode (0 wenn inaktiv)",
"long_description": "Zeigt, wie viele Minuten in der aktuellen teuren Periode noch verbleiben. Gibt 0 zurück, wenn keine Periode aktiv ist. Aktualisiert sich jede Minute. Prüfe binary_sensor.peak_price_period um zu sehen, ob eine Periode aktuell aktiv ist.",
"usage_tips": "Nutze in Automatisierungen: 'Wenn remaining_minutes > 60, breche aufgeschobene Ladesitzung ab'. Wert 0 macht es einfach zu unterscheiden zwischen aktiven (Wert > 0) und inaktiven (Wert = 0) Perioden."
},
"peak_price_progress": {
"description": "Fortschritt durch aktuelle teure Periode (0% wenn inaktiv)",
"long_description": "Zeigt den Fortschritt durch die aktuelle teure Periode als 0-100%. Gibt 0% zurück, wenn keine Periode aktiv ist. Aktualisiert sich jede Minute.",
"usage_tips": "Visueller Fortschrittsindikator in Dashboards. Automatisierung: 'Wenn progress > 0 UND progress > 90, bereite normale Heizplanung vor'. Wert 0 zeigt keine aktive Periode an."
},
"peak_price_next_start_time": {
"description": "Wann die nächste teure Periode startet",
"long_description": "Zeigt, wann die nächste kommende teure Periode startet. Während einer aktiven Periode zeigt dies den Start der NÄCHSTEN Periode nach der aktuellen. Gibt nur 'Unbekannt' zurück, wenn keine zukünftigen Perioden konfiguriert sind.",
"usage_tips": "Immer nützlich für Planung: 'Nächste teure Periode startet in 2 Stunden'. Automatisierung: 'Wenn nächste Startzeit in 30 Minuten ist, reduziere Heiztemperatur vorsorglich'."
},
"peak_price_next_in_minutes": {
"description": "Minuten bis nächste teure Periode startet (0 beim Übergang)",
"long_description": "Zeigt Minuten bis die nächste teure Periode startet. Während einer aktiven Periode zeigt dies die Zeit bis zur Periode NACH der aktuellen. Gibt 0 während kurzer Übergangsphasen zurück. Aktualisiert sich jede Minute.",
"usage_tips": "Präventive Automatisierung: 'Wenn next_in_minutes > 0 UND next_in_minutes < 10, beende aktuellen Ladezyklus jetzt bevor Preise steigen'."
}
},
"binary_sensor": {

View file

@ -283,6 +283,56 @@
"description": "Forecast of upcoming electricity prices",
"long_description": "Shows upcoming electricity prices for future intervals in a format that's easy to use in dashboards",
"usage_tips": "Use this entity's attributes to display upcoming prices in charts or custom cards. Access either 'intervals' for all future intervals or 'hours' for hourly summaries."
},
"best_price_end_time": {
"description": "When the current or next best price period ends",
"long_description": "Shows the end timestamp of the current best price period when active, or the end of the next period when no period is active. Always shows a useful time reference for planning. Returns 'Unknown' only when no periods are configured.",
"usage_tips": "Use this to display a countdown like 'Cheap period ends in 2 hours' (when active) or 'Next cheap period ends at 14:00' (when inactive). Home Assistant automatically shows relative time for timestamp sensors."
},
"best_price_remaining_minutes": {
"description": "Minutes remaining in current best price period (0 when inactive)",
"long_description": "Shows how many minutes are left in the current best price period. Returns 0 when no period is active. Updates every minute. Check binary_sensor.best_price_period to see if a period is currently active.",
"usage_tips": "Perfect for automations: 'If remaining_minutes > 0 AND remaining_minutes < 30, start washing machine now'. The value 0 makes it easy to check if a period is active (value > 0) or not (value = 0)."
},
"best_price_progress": {
"description": "Progress through current best price period (0% when inactive)",
"long_description": "Shows progress through the current best price period as 0-100%. Returns 0% when no period is active. Updates every minute. 0% means period just started, 100% means it's about to end.",
"usage_tips": "Great for visual progress bars. Use in automations: 'If progress > 0 AND progress > 75, send notification that cheap period is ending soon'. Value 0 indicates no active period."
},
"best_price_next_start_time": {
"description": "When the next best price period starts",
"long_description": "Shows when the next upcoming best price period starts. During an active period, this shows the start of the NEXT period after the current one. Returns 'Unknown' only when no future periods are configured.",
"usage_tips": "Always useful for planning ahead: 'Next cheap period starts in 3 hours' (whether you're in a period now or not). Combine with automations: 'When next start time is in 10 minutes, send notification to prepare washing machine'."
},
"best_price_next_in_minutes": {
"description": "Minutes until next best price period starts (0 when in transition)",
"long_description": "Shows minutes until the next best price period starts. During an active period, shows time until the period AFTER the current one. Returns 0 during brief transition moments. Updates every minute.",
"usage_tips": "Perfect for 'wait until cheap period' automations: 'If next_in_minutes > 0 AND next_in_minutes < 15, wait before starting dishwasher'. Value > 0 always indicates a future period is scheduled."
},
"peak_price_end_time": {
"description": "When the current or next peak price period ends",
"long_description": "Shows the end timestamp of the current peak price period when active, or the end of the next period when no period is active. Always shows a useful time reference for planning. Returns 'Unknown' only when no periods are configured.",
"usage_tips": "Use this to display 'Expensive period ends in 1 hour' (when active) or 'Next expensive period ends at 18:00' (when inactive). Combine with automations to resume operations after peak."
},
"peak_price_remaining_minutes": {
"description": "Minutes remaining in current peak price period (0 when inactive)",
"long_description": "Shows how many minutes are left in the current peak price period. Returns 0 when no period is active. Updates every minute. Check binary_sensor.peak_price_period to see if a period is currently active.",
"usage_tips": "Use in automations: 'If remaining_minutes > 60, cancel deferred charging session'. Value 0 makes it easy to distinguish active (value > 0) from inactive (value = 0) periods."
},
"peak_price_progress": {
"description": "Progress through current peak price period (0% when inactive)",
"long_description": "Shows progress through the current peak price period as 0-100%. Returns 0% when no period is active. Updates every minute.",
"usage_tips": "Visual progress indicator in dashboards. Automation: 'If progress > 0 AND progress > 90, prepare to resume normal heating schedule'. Value 0 indicates no active period."
},
"peak_price_next_start_time": {
"description": "When the next peak price period starts",
"long_description": "Shows when the next upcoming peak price period starts. During an active period, this shows the start of the NEXT period after the current one. Returns 'Unknown' only when no future periods are configured.",
"usage_tips": "Always useful for planning: 'Next expensive period starts in 2 hours'. Automation: 'When next start time is in 30 minutes, reduce heating temperature preemptively'."
},
"peak_price_next_in_minutes": {
"description": "Minutes until next peak price period starts (0 when in transition)",
"long_description": "Shows minutes until the next peak price period starts. During an active period, shows time until the period AFTER the current one. Returns 0 during brief transition moments. Updates every minute.",
"usage_tips": "Pre-emptive automation: 'If next_in_minutes > 0 AND next_in_minutes < 10, complete current charging cycle now before prices increase'."
}
},
"binary_sensor": {

View file

@ -283,6 +283,56 @@
"description": "Prognose for kommende elektrisitetspriser",
"long_description": "Viser kommende elektrisitetspriser for fremtidige intervaller i et format som er enkelt å bruke i dashboards",
"usage_tips": "Bruk denne entitetens attributter til å vise kommende priser i diagrammer eller tilpassede kort. Få tilgang til enten 'intervals' for alle fremtidige intervaller eller 'hours' for timesammendrag."
},
"best_price_end_time": {
"description": "Når gjeldende eller neste billigperiode slutter",
"long_description": "Viser sluttidspunktet for gjeldende billigperiode når aktiv, eller slutten av neste periode når ingen periode er aktiv. Viser alltid en nyttig tidsreferanse for planlegging. Returnerer 'Ukjent' bare når ingen perioder er konfigurert.",
"usage_tips": "Bruk dette til å vise en nedtelling som 'Billigperiode slutter om 2 timer' (når aktiv) eller 'Neste billigperiode slutter kl 14:00' (når inaktiv). Home Assistant viser automatisk relativ tid for tidsstempelsensorer."
},
"best_price_remaining_minutes": {
"description": "Gjenværende minutter i gjeldende billigperiode (0 når inaktiv)",
"long_description": "Viser hvor mange minutter som er igjen i gjeldende billigperiode. Returnerer 0 når ingen periode er aktiv. Oppdateres hvert minutt. Sjekk binary_sensor.best_price_period for å se om en periode er aktiv.",
"usage_tips": "Perfekt for automatiseringer: 'Hvis remaining_minutes > 0 OG remaining_minutes < 30, start vaskemaskin nå'. Verdien 0 gjør det enkelt å sjekke om en periode er aktiv (verdi > 0) eller ikke (verdi = 0)."
},
"best_price_progress": {
"description": "Fremdrift gjennom gjeldende billigperiode (0% når inaktiv)",
"long_description": "Viser fremdrift gjennom gjeldende billigperiode som 0-100%. Returnerer 0% når ingen periode er aktiv. Oppdateres hvert minutt. 0% betyr periode nettopp startet, 100% betyr den snart slutter.",
"usage_tips": "Flott for visuelle fremdriftslinjer. Bruk i automatiseringer: 'Hvis progress > 0 OG progress > 75, send varsel om at billigperiode snart slutter'. Verdi 0 indikerer ingen aktiv periode."
},
"best_price_next_start_time": {
"description": "Når neste billigperiode starter",
"long_description": "Viser når neste kommende billigperiode starter. Under en aktiv periode viser dette starten av NESTE periode etter den gjeldende. Returnerer 'Ukjent' bare når ingen fremtidige perioder er konfigurert.",
"usage_tips": "Alltid nyttig for planlegging: 'Neste billigperiode starter om 3 timer' (enten du er i en periode nå eller ikke). Kombiner med automatiseringer: 'Når neste starttid er om 10 minutter, send varsel for å forberede vaskemaskin'."
},
"best_price_next_in_minutes": {
"description": "Minutter til neste billigperiode starter (0 ved overgang)",
"long_description": "Viser minutter til neste billigperiode starter. Under en aktiv periode viser dette tiden til perioden ETTER den gjeldende. Returnerer 0 under korte overgangsmomenter. Oppdateres hvert minutt.",
"usage_tips": "Perfekt for 'vent til billigperiode' automatiseringer: 'Hvis next_in_minutes > 0 OG next_in_minutes < 15, vent før oppvaskmaskin startes'. Verdi > 0 indikerer alltid at en fremtidig periode er planlagt."
},
"peak_price_end_time": {
"description": "Når gjeldende eller neste dyrperiode slutter",
"long_description": "Viser sluttidspunktet for gjeldende dyrperiode når aktiv, eller slutten av neste periode når ingen periode er aktiv. Viser alltid en nyttig tidsreferanse for planlegging. Returnerer 'Ukjent' bare når ingen perioder er konfigurert.",
"usage_tips": "Bruk dette til å vise 'Dyrperiode slutter om 1 time' (når aktiv) eller 'Neste dyrperiode slutter kl 18:00' (når inaktiv). Kombiner med automatiseringer for å gjenoppta drift etter topp."
},
"peak_price_remaining_minutes": {
"description": "Gjenværende minutter i gjeldende dyrperiode (0 når inaktiv)",
"long_description": "Viser hvor mange minutter som er igjen i gjeldende dyrperiode. Returnerer 0 når ingen periode er aktiv. Oppdateres hvert minutt. Sjekk binary_sensor.peak_price_period for å se om en periode er aktiv.",
"usage_tips": "Bruk i automatiseringer: 'Hvis remaining_minutes > 60, avbryt utsatt ladeøkt'. Verdi 0 gjør det enkelt å skille mellom aktive (verdi > 0) og inaktive (verdi = 0) perioder."
},
"peak_price_progress": {
"description": "Fremdrift gjennom gjeldende dyrperiode (0% når inaktiv)",
"long_description": "Viser fremdrift gjennom gjeldende dyrperiode som 0-100%. Returnerer 0% når ingen periode er aktiv. Oppdateres hvert minutt.",
"usage_tips": "Visuell fremdriftsindikator i dashboards. Automatisering: 'Hvis progress > 0 OG progress > 90, forbered normal varmestyringsplan'. Verdi 0 indikerer ingen aktiv periode."
},
"peak_price_next_start_time": {
"description": "Når neste dyrperiode starter",
"long_description": "Viser når neste kommende dyrperiode starter. Under en aktiv periode viser dette starten av NESTE periode etter den gjeldende. Returnerer 'Ukjent' bare når ingen fremtidige perioder er konfigurert.",
"usage_tips": "Alltid nyttig for planlegging: 'Neste dyrperiode starter om 2 timer'. Automatisering: 'Når neste starttid er om 30 minutter, reduser varmetemperatur forebyggende'."
},
"peak_price_next_in_minutes": {
"description": "Minutter til neste dyrperiode starter (0 ved overgang)",
"long_description": "Viser minutter til neste dyrperiode starter. Under en aktiv periode viser dette tiden til perioden ETTER den gjeldende. Returnerer 0 under korte overgangsmomenter. Oppdateres hvert minutt.",
"usage_tips": "Forebyggende automatisering: 'Hvis next_in_minutes > 0 OG next_in_minutes < 10, fullfør gjeldende ladesyklus nå før prisene øker'."
}
},
"binary_sensor": {

View file

@ -283,6 +283,56 @@
"description": "Prognose van aanstaande elektriciteitsprijzen",
"long_description": "Toont aanstaande elektriciteitsprijzen voor toekomstige intervallen in een formaat dat gemakkelijk te gebruiken is in dashboards",
"usage_tips": "Gebruik de attributen van deze entiteit om aanstaande prijzen weer te geven in grafieken of aangepaste kaarten. Toegang tot 'intervals' voor alle toekomstige intervallen of 'hours' voor uuroverzichten."
},
"best_price_end_time": {
"description": "Wanneer de huidige of volgende goedkope periode eindigt",
"long_description": "Toont het eindtijdstempel van de huidige goedkope periode wanneer actief, of het einde van de volgende periode wanneer geen periode actief is. Toont altijd een nuttige tijdreferentie voor planning. Geeft alleen 'Onbekend' terug wanneer geen periodes zijn geconfigureerd.",
"usage_tips": "Gebruik dit om een aftelling weer te geven zoals 'Goedkope periode eindigt over 2 uur' (wanneer actief) of 'Volgende goedkope periode eindigt om 14:00' (wanneer inactief). Home Assistant toont automatisch relatieve tijd voor tijdstempelsensoren."
},
"best_price_remaining_minutes": {
"description": "Resterende minuten in huidige goedkope periode (0 wanneer inactief)",
"long_description": "Toont hoeveel minuten er nog over zijn in de huidige goedkope periode. Geeft 0 terug wanneer geen periode actief is. Werkt elke minuut bij. Controleer binary_sensor.best_price_period om te zien of een periode momenteel actief is.",
"usage_tips": "Perfect voor automatiseringen: 'Als remaining_minutes > 0 EN remaining_minutes < 30, start wasmachine nu'. De waarde 0 maakt het gemakkelijk om te controleren of een periode actief is (waarde > 0) of niet (waarde = 0)."
},
"best_price_progress": {
"description": "Voortgang door huidige goedkope periode (0% wanneer inactief)",
"long_description": "Toont de voortgang door de huidige goedkope periode als 0-100%. Geeft 0% terug wanneer geen periode actief is. Werkt elke minuut bij. 0% betekent periode net gestart, 100% betekent het eindigt bijna.",
"usage_tips": "Geweldig voor visuele voortgangsbalken. Gebruik in automatiseringen: 'Als progress > 0 EN progress > 75, stuur melding dat goedkope periode bijna eindigt'. Waarde 0 geeft aan dat er geen actieve periode is."
},
"best_price_next_start_time": {
"description": "Wanneer de volgende goedkope periode begint",
"long_description": "Toont wanneer de volgende komende goedkope periode begint. Tijdens een actieve periode toont dit de start van de VOLGENDE periode na de huidige. Geeft alleen 'Onbekend' terug wanneer geen toekomstige periodes zijn geconfigureerd.",
"usage_tips": "Altijd nuttig voor vooruitplanning: 'Volgende goedkope periode begint over 3 uur' (of je nu in een periode zit of niet). Combineer met automatiseringen: 'Wanneer volgende starttijd over 10 minuten is, stuur melding om wasmachine voor te bereiden'."
},
"best_price_next_in_minutes": {
"description": "Minuten tot volgende goedkope periode begint (0 bij overgang)",
"long_description": "Toont minuten tot de volgende goedkope periode begint. Tijdens een actieve periode toont dit de tijd tot de periode NA de huidige. Geeft 0 terug tijdens korte overgangsmomenten. Werkt elke minuut bij.",
"usage_tips": "Perfect voor 'wacht tot goedkope periode' automatiseringen: 'Als next_in_minutes > 0 EN next_in_minutes < 15, wacht voordat vaatwasser wordt gestart'. Waarde > 0 geeft altijd aan dat een toekomstige periode is gepland."
},
"peak_price_end_time": {
"description": "Wanneer de huidige of volgende dure periode eindigt",
"long_description": "Toont het eindtijdstempel van de huidige dure periode wanneer actief, of het einde van de volgende periode wanneer geen periode actief is. Toont altijd een nuttige tijdreferentie voor planning. Geeft alleen 'Onbekend' terug wanneer geen periodes zijn geconfigureerd.",
"usage_tips": "Gebruik dit om 'Dure periode eindigt over 1 uur' weer te geven (wanneer actief) of 'Volgende dure periode eindigt om 18:00' (wanneer inactief). Combineer met automatiseringen om activiteiten te hervatten na piek."
},
"peak_price_remaining_minutes": {
"description": "Resterende minuten in huidige dure periode (0 wanneer inactief)",
"long_description": "Toont hoeveel minuten er nog over zijn in de huidige dure periode. Geeft 0 terug wanneer geen periode actief is. Werkt elke minuut bij. Controleer binary_sensor.peak_price_period om te zien of een periode momenteel actief is.",
"usage_tips": "Gebruik in automatiseringen: 'Als remaining_minutes > 60, annuleer uitgestelde laadronde'. Waarde 0 maakt het gemakkelijk om onderscheid te maken tussen actieve (waarde > 0) en inactieve (waarde = 0) periodes."
},
"peak_price_progress": {
"description": "Voortgang door huidige dure periode (0% wanneer inactief)",
"long_description": "Toont de voortgang door de huidige dure periode als 0-100%. Geeft 0% terug wanneer geen periode actief is. Werkt elke minuut bij.",
"usage_tips": "Visuele voortgangsindicator in dashboards. Automatisering: 'Als progress > 0 EN progress > 90, bereid normale verwarmingsplanning voor'. Waarde 0 geeft aan dat er geen actieve periode is."
},
"peak_price_next_start_time": {
"description": "Wanneer de volgende dure periode begint",
"long_description": "Toont wanneer de volgende komende dure periode begint. Tijdens een actieve periode toont dit de start van de VOLGENDE periode na de huidige. Geeft alleen 'Onbekend' terug wanneer geen toekomstige periodes zijn geconfigureerd.",
"usage_tips": "Altijd nuttig voor planning: 'Volgende dure periode begint over 2 uur'. Automatisering: 'Wanneer volgende starttijd over 30 minuten is, verlaag verwarmingstemperatuur preventief'."
},
"peak_price_next_in_minutes": {
"description": "Minuten tot volgende dure periode begint (0 bij overgang)",
"long_description": "Toont minuten tot de volgende dure periode begint. Tijdens een actieve periode toont dit de tijd tot de periode NA de huidige. Geeft 0 terug tijdens korte overgangsmomenten. Werkt elke minuut bij.",
"usage_tips": "Preventieve automatisering: 'Als next_in_minutes > 0 EN next_in_minutes < 10, voltooi huidige laadcyclus nu voordat prijzen stijgen'."
}
},
"binary_sensor": {

View file

@ -283,6 +283,56 @@
"description": "Prognos för kommande elpriser",
"long_description": "Visar kommande elpriser för framtida intervaller i ett format som är enkelt att använda i instrumentpaneler",
"usage_tips": "Använd denna enhets attribut för att visa kommande priser i diagram eller anpassade kort. Få åtkomst till antingen 'intervals' för alla framtida intervaller eller 'hours' för timvisa sammanfattningar."
},
"best_price_end_time": {
"description": "När nuvarande eller nästa billigperiod slutar",
"long_description": "Visar sluttidsstämpeln för nuvarande billigperiod när aktiv, eller slutet av nästa period när ingen period är aktiv. Visar alltid en användbar tidsreferens för planering. Returnerar 'Okänt' endast när inga perioder är konfigurerade.",
"usage_tips": "Använd detta för att visa en nedräkning som 'Billigperiod slutar om 2 timmar' (när aktiv) eller 'Nästa billigperiod slutar kl 14:00' (när inaktiv). Home Assistant visar automatiskt relativ tid för tidsstämpelsensorer."
},
"best_price_remaining_minutes": {
"description": "Återstående minuter i nuvarande billigperiod (0 när inaktiv)",
"long_description": "Visar hur många minuter som återstår i nuvarande billigperiod. Returnerar 0 när ingen period är aktiv. Uppdateras varje minut. Kontrollera binary_sensor.best_price_period för att se om en period är aktiv.",
"usage_tips": "Perfekt för automationer: 'Om remaining_minutes > 0 OCH remaining_minutes < 30, starta tvättmaskin nu'. Värdet 0 gör det enkelt att kontrollera om en period är aktiv (värde > 0) eller inte (värde = 0)."
},
"best_price_progress": {
"description": "Framsteg genom nuvarande billigperiod (0% när inaktiv)",
"long_description": "Visar framsteg genom nuvarande billigperiod som 0-100%. Returnerar 0% när ingen period är aktiv. Uppdateras varje minut. 0% betyder period just startad, 100% betyder den snart slutar.",
"usage_tips": "Bra för visuella framstegsstaplar. Använd i automationer: 'Om progress > 0 OCH progress > 75, skicka meddelande att billigperiod snart slutar'. Värde 0 indikerar ingen aktiv period."
},
"best_price_next_start_time": {
"description": "När nästa billigperiod startar",
"long_description": "Visar när nästa kommande billigperiod startar. Under en aktiv period visar detta starten av NÄSTA period efter den nuvarande. Returnerar 'Okänt' endast när inga framtida perioder är konfigurerade.",
"usage_tips": "Alltid användbart för framåtplanering: 'Nästa billigperiod startar om 3 timmar' (oavsett om du är i en period nu eller inte). Kombinera med automationer: 'När nästa starttid är om 10 minuter, skicka meddelande för att förbereda tvättmaskin'."
},
"best_price_next_in_minutes": {
"description": "Minuter tills nästa billigperiod startar (0 vid övergång)",
"long_description": "Visar minuter tills nästa billigperiod startar. Under en aktiv period visar detta tiden till perioden EFTER den nuvarande. Returnerar 0 under korta övergångsmoment. Uppdateras varje minut.",
"usage_tips": "Perfekt för 'vänta tills billigperiod' automationer: 'Om next_in_minutes > 0 OCH next_in_minutes < 15, vänta innan diskmaskin startas'. Värde > 0 indikerar alltid att en framtida period är planerad."
},
"peak_price_end_time": {
"description": "När nuvarande eller nästa dyrperiod slutar",
"long_description": "Visar sluttidsstämpeln för nuvarande dyrperiod när aktiv, eller slutet av nästa period när ingen period är aktiv. Visar alltid en användbar tidsreferens för planering. Returnerar 'Okänt' endast när inga perioder är konfigurerade.",
"usage_tips": "Använd detta för att visa 'Dyrperiod slutar om 1 timme' (när aktiv) eller 'Nästa dyrperiod slutar kl 18:00' (när inaktiv). Kombinera med automationer för att återuppta drift efter topp."
},
"peak_price_remaining_minutes": {
"description": "Återstående minuter i nuvarande dyrperiod (0 när inaktiv)",
"long_description": "Visar hur många minuter som återstår i nuvarande dyrperiod. Returnerar 0 när ingen period är aktiv. Uppdateras varje minut. Kontrollera binary_sensor.peak_price_period för att se om en period är aktiv.",
"usage_tips": "Använd i automationer: 'Om remaining_minutes > 60, avbryt uppskjuten laddningssession'. Värde 0 gör det enkelt att skilja mellan aktiva (värde > 0) och inaktiva (värde = 0) perioder."
},
"peak_price_progress": {
"description": "Framsteg genom nuvarande dyrperiod (0% när inaktiv)",
"long_description": "Visar framsteg genom nuvarande dyrperiod som 0-100%. Returnerar 0% när ingen period är aktiv. Uppdateras varje minut.",
"usage_tips": "Visuell framstegsindikator i instrumentpaneler. Automation: 'Om progress > 0 OCH progress > 90, förbered normal värmeplanering'. Värde 0 indikerar ingen aktiv period."
},
"peak_price_next_start_time": {
"description": "När nästa dyrperiod startar",
"long_description": "Visar när nästa kommande dyrperiod startar. Under en aktiv period visar detta starten av NÄSTA period efter den nuvarande. Returnerar 'Okänt' endast när inga framtida perioder är konfigurerade.",
"usage_tips": "Alltid användbart för planering: 'Nästa dyrperiod startar om 2 timmar'. Automation: 'När nästa starttid är om 30 minuter, minska värmetemperatur förebyggande'."
},
"peak_price_next_in_minutes": {
"description": "Minuter tills nästa dyrperiod startar (0 vid övergång)",
"long_description": "Visar minuter tills nästa dyrperiod startar. Under en aktiv period visar detta tiden till perioden EFTER den nuvarande. Returnerar 0 under korta övergångsmoment. Uppdateras varje minut.",
"usage_tips": "Förebyggande automation: 'Om next_in_minutes > 0 OCH next_in_minutes < 10, slutför nuvarande laddcykel nu innan priserna ökar'."
}
},
"binary_sensor": {

View file

@ -11,6 +11,11 @@ from custom_components.tibber_prices.const import (
VOLATILITY_COLOR_MAPPING,
)
# Timing sensor color thresholds
TIMING_HIGH_PROGRESS_THRESHOLD = 75 # >=75%: High intensity color
TIMING_URGENT_THRESHOLD = 15 # <=15 min: Urgent
TIMING_SOON_THRESHOLD = 60 # <=60 min: Soon
def add_icon_color_attribute(
attributes: dict,
@ -68,6 +73,11 @@ def get_icon_color(
}
return trend_colors.get(state_value)
# Timing sensor colors (best_price = green, peak_price = red/orange)
timing_color = get_timing_sensor_color(key, state_value)
if timing_color:
return timing_color
# Price level/rating/volatility colors (based on uppercase value)
if isinstance(state_value, str):
return (
@ -77,3 +87,38 @@ def get_icon_color(
)
return None
def get_timing_sensor_color(key: str, state_value: Any) -> str | None:
"""
Get color for best_price/peak_price timing sensors.
Best price sensors: Green (good for user)
Peak price sensors: Red/Orange (warning/alert)
Args:
key: Entity description key
state_value: Sensor value (percentage or minutes)
Returns:
CSS color variable string or None
"""
is_best_price = key.startswith("best_price_")
if not (is_best_price or key.startswith("peak_price_")):
return None
# No data / zero value
if state_value is None or (isinstance(state_value, (int, float)) and state_value == 0):
return "var(--disabled-color)"
# Progress sensors: Intensity based on completion
if key.endswith("_progress") and isinstance(state_value, (int, float)):
high_intensity = state_value >= TIMING_HIGH_PROGRESS_THRESHOLD
if is_best_price:
return "var(--success-color)" if high_intensity else "var(--info-color)"
return "var(--error-color)" if high_intensity else "var(--warning-color)"
# All other sensors: Simple period-type color
return "var(--success-color)" if is_best_price else "var(--warning-color)"

View file

@ -2,6 +2,8 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
from typing import TYPE_CHECKING, Any
@ -19,20 +21,38 @@ from custom_components.tibber_prices.sensor.helpers import (
)
from homeassistant.util import dt as dt_util
@dataclass
class IconContext:
"""Context data for dynamic icon selection."""
is_on: bool | None = None
coordinator_data: dict | None = None
has_future_periods_callback: Callable[[], bool] | None = None
period_is_active_callback: Callable[[], bool] | None = None
if TYPE_CHECKING:
from collections.abc import Callable
# Constants imported from price_utils
MINUTES_PER_INTERVAL = 15
# Timing sensor icon thresholds (in minutes)
TIMING_URGENT_THRESHOLD = 15 # ≤15 min: Alert icon
TIMING_SOON_THRESHOLD = 60 # ≤1 hour: Timer icon
TIMING_MEDIUM_THRESHOLD = 180 # ≤3 hours: Sand timer icon
# >3 hours: Outline timer icon
# Progress sensor constants
PROGRESS_MAX = 100 # Maximum progress value (100%)
def get_dynamic_icon(
key: str,
value: Any,
*,
is_on: bool | None = None,
coordinator_data: dict | None = None,
has_future_periods_callback: Callable[[], bool] | None = None,
context: IconContext | None = None,
) -> str | None:
"""
Get dynamic icon based on sensor state.
@ -42,22 +62,23 @@ def get_dynamic_icon(
Args:
key: Entity description key
value: Native value of the sensor
is_on: Binary sensor state (None for regular sensors)
coordinator_data: Coordinator data for price level lookups
has_future_periods_callback: Callback to check if future periods exist (binary sensors)
context: Optional context with is_on state, coordinator_data, and callbacks
Returns:
Icon string or None if no dynamic icon applies
"""
ctx = context or IconContext()
# Try various icon sources in order
return (
get_trend_icon(key, value)
or get_price_sensor_icon(key, coordinator_data)
or get_timing_sensor_icon(key, value, period_is_active_callback=ctx.period_is_active_callback)
or get_price_sensor_icon(key, ctx.coordinator_data)
or get_level_sensor_icon(key, value)
or get_rating_sensor_icon(key, value)
or get_volatility_sensor_icon(key, value)
or get_binary_sensor_icon(key, is_on=is_on, has_future_periods_callback=has_future_periods_callback)
or get_binary_sensor_icon(key, is_on=ctx.is_on, has_future_periods_callback=ctx.has_future_periods_callback)
)
@ -74,6 +95,74 @@ def get_trend_icon(key: str, value: Any) -> str | None:
return trend_icons.get(value)
def get_timing_sensor_icon(
key: str,
value: Any,
*,
period_is_active_callback: Callable[[], bool] | None = None,
) -> str | None:
"""
Get dynamic icon for best_price/peak_price timing sensors.
Progress sensors: Different icons based on period state
- No period: mdi:help-circle-outline (Unknown/gray)
- Waiting (0%, period not active): mdi:timer-pause-outline (paused/waiting)
- Active (0%, period running): mdi:circle-outline (just started)
- Progress 1-99%: mdi:circle-slice-1 to mdi:circle-slice-7
- Complete (100%): mdi:circle-slice-8
Remaining/Next-in sensors: Different timer icons based on time remaining
Timestamp sensors: Static icons (handled by entity description)
Args:
key: Entity description key
value: Sensor value (percentage for progress, minutes for countdown)
period_is_active_callback: Callback to check if related period is currently active
Returns:
Icon string or None if not a timing sensor with dynamic icon
"""
# Unknown state: Show help icon for all timing sensors
if value is None and key.startswith(("best_price_", "peak_price_")):
return "mdi:help-circle-outline"
# Progress sensors: Circle-slice icons for visual progress indication
# mdi:circle-slice-N where N represents filled portions (1=12.5%, 8=100%)
if key.endswith("_progress") and isinstance(value, (int, float)):
# Special handling for 0%: Distinguish between waiting and active
if value <= 0:
# Check if period is currently active via callback
is_active = (
period_is_active_callback()
if (period_is_active_callback and callable(period_is_active_callback))
else True
)
# Period just started (0% but running) vs waiting for next
return "mdi:circle-outline" if is_active else "mdi:timer-pause-outline"
# Calculate slice based on progress percentage
slice_num = 8 if value >= PROGRESS_MAX else min(7, max(1, int((value / PROGRESS_MAX) * 8)))
return f"mdi:circle-slice-{slice_num}"
# Remaining/Next-in minutes sensors: Timer icons based on urgency thresholds
if key.endswith(("_remaining_minutes", "_next_in_minutes")) and isinstance(value, (int, float)):
# Map time remaining to appropriate timer icon
urgency_map = [
(0, "mdi:timer-off-outline"), # Exactly 0 minutes
(TIMING_URGENT_THRESHOLD, "mdi:timer-alert"), # < 15 min: urgent
(TIMING_SOON_THRESHOLD, "mdi:timer"), # < 60 min: soon
(TIMING_MEDIUM_THRESHOLD, "mdi:timer-sand"), # < 180 min: medium
]
for threshold, icon in urgency_map:
if value <= threshold:
return icon
return "mdi:timer-outline" # >= 180 min: far away
# Timestamp sensors use static icons from entity description
return None
def get_price_sensor_icon(key: str, coordinator_data: dict | None) -> str | None:
"""
Get icon for current price sensors (dynamic based on price level).

View file

@ -32,6 +32,36 @@ if TYPE_CHECKING:
MAX_FORECAST_INTERVALS = 8 # Show up to 8 future intervals (2 hours with 15-min intervals)
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_timing_or_volatility_attributes(
attributes: dict,
key: str,
cached_data: dict,
native_value: Any = None,
) -> None:
"""Add attributes for timing or volatility sensors."""
if key.endswith("_volatility"):
add_volatility_attributes(attributes=attributes, cached_data=cached_data)
else:
add_period_timing_attributes(attributes=attributes, key=key, state_value=native_value)
def build_sensor_attributes(
key: str,
coordinator: TibberPricesDataUpdateCoordinator,
@ -121,8 +151,8 @@ def build_sensor_attributes(
)
elif key == "price_forecast":
add_price_forecast_attributes(attributes=attributes, coordinator=coordinator)
elif key.endswith("_volatility"):
add_volatility_attributes(attributes=attributes, cached_data=cached_data)
elif _is_timing_or_volatility_sensor(key):
_add_timing_or_volatility_attributes(attributes, key, cached_data, native_value)
# For current_interval_price_level, add the original level as attribute
if key == "current_interval_price_level" and cached_data.get("last_price_level") is not None:
@ -887,3 +917,42 @@ def get_current_interval_data(
price_info = coordinator.data.get("priceInfo", {})
now = dt_util.now()
return find_price_data_for_interval(price_info, now)
def add_period_timing_attributes(
attributes: dict,
key: str,
state_value: Any = None,
) -> 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): Timestamp of current 15-min interval
- Minute-update sensors (remaining_minutes, progress, next_in_minutes): Current minute with :00 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
"""
# Determine if this is a quarter-hour or minute-update sensor
is_quarter_hour_sensor = key.endswith(("_end_time", "_next_start_time"))
now = dt_util.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:
# Minute-update sensors: Use current minute with :00 seconds
# This ensures clean timestamps despite timer fluctuations
timestamp = now.replace(second=0, microsecond=0)
attributes["timestamp"] = timestamp.isoformat()
# Add icon_color for dynamic styling
add_icon_color_attribute(attributes, key=key, state_value=state_value)

View file

@ -14,6 +14,9 @@ from custom_components.tibber_prices.average_utils import (
calculate_current_trailing_min,
calculate_next_n_hours_avg,
)
from custom_components.tibber_prices.binary_sensor.attributes import (
get_price_intervals_attributes,
)
from custom_components.tibber_prices.const import (
CONF_EXTENDED_DESCRIPTIONS,
CONF_PRICE_RATING_THRESHOLD_HIGH,
@ -30,12 +33,16 @@ from custom_components.tibber_prices.const import (
format_price_unit_minor,
get_entity_description,
)
from custom_components.tibber_prices.coordinator import TIME_SENSITIVE_ENTITY_KEYS
from custom_components.tibber_prices.coordinator import (
MINUTE_UPDATE_ENTITY_KEYS,
TIME_SENSITIVE_ENTITY_KEYS,
)
from custom_components.tibber_prices.entity import TibberPricesEntity
from custom_components.tibber_prices.entity_utils import (
add_icon_color_attribute,
get_dynamic_icon,
)
from custom_components.tibber_prices.entity_utils.icons import IconContext
from custom_components.tibber_prices.price_utils import (
MINUTES_PER_INTERVAL,
calculate_price_trend,
@ -76,6 +83,7 @@ LAST_HOUR_OF_DAY = 23
INTERVALS_PER_HOUR = 4 # 15-minute intervals
MAX_FORECAST_INTERVALS = 8 # Show up to 8 future intervals (2 hours with 15-min intervals)
MIN_HOURS_FOR_LATER_HALF = 3 # Minimum hours needed to calculate later half average
PROGRESS_GRACE_PERIOD_SECONDS = 60 # Show 100% for 1 minute after period ends
class TibberPricesSensor(TibberPricesEntity, SensorEntity):
@ -93,6 +101,7 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
self._attr_has_entity_name = True
self._value_getter: Callable | None = self._get_value_getter()
self._time_sensitive_remove_listener: Callable | None = None
self._minute_update_remove_listener: Callable | None = None
self._trend_attributes: dict[str, Any] = {} # Sensor-specific trend attributes
self._cached_trend_value: str | None = None # Cache for trend state
@ -106,6 +115,12 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
self._handle_time_sensitive_update
)
# Register with coordinator for minute-by-minute updates if applicable
if self.entity_description.key in MINUTE_UPDATE_ENTITY_KEYS:
self._minute_update_remove_listener = self.coordinator.async_add_minute_update_listener(
self._handle_minute_update
)
async def async_will_remove_from_hass(self) -> None:
"""When entity will be removed from hass."""
await super().async_will_remove_from_hass()
@ -115,6 +130,11 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
self._time_sensitive_remove_listener()
self._time_sensitive_remove_listener = None
# Remove minute-update listener if registered
if self._minute_update_remove_listener:
self._minute_update_remove_listener()
self._minute_update_remove_listener = None
@callback
def _handle_time_sensitive_update(self) -> None:
"""Handle time-sensitive update from coordinator."""
@ -124,6 +144,11 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
self._trend_attributes = {}
self.async_write_ha_state()
@callback
def _handle_minute_update(self) -> None:
"""Handle minute-by-minute update from coordinator."""
self.async_write_ha_state()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
@ -247,6 +272,41 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
"tomorrow_volatility": lambda: self._get_volatility_value(volatility_type="tomorrow"),
"next_24h_volatility": lambda: self._get_volatility_value(volatility_type="next_24h"),
"today_tomorrow_volatility": lambda: self._get_volatility_value(volatility_type="today_tomorrow"),
# ================================================================
# BEST/PEAK PRICE TIMING SENSORS (period-based time tracking)
# ================================================================
# Best Price timing sensors
"best_price_end_time": lambda: self._get_period_timing_value(
period_type="best_price", value_type="end_time"
),
"best_price_remaining_minutes": lambda: self._get_period_timing_value(
period_type="best_price", value_type="remaining_minutes"
),
"best_price_progress": lambda: self._get_period_timing_value(
period_type="best_price", value_type="progress"
),
"best_price_next_start_time": lambda: self._get_period_timing_value(
period_type="best_price", value_type="next_start_time"
),
"best_price_next_in_minutes": lambda: self._get_period_timing_value(
period_type="best_price", value_type="next_in_minutes"
),
# Peak Price timing sensors
"peak_price_end_time": lambda: self._get_period_timing_value(
period_type="peak_price", value_type="end_time"
),
"peak_price_remaining_minutes": lambda: self._get_period_timing_value(
period_type="peak_price", value_type="remaining_minutes"
),
"peak_price_progress": lambda: self._get_period_timing_value(
period_type="peak_price", value_type="progress"
),
"peak_price_next_start_time": lambda: self._get_period_timing_value(
period_type="peak_price", value_type="next_start_time"
),
"peak_price_next_in_minutes": lambda: self._get_period_timing_value(
period_type="peak_price", value_type="next_in_minutes"
),
}
return handlers.get(key)
@ -930,6 +990,188 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
# Return lowercase for ENUM device class
return volatility.lower()
# ========================================================================
# BEST/PEAK PRICE TIMING METHODS (period-based time tracking)
# ========================================================================
def _get_period_timing_value(
self,
*,
period_type: str,
value_type: str,
) -> datetime | float | None:
"""
Get timing-related values for best_price/peak_price periods.
This method provides timing information based on whether a period is currently
active or not, ensuring sensors always provide useful information.
Value types behavior:
- end_time: Active period current end | No active next period end | None if no periods
- next_start_time: Active period next-next start | No active next start | None if no more
- remaining_minutes: Active period minutes to end | No active 0
- progress: Active period 0-100% | No active 0
- next_in_minutes: Active period minutes to next-next | No active minutes to next | None if no more
Args:
period_type: "best_price" or "peak_price"
value_type: "end_time", "remaining_minutes", "progress", "next_start_time", "next_in_minutes"
Returns:
- datetime for end_time/next_start_time
- float for remaining_minutes/next_in_minutes/progress (or 0 when not active)
- None if no relevant period data available
"""
if not self.coordinator.data:
return None
# Get period data from coordinator
periods_data = self.coordinator.data.get("periods", {})
period_data = periods_data.get(period_type)
if not period_data or not period_data.get("periods"):
# No periods available - return 0 for numeric sensors, None for timestamps
return 0 if value_type in ("remaining_minutes", "progress", "next_in_minutes") else None
period_summaries = period_data["periods"]
now = dt_util.now()
# Find current, previous and next periods
current_period = self._find_active_period(period_summaries, now)
previous_period = self._find_previous_period(period_summaries, now)
next_period = self._find_next_period(period_summaries, now, skip_current=bool(current_period))
# Delegate to specific calculators
return self._calculate_timing_value(value_type, current_period, previous_period, next_period, now)
def _calculate_timing_value(
self,
value_type: str,
current_period: dict | None,
previous_period: dict | None,
next_period: dict | None,
now: datetime,
) -> datetime | float | None:
"""Calculate specific timing value based on type and available periods."""
# Define calculation strategies for each value type
calculators = {
"end_time": lambda: (
current_period.get("end") if current_period else (next_period.get("end") if next_period else None)
),
"next_start_time": lambda: next_period.get("start") if next_period else None,
"remaining_minutes": lambda: (self._calc_remaining_minutes(current_period, now) if current_period else 0),
"progress": lambda: self._calc_progress_with_grace_period(current_period, previous_period, now),
"next_in_minutes": lambda: (self._calc_next_in_minutes(next_period, now) if next_period else None),
}
calculator = calculators.get(value_type)
return calculator() if calculator else None
def _find_active_period(self, periods: list, now: datetime) -> dict | None:
"""Find currently active period."""
for period in periods:
start = period.get("start")
end = period.get("end")
if start and end and start <= now < end:
return period
return None
def _find_previous_period(self, periods: list, now: datetime) -> dict | None:
"""Find the most recent period that has already ended."""
past_periods = [p for p in periods if p.get("end") and p.get("end") <= now]
if not past_periods:
return None
# Sort by end time descending to get the most recent one
past_periods.sort(key=lambda p: p["end"], reverse=True)
return past_periods[0]
def _find_next_period(self, periods: list, now: datetime, *, skip_current: bool = False) -> dict | None:
"""
Find next future period.
Args:
periods: List of period dictionaries
now: Current time
skip_current: If True, skip the first future period (to get next-next)
Returns:
Next period dict or None if no future periods
"""
future_periods = [p for p in periods if p.get("start") and p.get("start") > now]
if not future_periods:
return None
# Sort by start time to ensure correct order
future_periods.sort(key=lambda p: p["start"])
# Return second period if skip_current=True (next-next), otherwise first (next)
if skip_current and len(future_periods) > 1:
return future_periods[1]
if not skip_current and future_periods:
return future_periods[0]
return None
def _calc_remaining_minutes(self, period: dict, now: datetime) -> float:
"""Calculate minutes until period ends."""
end = period.get("end")
if not end:
return 0
delta = end - now
return max(0, delta.total_seconds() / 60)
def _calc_next_in_minutes(self, period: dict, now: datetime) -> float:
"""Calculate minutes until period starts."""
start = period.get("start")
if not start:
return 0
delta = start - now
return max(0, delta.total_seconds() / 60)
def _calc_progress(self, period: dict, now: datetime) -> float:
"""Calculate progress percentage (0-100) of current period."""
start = period.get("start")
end = period.get("end")
if not start or not end:
return 0
total_duration = (end - start).total_seconds()
if total_duration <= 0:
return 0
elapsed = (now - start).total_seconds()
progress = (elapsed / total_duration) * 100
return min(100, max(0, progress))
def _calc_progress_with_grace_period(
self, current_period: dict | None, previous_period: dict | None, now: datetime
) -> float:
"""
Calculate progress with grace period after period end.
Shows 100% for 1 minute after period ends to allow triggers on 100% completion.
This prevents the progress from jumping directly from ~99% to 0% without ever
reaching 100%, which would make automations like "when progress = 100%" impossible.
"""
# If we have an active period, calculate normal progress
if current_period:
return self._calc_progress(current_period, now)
# No active period - check if we just finished one (within grace period)
if previous_period:
previous_end = previous_period.get("end")
if previous_end:
seconds_since_end = (now - previous_end).total_seconds()
# Grace period: Show 100% for defined time after period ended
if 0 <= seconds_since_end <= PROGRESS_GRACE_PERIOD_SECONDS:
return 100
# No active period and either no previous period or grace period expired
return 0
# Add method to get future price intervals
def _get_price_forecast_value(self) -> str | None:
"""Get the highest or lowest price status for the price forecast entity."""
@ -962,16 +1204,45 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement dynamically based on currency."""
if self.entity_description.device_class != SensorDeviceClass.MONETARY:
return None
"""Return the unit of measurement dynamically based on currency or entity description."""
# For MONETARY sensors, return currency-specific unit
if self.entity_description.device_class == SensorDeviceClass.MONETARY:
currency = None
if self.coordinator.data:
price_info = self.coordinator.data.get("priceInfo", {})
currency = price_info.get("currency")
return format_price_unit_minor(currency)
currency = None
if self.coordinator.data:
price_info = self.coordinator.data.get("priceInfo", {})
currency = price_info.get("currency")
# For all other sensors, use unit from entity description
return self.entity_description.native_unit_of_measurement
return format_price_unit_minor(currency)
def _is_best_price_period_active(self) -> bool:
"""Check if the current time is within a best price period."""
if not self.coordinator.data:
return False
attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=False)
if not attrs:
return False
start = attrs.get("start")
end = attrs.get("end")
if not start or not end:
return False
now = dt_util.now()
return start <= now < end
def _is_peak_price_period_active(self) -> bool:
"""Check if the current time is within a peak price period."""
if not self.coordinator.data:
return False
attrs = get_price_intervals_attributes(self.coordinator.data, reverse_sort=True)
if not attrs:
return False
start = attrs.get("start")
end = attrs.get("end")
if not start or not end:
return False
now = dt_util.now()
return start <= now < end
@property
def icon(self) -> str | None:
@ -979,11 +1250,21 @@ class TibberPricesSensor(TibberPricesEntity, SensorEntity):
key = self.entity_description.key
value = self.native_value
# Use centralized icon logic
# Create callback for period active state check (used by timing sensors)
period_is_active_callback = None
if key.startswith("best_price_"):
period_is_active_callback = self._is_best_price_period_active
elif key.startswith("peak_price_"):
period_is_active_callback = self._is_peak_price_period_active
# Use centralized icon logic with context
icon = get_dynamic_icon(
key=key,
value=value,
coordinator_data=self.coordinator.data,
context=IconContext(
coordinator_data=self.coordinator.data,
period_is_active_callback=period_is_active_callback,
),
)
# Fall back to static icon from entity description

View file

@ -12,7 +12,8 @@ Organization by calculation pattern:
4. 24h windows: Trailing/leading statistics
5. Future forecast: N-hour windows from next interval
6. Volatility: Price variation analysis
7. Diagnostic: System metadata
7. Best/Peak Price timing: Period-based time tracking
8. Diagnostic: System metadata
"""
from __future__ import annotations
@ -21,7 +22,7 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
# ============================================================================
# SENSOR DEFINITIONS - Grouped by calculation method
@ -38,7 +39,8 @@ from homeassistant.const import EntityCategory
# 4. 24h windows: Trailing/leading from current interval
# 5. Future forecast: N-hour windows starting from next interval
# 6. Volatility: Statistical analysis of price variation
# 7. Diagnostic: System information and metadata
# 7. Best/Peak Price timing: Period-based time tracking (requires minute updates)
# 8. Diagnostic: System information and metadata
# ============================================================================
# ----------------------------------------------------------------------------
@ -594,7 +596,105 @@ VOLATILITY_SENSORS = (
)
# ----------------------------------------------------------------------------
# 7. DIAGNOSTIC SENSORS (data availability and metadata)
# 7. BEST/PEAK PRICE TIMING SENSORS (period-based time tracking)
# ----------------------------------------------------------------------------
# These sensors track time relative to best_price/peak_price binary sensor periods.
# They require minute-by-minute updates via async_track_time_interval.
#
# When period is active (binary_sensor ON):
# - end_time: Timestamp when current period ends
# - remaining_minutes: Minutes until period ends
# - progress: Percentage of period completed (0-100%)
#
# When period is inactive (binary_sensor OFF):
# - next_start_time: Timestamp when next period starts
# - next_in_minutes: Minutes until next period starts
#
# All return None/Unknown when no period is active/scheduled.
BEST_PRICE_TIMING_SENSORS = (
SensorEntityDescription(
key="best_price_end_time",
translation_key="best_price_end_time",
name="Best Price Period End",
icon="mdi:clock-end",
device_class=SensorDeviceClass.TIMESTAMP,
),
SensorEntityDescription(
key="best_price_remaining_minutes",
translation_key="best_price_remaining_minutes",
name="Best Price Remaining Time",
icon="mdi:timer-sand",
native_unit_of_measurement=UnitOfTime.MINUTES,
suggested_display_precision=0,
),
SensorEntityDescription(
key="best_price_progress",
translation_key="best_price_progress",
name="Best Price Progress",
icon="mdi:percent", # Dynamic: mdi:percent-0 to mdi:percent-100
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=0,
),
SensorEntityDescription(
key="best_price_next_start_time",
translation_key="best_price_next_start_time",
name="Best Price Next Period Start",
icon="mdi:clock-start",
device_class=SensorDeviceClass.TIMESTAMP,
),
SensorEntityDescription(
key="best_price_next_in_minutes",
translation_key="best_price_next_in_minutes",
name="Best Price Starts In",
icon="mdi:timer-outline",
native_unit_of_measurement=UnitOfTime.MINUTES,
suggested_display_precision=0,
),
)
PEAK_PRICE_TIMING_SENSORS = (
SensorEntityDescription(
key="peak_price_end_time",
translation_key="peak_price_end_time",
name="Peak Price Period End",
icon="mdi:clock-end",
device_class=SensorDeviceClass.TIMESTAMP,
),
SensorEntityDescription(
key="peak_price_remaining_minutes",
translation_key="peak_price_remaining_minutes",
name="Peak Price Remaining Time",
icon="mdi:timer-sand",
native_unit_of_measurement=UnitOfTime.MINUTES,
suggested_display_precision=0,
),
SensorEntityDescription(
key="peak_price_progress",
translation_key="peak_price_progress",
name="Peak Price Progress",
icon="mdi:percent", # Dynamic: mdi:percent-0 to mdi:percent-100
native_unit_of_measurement=PERCENTAGE,
suggested_display_precision=0,
),
SensorEntityDescription(
key="peak_price_next_start_time",
translation_key="peak_price_next_start_time",
name="Peak Price Next Period Start",
icon="mdi:clock-start",
device_class=SensorDeviceClass.TIMESTAMP,
),
SensorEntityDescription(
key="peak_price_next_in_minutes",
translation_key="peak_price_next_in_minutes",
name="Peak Price Starts In",
icon="mdi:timer-outline",
native_unit_of_measurement=UnitOfTime.MINUTES,
suggested_display_precision=0,
),
)
# 8. DIAGNOSTIC SENSORS (data availability and metadata)
# ----------------------------------------------------------------------------
DIAGNOSTIC_SENSORS = (
@ -633,5 +733,7 @@ ENTITY_DESCRIPTIONS = (
*FUTURE_AVG_SENSORS,
*FUTURE_TREND_SENSORS,
*VOLATILITY_SENSORS,
*BEST_PRICE_TIMING_SENSORS,
*PEAK_PRICE_TIMING_SENSORS,
*DIAGNOSTIC_SENSORS,
)

View file

@ -511,6 +511,36 @@
"very_high": "Sehr hoch"
}
},
"best_price_end_time": {
"name": "Günstige Periode endet"
},
"best_price_remaining_minutes": {
"name": "Günstige Periode verbleibend"
},
"best_price_progress": {
"name": "Günstige Periode Fortschritt"
},
"best_price_next_start_time": {
"name": "Nächste günstige Periode startet"
},
"best_price_next_in_minutes": {
"name": "Günstige Periode startet in"
},
"peak_price_end_time": {
"name": "Teure Periode endet"
},
"peak_price_remaining_minutes": {
"name": "Teure Periode verbleibend"
},
"peak_price_progress": {
"name": "Teure Periode Fortschritt"
},
"peak_price_next_start_time": {
"name": "Nächste teure Periode startet"
},
"peak_price_next_in_minutes": {
"name": "Teure Periode startet in"
},
"price_forecast": {
"name": "Preisprognose"
}

View file

@ -507,6 +507,36 @@
"very_high": "Very High"
}
},
"best_price_end_time": {
"name": "Best Price Period End"
},
"best_price_remaining_minutes": {
"name": "Best Price Remaining Time"
},
"best_price_progress": {
"name": "Best Price Progress"
},
"best_price_next_start_time": {
"name": "Best Price Next Period Start"
},
"best_price_next_in_minutes": {
"name": "Best Price Starts In"
},
"peak_price_end_time": {
"name": "Peak Price Period End"
},
"peak_price_remaining_minutes": {
"name": "Peak Price Remaining Time"
},
"peak_price_progress": {
"name": "Peak Price Progress"
},
"peak_price_next_start_time": {
"name": "Peak Price Next Period Start"
},
"peak_price_next_in_minutes": {
"name": "Peak Price Starts In"
},
"price_forecast": {
"name": "Price Forecast"
}

View file

@ -507,6 +507,36 @@
"very_high": "Svært Høy"
}
},
"best_price_end_time": {
"name": "Beste prisperiode slutter"
},
"best_price_remaining_minutes": {
"name": "Beste prisperiode gjenværende tid"
},
"best_price_progress": {
"name": "Beste prisperiode fremgang"
},
"best_price_next_start_time": {
"name": "Neste beste prisperiode starter"
},
"best_price_next_in_minutes": {
"name": "Beste prisperiode starter om"
},
"peak_price_end_time": {
"name": "Topprisperiode slutter"
},
"peak_price_remaining_minutes": {
"name": "Topprisperiode gjenværende tid"
},
"peak_price_progress": {
"name": "Topprisperiode fremgang"
},
"peak_price_next_start_time": {
"name": "Neste topprisperiode starter"
},
"peak_price_next_in_minutes": {
"name": "Topprisperiode starter om"
},
"price_forecast": {
"name": "Prisprognose"
}

View file

@ -507,6 +507,36 @@
"very_high": "Zeer Hoog"
}
},
"best_price_end_time": {
"name": "Beste prijsperiode eindigt"
},
"best_price_remaining_minutes": {
"name": "Beste prijsperiode resterende tijd"
},
"best_price_progress": {
"name": "Beste prijsperiode voortgang"
},
"best_price_next_start_time": {
"name": "Volgende beste prijsperiode start"
},
"best_price_next_in_minutes": {
"name": "Beste prijsperiode start over"
},
"peak_price_end_time": {
"name": "Piekprijsperiode eindigt"
},
"peak_price_remaining_minutes": {
"name": "Piekprijsperiode resterende tijd"
},
"peak_price_progress": {
"name": "Piekprijsperiode voortgang"
},
"peak_price_next_start_time": {
"name": "Volgende piekprijsperiode start"
},
"peak_price_next_in_minutes": {
"name": "Piekprijsperiode start over"
},
"price_forecast": {
"name": "Prijsprognose"
}

View file

@ -507,6 +507,36 @@
"very_high": "Mycket Hög"
}
},
"best_price_end_time": {
"name": "Bästa prisperiod slutar"
},
"best_price_remaining_minutes": {
"name": "Bästa prisperiod återstående tid"
},
"best_price_progress": {
"name": "Bästa prisperiod framsteg"
},
"best_price_next_start_time": {
"name": "Nästa bästa prisperiod startar"
},
"best_price_next_in_minutes": {
"name": "Bästa prisperiod startar om"
},
"peak_price_end_time": {
"name": "Topprisperiod slutar"
},
"peak_price_remaining_minutes": {
"name": "Topprisperiod återstående tid"
},
"peak_price_progress": {
"name": "Topprisperiod framsteg"
},
"peak_price_next_start_time": {
"name": "Nästa topprisperiod startar"
},
"peak_price_next_in_minutes": {
"name": "Topprisperiod startar om"
},
"price_forecast": {
"name": "Prisprognos"
}