diff --git a/custom_components/tibber_prices/coordinator.py b/custom_components/tibber_prices/coordinator.py index bbaf532..cc367d7 100644 --- a/custom_components/tibber_prices/coordinator.py +++ b/custom_components/tibber_prices/coordinator.py @@ -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, {}) diff --git a/custom_components/tibber_prices/custom_translations/de.json b/custom_components/tibber_prices/custom_translations/de.json index ccbcc87..379fadf 100644 --- a/custom_components/tibber_prices/custom_translations/de.json +++ b/custom_components/tibber_prices/custom_translations/de.json @@ -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": { diff --git a/custom_components/tibber_prices/custom_translations/en.json b/custom_components/tibber_prices/custom_translations/en.json index 12a53a2..b424fd8 100644 --- a/custom_components/tibber_prices/custom_translations/en.json +++ b/custom_components/tibber_prices/custom_translations/en.json @@ -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": { diff --git a/custom_components/tibber_prices/custom_translations/nb.json b/custom_components/tibber_prices/custom_translations/nb.json index 5157447..d8cc2b1 100644 --- a/custom_components/tibber_prices/custom_translations/nb.json +++ b/custom_components/tibber_prices/custom_translations/nb.json @@ -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": { diff --git a/custom_components/tibber_prices/custom_translations/nl.json b/custom_components/tibber_prices/custom_translations/nl.json index d6099b1..8302c66 100644 --- a/custom_components/tibber_prices/custom_translations/nl.json +++ b/custom_components/tibber_prices/custom_translations/nl.json @@ -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": { diff --git a/custom_components/tibber_prices/custom_translations/sv.json b/custom_components/tibber_prices/custom_translations/sv.json index e26c754..669c69f 100644 --- a/custom_components/tibber_prices/custom_translations/sv.json +++ b/custom_components/tibber_prices/custom_translations/sv.json @@ -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": { diff --git a/custom_components/tibber_prices/entity_utils/colors.py b/custom_components/tibber_prices/entity_utils/colors.py index c07e7da..848b697 100644 --- a/custom_components/tibber_prices/entity_utils/colors.py +++ b/custom_components/tibber_prices/entity_utils/colors.py @@ -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)" diff --git a/custom_components/tibber_prices/entity_utils/icons.py b/custom_components/tibber_prices/entity_utils/icons.py index 200b8b4..7e2ec38 100644 --- a/custom_components/tibber_prices/entity_utils/icons.py +++ b/custom_components/tibber_prices/entity_utils/icons.py @@ -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). diff --git a/custom_components/tibber_prices/sensor/attributes.py b/custom_components/tibber_prices/sensor/attributes.py index ed4a244..d23a6ff 100644 --- a/custom_components/tibber_prices/sensor/attributes.py +++ b/custom_components/tibber_prices/sensor/attributes.py @@ -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) diff --git a/custom_components/tibber_prices/sensor/core.py b/custom_components/tibber_prices/sensor/core.py index 93d5b73..080ffdb 100644 --- a/custom_components/tibber_prices/sensor/core.py +++ b/custom_components/tibber_prices/sensor/core.py @@ -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 diff --git a/custom_components/tibber_prices/sensor/definitions.py b/custom_components/tibber_prices/sensor/definitions.py index f38f0f0..2267f9e 100644 --- a/custom_components/tibber_prices/sensor/definitions.py +++ b/custom_components/tibber_prices/sensor/definitions.py @@ -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, ) diff --git a/custom_components/tibber_prices/translations/de.json b/custom_components/tibber_prices/translations/de.json index e78a2fc..ac2d548 100644 --- a/custom_components/tibber_prices/translations/de.json +++ b/custom_components/tibber_prices/translations/de.json @@ -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" } diff --git a/custom_components/tibber_prices/translations/en.json b/custom_components/tibber_prices/translations/en.json index e7f03e4..594e42a 100644 --- a/custom_components/tibber_prices/translations/en.json +++ b/custom_components/tibber_prices/translations/en.json @@ -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" } diff --git a/custom_components/tibber_prices/translations/nb.json b/custom_components/tibber_prices/translations/nb.json index 1ba0016..9142399 100644 --- a/custom_components/tibber_prices/translations/nb.json +++ b/custom_components/tibber_prices/translations/nb.json @@ -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" } diff --git a/custom_components/tibber_prices/translations/nl.json b/custom_components/tibber_prices/translations/nl.json index 6d6f453..3259605 100644 --- a/custom_components/tibber_prices/translations/nl.json +++ b/custom_components/tibber_prices/translations/nl.json @@ -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" } diff --git a/custom_components/tibber_prices/translations/sv.json b/custom_components/tibber_prices/translations/sv.json index c9ba9d3..203ffa3 100644 --- a/custom_components/tibber_prices/translations/sv.json +++ b/custom_components/tibber_prices/translations/sv.json @@ -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" }