Skip to main content
Version: v0.22.1

Timer Architecture

This document explains the timer/scheduler system in the Tibber Prices integration - what runs when, why, and how they coordinate.

Overview​

The integration uses three independent timer mechanisms for different purposes:

TimerTypeIntervalPurposeTrigger Method
Timer #1HA built-in15 minutesAPI data updatesDataUpdateCoordinator
Timer #2Custom:00, :15, :30, :45Entity state refreshasync_track_utc_time_change()
Timer #3CustomEvery minuteCountdown/progressasync_track_utc_time_change()

Key principle: Timer #1 (HA) controls data fetching, Timer #2 controls entity updates, Timer #3 controls timing displays.


Timer #1: DataUpdateCoordinator (HA Built-in)​

File: coordinator/core.py β†’ TibberPricesDataUpdateCoordinator

Type: Home Assistant's built-in DataUpdateCoordinator with UPDATE_INTERVAL = 15 minutes

What it is:

  • HA provides this timer system automatically when you inherit from DataUpdateCoordinator
  • Triggers _async_update_data() method every 15 minutes
  • Not synchronized to clock boundaries (each installation has different start time)

Purpose: Check if fresh API data is needed, fetch if necessary

What it does:

async def _async_update_data(self) -> TibberPricesData:
# Step 1: Check midnight turnover FIRST (prevents race with Timer #2)
if self._check_midnight_turnover_needed(dt_util.now()):
await self._perform_midnight_data_rotation(dt_util.now())
# Notify ALL entities after midnight turnover
return self.data # Early return

# Step 2: Check if we need tomorrow data (after 13:00)
if self._should_update_price_data() == "tomorrow_check":
await self._fetch_and_update_data() # Fetch from API
return self.data

# Step 3: Use cached data (fast path - most common)
return self.data

Load Distribution:

  • Each HA installation starts Timer #1 at different times β†’ natural distribution
  • Tomorrow data check adds 0-30s random delay β†’ prevents "thundering herd" on Tibber API
  • Result: API load spread over ~30 minutes instead of all at once

Midnight Coordination:

  • Atomic check: _check_midnight_turnover_needed(now) compares dates only (no side effects)
  • If midnight turnover needed β†’ performs it and returns early
  • Timer #2 will see turnover already done and skip gracefully

Why we use HA's timer:

  • Automatic restart after HA restart
  • Built-in retry logic for temporary failures
  • Standard HA integration pattern
  • Handles backpressure (won't queue up if previous update still running)

Timer #2: Quarter-Hour Refresh (Custom)​

File: coordinator/listeners.py β†’ ListenerManager.schedule_quarter_hour_refresh()

Type: Custom timer using async_track_utc_time_change(minute=[0, 15, 30, 45], second=0)

Purpose: Update time-sensitive entity states at interval boundaries without waiting for API poll

Problem it solves:

  • Timer #1 runs every 15 minutes but NOT synchronized to clock (:03, :18, :33, :48)
  • Current price changes at :00, :15, :30, :45 β†’ entities would show stale data for up to 15 minutes
  • Example: 14:00 new price, but Timer #1 ran at 13:58 β†’ next update at 14:13 β†’ users see old price until 14:13

What it does:

async def _handle_quarter_hour_refresh(self, now: datetime) -> None:
# Step 1: Check midnight turnover (coordinates with Timer #1)
if self._check_midnight_turnover_needed(now):
# Timer #1 might have already done this β†’ atomic check handles it
await self._perform_midnight_data_rotation(now)
# Notify ALL entities after midnight turnover
return

# Step 2: Normal quarter-hour refresh (most common path)
# Only notify time-sensitive entities (current_interval_price, etc.)
self._listener_manager.async_update_time_sensitive_listeners()

Smart Boundary Tolerance:

  • Uses round_to_nearest_quarter_hour() with Β±2 second tolerance
  • HA may schedule timer at 14:59:58 β†’ rounds to 15:00:00 (shows new interval)
  • HA restart at 14:59:30 β†’ stays at 14:45:00 (shows current interval)
  • See Architecture for details

Absolute Time Scheduling:

  • async_track_utc_time_change() plans for all future boundaries (15:00, 15:15, 15:30, ...)
  • NOT relative delays ("in 15 minutes")
  • If triggered at 14:59:58 β†’ next trigger is 15:15:00, NOT 15:00:00 (prevents double updates)

Which entities listen:

  • All sensors that depend on "current interval" (e.g., current_interval_price, next_interval_price)
  • Binary sensors that check "is now in period?" (e.g., best_price_period_active)
  • ~50-60 entities out of 120+ total

Why custom timer:

  • HA's built-in coordinator doesn't support exact boundary timing
  • We need absolute time triggers, not periodic intervals
  • Allows fast entity updates without expensive data transformation

Timer #3: Minute Refresh (Custom)​

File: coordinator/listeners.py β†’ ListenerManager.schedule_minute_refresh()

Type: Custom timer using async_track_utc_time_change(second=0) (every minute)

Purpose: Update countdown and progress sensors for smooth UX

What it does:

async def _handle_minute_refresh(self, now: datetime) -> None:
# Only notify minute-update entities
# No data fetching, no transformation, no midnight handling
self._listener_manager.async_update_minute_listeners()

Which entities listen:

  • best_price_remaining_minutes - Countdown timer
  • peak_price_remaining_minutes - Countdown timer
  • best_price_progress - Progress bar (0-100%)
  • peak_price_progress - Progress bar (0-100%)
  • ~10 entities total

Why custom timer:

  • Users want smooth countdowns (not jumping 15 minutes at a time)
  • Progress bars need minute-by-minute updates
  • Very lightweight (no data processing, just state recalculation)

Why NOT every second:

  • Minute precision sufficient for countdown UX
  • Reduces CPU load (60Γ— fewer updates than seconds)
  • Home Assistant best practice (avoid sub-minute updates)

Listener Pattern (Python/HA Terminology)​

Your question: "Sind Timer fΓΌr dich eigentlich 'Listener'?"

Answer: In Home Assistant terminology:

  • Timer = The mechanism that triggers at specific times (async_track_utc_time_change)
  • Listener = A callback function that gets called when timer triggers
  • Observer Pattern = Entities register callbacks, coordinator notifies them

How it works:

# Entity registers a listener callback
class TibberPricesSensor(CoordinatorEntity):
async def async_added_to_hass(self):
# Register this entity's update callback
self._remove_listener = self.coordinator.async_add_time_sensitive_listener(
self._handle_coordinator_update
)

# Coordinator maintains list of listeners
class ListenerManager:
def __init__(self):
self._time_sensitive_listeners = [] # List of callbacks

def async_add_time_sensitive_listener(self, callback):
self._time_sensitive_listeners.append(callback)

def async_update_time_sensitive_listeners(self):
# Timer triggered β†’ notify all listeners
for callback in self._time_sensitive_listeners:
callback() # Entity updates itself

Why this pattern:

  • Decouples timer logic from entity logic
  • One timer can notify many entities efficiently
  • Entities can unregister when removed (cleanup)
  • Standard HA pattern for coordinator-based integrations

Timer Coordination Scenarios​

Scenario 1: Normal Operation (No Midnight)​

14:00:00 β†’ Timer #2 triggers
β†’ Update time-sensitive entities (current price changed)
β†’ 60 entities updated (~5ms)

14:03:12 β†’ Timer #1 triggers (HA's 15-min cycle)
β†’ Check if tomorrow data needed (no, still cached)
β†’ Return cached data (fast path, ~2ms)

14:15:00 β†’ Timer #2 triggers
β†’ Update time-sensitive entities
β†’ 60 entities updated (~5ms)

14:16:00 β†’ Timer #3 triggers
β†’ Update countdown/progress entities
β†’ 10 entities updated (~1ms)

Key observation: Timer #1 and Timer #2 run independently, no conflicts.

Scenario 2: Midnight Turnover​

23:45:12 β†’ Timer #1 triggers
β†’ Check midnight: current_date=2025-11-17, last_check=2025-11-17
β†’ No turnover needed
β†’ Return cached data

00:00:00 β†’ Timer #2 triggers FIRST (synchronized to midnight)
β†’ Check midnight: current_date=2025-11-18, last_check=2025-11-17
β†’ Turnover needed! Perform rotation, save cache
β†’ _last_midnight_check = 2025-11-18
β†’ Notify ALL entities

00:03:12 β†’ Timer #1 triggers (its regular cycle)
β†’ Check midnight: current_date=2025-11-18, last_check=2025-11-18
β†’ Turnover already done β†’ skip
β†’ Return existing data (fast path)

Key observation: Atomic date comparison prevents double-turnover, whoever runs first wins.

Scenario 3: Tomorrow Data Check (After 13:00)​

13:00:00 β†’ Timer #2 triggers
β†’ Normal quarter-hour refresh
β†’ Update time-sensitive entities

13:03:12 β†’ Timer #1 triggers
β†’ Check tomorrow data: missing or invalid
β†’ Fetch from Tibber API (~300ms)
β†’ Transform data (~200ms)
β†’ Calculate periods (~100ms)
β†’ Notify ALL entities (new data available)

13:15:00 β†’ Timer #2 triggers
β†’ Normal quarter-hour refresh (uses newly fetched data)
β†’ Update time-sensitive entities

Key observation: Timer #1 does expensive work (API + transform), Timer #2 does cheap work (entity notify).


Why We Keep HA's Timer (Timer #1)​

Your question: "warum wir den HA timer trotzdem weiter benutzen, da er ja fΓΌr uns unkontrollierte aktualisierte Γ€nderungen triggert"

Answer: You're correct that it's not synchronized, but that's actually intentional:

Reason 1: Load Distribution on Tibber API​

If all installations used synchronized timers:

  • ❌ Everyone fetches at 13:00:00 β†’ Tibber API overload
  • ❌ Everyone fetches at 14:00:00 β†’ Tibber API overload
  • ❌ "Thundering herd" problem

With HA's unsynchronized timer:

  • βœ… Installation A: 13:03:12, 13:18:12, 13:33:12, ...
  • βœ… Installation B: 13:07:45, 13:22:45, 13:37:45, ...
  • βœ… Installation C: 13:11:28, 13:26:28, 13:41:28, ...
  • βœ… Natural distribution over ~30 minutes
  • βœ… Plus: Random 0-30s delay on tomorrow checks

Result: API load spread evenly, no spikes.

Reason 2: What Timer #1 Actually Checks​

Timer #1 does NOT blindly update. It checks:

def _should_update_price_data(self) -> str:
# Check 1: Do we have tomorrow data? (only relevant after ~13:00)
if tomorrow_missing or tomorrow_invalid:
return "tomorrow_check" # Fetch needed

# Check 2: Is cache still valid?
if cache_valid:
return "cached" # No fetch needed (most common!)

# Check 3: Has enough time passed?
if time_since_last_update < threshold:
return "cached" # Too soon, skip fetch

return "update_needed" # Rare case

Most Timer #1 cycles: Fast path (~2ms), no API call, just returns cached data.

API fetch only when:

  • Tomorrow data missing/invalid (after 13:00)
  • Cache expired (midnight turnover)
  • Explicit user refresh

Reason 3: HA Integration Best Practices​

  • βœ… Standard HA pattern: DataUpdateCoordinator is recommended by HA docs
  • βœ… Automatic retry logic for temporary API failures
  • βœ… Backpressure handling (won't queue updates if previous still running)
  • βœ… Developer tools integration (users can manually trigger refresh)
  • βœ… Diagnostics integration (shows last update time, success/failure)

What We DO Synchronize​

  • βœ… Timer #2: Entity state updates at exact boundaries (user-visible)
  • βœ… Timer #3: Countdown/progress at exact minutes (user-visible)
  • ❌ Timer #1: API fetch timing (invisible to user, distribution wanted)

Performance Characteristics​

Timer #1 (DataUpdateCoordinator)​

  • Triggers: Every 15 minutes (unsynchronized)
  • Fast path: ~2ms (cache check, return existing data)
  • Slow path: ~600ms (API fetch + transform + calculate)
  • Frequency: ~96 times/day
  • API calls: ~1-2 times/day (cached otherwise)

Timer #2 (Quarter-Hour Refresh)​

  • Triggers: 96 times/day (exact boundaries)
  • Processing: ~5ms (notify 60 entities)
  • No API calls: Uses cached/transformed data
  • No transformation: Just entity state updates

Timer #3 (Minute Refresh)​

  • Triggers: 1440 times/day (every minute)
  • Processing: ~1ms (notify 10 entities)
  • No API calls: No data processing at all
  • Lightweight: Just countdown math

Total CPU budget: ~15 seconds/day for all timers combined.


Debugging Timer Issues​

Check Timer #1 (HA Coordinator)​

# Enable debug logging
_LOGGER.setLevel(logging.DEBUG)

# Watch for these log messages:
"Fetching data from API (reason: tomorrow_check)" # API call
"Using cached data (no update needed)" # Fast path
"Midnight turnover detected (Timer #1)" # Turnover

Check Timer #2 (Quarter-Hour)​

# Watch coordinator logs:
"Updated 60 time-sensitive entities at quarter-hour boundary" # Normal
"Midnight turnover detected (Timer #2)" # Turnover

Check Timer #3 (Minute)​

# Watch coordinator logs:
"Updated 10 minute-update entities" # Every minute

Common Issues​

  1. Timer #2 not triggering:

    • Check: schedule_quarter_hour_refresh() called in __init__?
    • Check: _quarter_hour_timer_cancel properly stored?
  2. Double updates at midnight:

    • Should NOT happen (atomic coordination)
    • Check: Both timers use same date comparison logic?
  3. API overload:

    • Check: Random delay working? (0-30s jitter on tomorrow check)
    • Check: Cache validation logic correct?


Summary​

Three independent timers:

  1. Timer #1 (HA built-in, 15 min, unsynchronized) β†’ Data fetching (when needed)
  2. Timer #2 (Custom, :00/:15/:30/:45) β†’ Entity state updates (always)
  3. Timer #3 (Custom, every minute) β†’ Countdown/progress (always)

Key insights:

  • Timer #1 unsynchronized = good (load distribution on API)
  • Timer #2 synchronized = good (user sees correct data immediately)
  • Timer #3 synchronized = good (smooth countdown UX)
  • All three coordinate gracefully (atomic midnight checks, no conflicts)

"Listener" terminology:

  • Timer = mechanism that triggers
  • Listener = callback that gets called
  • Observer pattern = entities register, coordinator notifies