diff --git a/custom_components/tibber_prices/coordinator/time_service.py b/custom_components/tibber_prices/coordinator/time_service.py index 528cb4f..503eb1d 100644 --- a/custom_components/tibber_prices/coordinator/time_service.py +++ b/custom_components/tibber_prices/coordinator/time_service.py @@ -12,6 +12,33 @@ All datetime operations MUST go through TimeService to ensure: - Proper timezone handling (local time, not UTC) - Testability (mock time in one place) - Future time-travel feature support + +TIMER ARCHITECTURE: + +This integration uses three distinct timer mechanisms: + +1. **Timer #1: API Polling (DataUpdateCoordinator)** + - Runs every 15 minutes at a RANDOM offset (e.g., 10:04:37, 10:19:37, 10:34:37) + - Offset determined by when last API call completed + - Tracked via _last_coordinator_update for next poll prediction + - NO tolerance needed - offset variation is INTENTIONAL + - Purpose: Spread API load, avoid thundering herd problem + +2. **Timer #2: Entity Updates (quarter-hour boundaries)** + - Must trigger at EXACT boundaries (00, 15, 30, 45 minutes) + - Uses _BOUNDARY_TOLERANCE_SECONDS for HA scheduling jitter correction + - Scheduled via async_track_utc_time_change(minute=[0,15,30,45], second=0) + - If HA triggers at 15:00:01 → round to 15:00:00 (within ±2s tolerance) + - Purpose: Entity state updates reflect correct quarter-hour interval + +3. **Timer #3: Timing Sensors (30-second boundaries)** + - Must trigger at EXACT boundaries (0, 30 seconds) + - Uses _BOUNDARY_TOLERANCE_SECONDS for HA scheduling jitter correction + - Scheduled via async_track_utc_time_change(second=[0,30]) + - Purpose: Update countdown/time-to sensors + +CRITICAL: The tolerance is ONLY for Timer #2 and #3 to correct HA's +scheduling delays. It is NOT used for Timer #1's offset tracking. """ from __future__ import annotations @@ -42,6 +69,13 @@ _INTERVALS_PER_HOUR = 60 // _DEFAULT_INTERVAL_MINUTES # 4 _INTERVALS_PER_DAY = 24 * _INTERVALS_PER_HOUR # 96 # Rounding tolerance for boundary detection (±2 seconds) +# This handles Home Assistant's scheduling jitter for Timer #2 (entity updates) +# and Timer #3 (timing sensors). When HA schedules a callback for exactly +# 15:00:00 but actually triggers it at 15:00:01, this tolerance ensures we +# still recognize it as the 15:00:00 boundary. +# +# NOT used for Timer #1 (API polling), which intentionally runs at random +# offsets determined by last API call completion time. _BOUNDARY_TOLERANCE_SECONDS = 2 @@ -498,16 +532,17 @@ class TibberPricesTimeService: rounded_seconds = interval_start_seconds elif distance_to_next <= _BOUNDARY_TOLERANCE_SECONDS: # Near next interval start → use it + # CRITICAL: If rounding to next interval and it wraps to midnight (index 0), + # we need to increment to next day, not stay on same day! + if next_interval_index == 0: + # Rounding to midnight of NEXT day + return (target + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) rounded_seconds = next_interval_start_seconds else: # Not near any boundary → floor to current interval rounded_seconds = interval_start_seconds - # Handle midnight wrap - if rounded_seconds >= 24 * 3600: - rounded_seconds = 0 - - # Build rounded datetime + # Build rounded datetime (no midnight wrap needed here - handled above) hours = int(rounded_seconds // 3600) minutes = int((rounded_seconds % 3600) // 60) diff --git a/tests/test_time_service.py b/tests/test_time_service.py new file mode 100644 index 0000000..936cef9 --- /dev/null +++ b/tests/test_time_service.py @@ -0,0 +1,408 @@ +"""Tests for TimeService - critical time handling with boundary tolerance and DST.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest + +from custom_components.tibber_prices.coordinator.time_service import ( + TibberPricesTimeService, +) + +# ============================================================================= +# Quarter-Hour Rounding with Boundary Tolerance (CRITICAL) +# ============================================================================= + + +def test_round_to_quarter_exact_boundary() -> None: + """Test rounding when exactly on boundary.""" + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 0, 0, tzinfo=UTC)) + rounded = time_service.round_to_nearest_quarter() + assert rounded == datetime(2025, 11, 22, 14, 0, 0, tzinfo=UTC) + + +def test_round_to_quarter_within_tolerance_before_boundary() -> None: + """Test rounding when 2 seconds before boundary (within 2s tolerance).""" + # 14:59:58 → should round UP to 15:00:00 (within 2s tolerance) + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 59, 58, tzinfo=UTC)) + rounded = time_service.round_to_nearest_quarter() + assert rounded == datetime(2025, 11, 22, 15, 0, 0, tzinfo=UTC) + + +def test_round_to_quarter_exactly_2s_before_boundary() -> None: + """Test rounding when exactly 2 seconds before boundary (edge of 2s tolerance).""" + # 14:59:58 → should round UP to 15:00:00 (exactly 2s tolerance) + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 59, 58, tzinfo=UTC)) + rounded = time_service.round_to_nearest_quarter() + assert rounded == datetime(2025, 11, 22, 15, 0, 0, tzinfo=UTC) + + +def test_round_to_quarter_just_outside_tolerance_before() -> None: + """Test rounding when 3 seconds before boundary (outside 2s tolerance).""" + # 14:59:57 → should STAY at 14:45:00 (>2s away from boundary) + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 59, 57, tzinfo=UTC)) + rounded = time_service.round_to_nearest_quarter() + assert rounded == datetime(2025, 11, 22, 14, 45, 0, tzinfo=UTC) + + +def test_round_to_quarter_within_2s_after_boundary() -> None: + """Test rounding when 1 second after boundary (within 2s tolerance).""" + # 15:00:01 → should round DOWN to 15:00:00 (within 2s tolerance) + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 15, 0, 1, tzinfo=UTC)) + rounded = time_service.round_to_nearest_quarter() + assert rounded == datetime(2025, 11, 22, 15, 0, 0, tzinfo=UTC) + + +def test_round_to_quarter_exactly_2s_after_boundary() -> None: + """Test rounding when exactly 2 seconds after boundary (edge of 2s tolerance).""" + # 15:00:02 → should round DOWN to 15:00:00 (exactly 2s tolerance) + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 15, 0, 2, tzinfo=UTC)) + rounded = time_service.round_to_nearest_quarter() + assert rounded == datetime(2025, 11, 22, 15, 0, 0, tzinfo=UTC) + + +def test_round_to_quarter_just_outside_tolerance_after() -> None: + """Test rounding when 3 seconds after boundary (outside 2s tolerance).""" + # 15:00:03 → should STAY at 15:00:00 (>2s away from next boundary) + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 15, 0, 3, tzinfo=UTC)) + rounded = time_service.round_to_nearest_quarter() + assert rounded == datetime(2025, 11, 22, 15, 0, 0, tzinfo=UTC) + + +def test_round_to_quarter_mid_interval() -> None: + """Test rounding when in middle of interval (far from boundaries).""" + # 14:37:30 → should floor to 14:30:00 (not near any boundary) + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 37, 30, tzinfo=UTC)) + rounded = time_service.round_to_nearest_quarter() + assert rounded == datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC) + + +def test_round_to_quarter_microseconds_before_boundary() -> None: + """Test rounding with microseconds just before boundary.""" + # 14:59:59.999999 → should round UP to 15:00:00 + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 59, 59, 999999, tzinfo=UTC)) + rounded = time_service.round_to_nearest_quarter() + assert rounded == datetime(2025, 11, 22, 15, 0, 0, tzinfo=UTC) + + +def test_round_to_quarter_microseconds_after_boundary() -> None: + """Test rounding with microseconds just after boundary.""" + # 15:00:00.000001 → should round DOWN to 15:00:00 + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 15, 0, 0, 1, tzinfo=UTC)) + rounded = time_service.round_to_nearest_quarter() + assert rounded == datetime(2025, 11, 22, 15, 0, 0, tzinfo=UTC) + + +def test_round_to_quarter_all_boundaries() -> None: + """Test rounding at all four quarter-hour boundaries.""" + # Test :00, :15, :30, :45 boundaries + boundaries = [ + (datetime(2025, 11, 22, 14, 0, 1, tzinfo=UTC), datetime(2025, 11, 22, 14, 0, 0, tzinfo=UTC)), + (datetime(2025, 11, 22, 14, 15, 1, tzinfo=UTC), datetime(2025, 11, 22, 14, 15, 0, tzinfo=UTC)), + (datetime(2025, 11, 22, 14, 30, 1, tzinfo=UTC), datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC)), + (datetime(2025, 11, 22, 14, 45, 1, tzinfo=UTC), datetime(2025, 11, 22, 14, 45, 0, tzinfo=UTC)), + ] + + for input_time, expected in boundaries: + time_service = TibberPricesTimeService(input_time) + rounded = time_service.round_to_nearest_quarter() + assert rounded == expected, f"Failed for {input_time}: got {rounded}, expected {expected}" + + +def test_round_to_quarter_midnight_boundary_before() -> None: + """Test rounding just before midnight (critical edge case).""" + # 23:59:59 → should round to midnight 00:00:00 of NEXT day (1 second away, within tolerance) + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 23, 59, 59, tzinfo=UTC)) + rounded = time_service.round_to_nearest_quarter() + # Within 2s of midnight boundary, rounds to 00:00:00 of NEXT day (Nov 23) + assert rounded == datetime(2025, 11, 23, 0, 0, 0, tzinfo=UTC) + + +def test_round_to_quarter_midnight_boundary_at() -> None: + """Test rounding exactly at midnight.""" + # 00:00:00 → should stay 00:00:00 + time_service = TibberPricesTimeService(datetime(2025, 11, 23, 0, 0, 0, tzinfo=UTC)) + rounded = time_service.round_to_nearest_quarter() + assert rounded == datetime(2025, 11, 23, 0, 0, 0, tzinfo=UTC) + + +def test_round_to_quarter_first_interval_of_day() -> None: + """Test rounding in first interval of day.""" + # 00:07:30 → should floor to 00:00:00 + time_service = TibberPricesTimeService(datetime(2025, 11, 23, 0, 7, 30, tzinfo=UTC)) + rounded = time_service.round_to_nearest_quarter() + assert rounded == datetime(2025, 11, 23, 0, 0, 0, tzinfo=UTC) + + +def test_round_to_quarter_last_interval_of_day() -> None: + """Test rounding in last interval of day (23:45-00:00).""" + # 23:52:30 → should floor to 23:45:00 (same day, not near boundary) + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 23, 52, 30, tzinfo=UTC)) + rounded = time_service.round_to_nearest_quarter() + assert rounded == datetime(2025, 11, 22, 23, 45, 0, tzinfo=UTC) + + +def test_round_to_quarter_midnight_wrap_exactly_2s_before() -> None: + """Test rounding exactly 2 seconds before midnight (edge of 2s tolerance).""" + # 23:59:58 → should round to midnight 00:00:00 of NEXT day (exactly 2s tolerance) + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 23, 59, 58, tzinfo=UTC)) + rounded = time_service.round_to_nearest_quarter() + assert rounded == datetime(2025, 11, 23, 0, 0, 0, tzinfo=UTC) + + +def test_round_to_quarter_midnight_wrap_outside_tolerance() -> None: + """Test rounding 3 seconds before midnight (outside 2s tolerance).""" + # 23:59:57 → should STAY at 23:45:00 (>2s away from boundary) + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 23, 59, 57, tzinfo=UTC)) + rounded = time_service.round_to_nearest_quarter() + assert rounded == datetime(2025, 11, 22, 23, 45, 0, tzinfo=UTC) + + +def test_round_to_quarter_midnight_wrap_with_microseconds() -> None: + """Test rounding with microseconds just before midnight.""" + # 23:59:59.999999 → should round to midnight 00:00:00 of NEXT day + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 23, 59, 59, 999999, tzinfo=UTC)) + rounded = time_service.round_to_nearest_quarter() + assert rounded == datetime(2025, 11, 23, 0, 0, 0, tzinfo=UTC) + + +# ============================================================================= +# DST Handling (CRITICAL for 23h/25h days) +# ============================================================================= + + +def test_get_expected_intervals_standard_day() -> None: + """Test interval count on standard 24-hour day.""" + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 12, 0, 0, tzinfo=UTC)) + # Standard day: 24 hours x 4 intervals/hour = 96 intervals + count = time_service.get_expected_intervals_for_day(datetime(2025, 11, 22, tzinfo=UTC).date()) + assert count == 96 + + +@pytest.mark.skip(reason="DST handling requires local timezone setup (Europe/Berlin) - tested in integration tests") +def test_get_expected_intervals_spring_dst_23h_day() -> None: + """Test interval count on Spring DST day (23 hours, clock jumps forward).""" + # In Europe: Last Sunday of March, 02:00 → 03:00 (23-hour day) + # 2025-03-30 is the last Sunday of March + # NOTE: This test requires time_service to use Europe/Berlin timezone, not UTC + time_service = TibberPricesTimeService(datetime(2025, 3, 30, 12, 0, 0, tzinfo=UTC)) + count = time_service.get_expected_intervals_for_day(datetime(2025, 3, 30, tzinfo=UTC).date()) + # 23 hours x 4 intervals/hour = 92 intervals + assert count == 92 + + +@pytest.mark.skip(reason="DST handling requires local timezone setup (Europe/Berlin) - tested in integration tests") +def test_get_expected_intervals_fall_dst_25h_day() -> None: + """Test interval count on Fall DST day (25 hours, clock jumps backward).""" + # In Europe: Last Sunday of October, 03:00 → 02:00 (25-hour day) + # 2025-10-26 is the last Sunday of October + # NOTE: This test requires time_service to use Europe/Berlin timezone, not UTC + time_service = TibberPricesTimeService(datetime(2025, 10, 26, 12, 0, 0, tzinfo=UTC)) + count = time_service.get_expected_intervals_for_day(datetime(2025, 10, 26, tzinfo=UTC).date()) + # 25 hours x 4 intervals/hour = 100 intervals + assert count == 100 + + +# ============================================================================= +# Day Boundaries +# ============================================================================= + + +def test_get_day_boundaries_today() -> None: + """Test day boundaries for 'today'.""" + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC)) + start, end = time_service.get_day_boundaries("today") + + # Start should be midnight today + assert start.hour == 0 + assert start.minute == 0 + assert start.second == 0 + assert start.date() == datetime(2025, 11, 22, tzinfo=UTC).date() + + # End should be midnight tomorrow + assert end.hour == 0 + assert end.minute == 0 + assert end.second == 0 + assert end.date() == datetime(2025, 11, 23, tzinfo=UTC).date() + + +def test_get_day_boundaries_yesterday() -> None: + """Test day boundaries for 'yesterday'.""" + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC)) + start, end = time_service.get_day_boundaries("yesterday") + + # Start should be midnight yesterday + assert start.date() == datetime(2025, 11, 21, tzinfo=UTC).date() + + # End should be midnight today + assert end.date() == datetime(2025, 11, 22, tzinfo=UTC).date() + + +def test_get_day_boundaries_tomorrow() -> None: + """Test day boundaries for 'tomorrow'.""" + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC)) + start, end = time_service.get_day_boundaries("tomorrow") + + # Start should be midnight tomorrow + assert start.date() == datetime(2025, 11, 23, tzinfo=UTC).date() + + # End should be midnight day after tomorrow + assert end.date() == datetime(2025, 11, 24, tzinfo=UTC).date() + + +# ============================================================================= +# Interval Offset Calculations +# ============================================================================= + + +def test_get_interval_offset_current() -> None: + """Test offset=0 returns current interval start.""" + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 37, 23, tzinfo=UTC)) + result = time_service.get_interval_offset_time(0) + assert result == datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC) + + +def test_get_interval_offset_next() -> None: + """Test offset=1 returns next interval start.""" + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 37, 23, tzinfo=UTC)) + result = time_service.get_interval_offset_time(1) + assert result == datetime(2025, 11, 22, 14, 45, 0, tzinfo=UTC) + + +def test_get_interval_offset_previous() -> None: + """Test offset=-1 returns previous interval start.""" + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 37, 23, tzinfo=UTC)) + result = time_service.get_interval_offset_time(-1) + assert result == datetime(2025, 11, 22, 14, 15, 0, tzinfo=UTC) + + +def test_get_interval_offset_multiple_forward() -> None: + """Test multiple intervals forward.""" + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 37, 23, tzinfo=UTC)) + result = time_service.get_interval_offset_time(4) # +1 hour + assert result == datetime(2025, 11, 22, 15, 30, 0, tzinfo=UTC) + + +def test_get_interval_offset_cross_hour_boundary() -> None: + """Test offset crossing hour boundary.""" + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 52, 0, tzinfo=UTC)) + # Current: 14:45, +1 = 15:00 + result = time_service.get_interval_offset_time(1) + assert result == datetime(2025, 11, 22, 15, 0, 0, tzinfo=UTC) + + +def test_get_interval_offset_cross_day_boundary() -> None: + """Test offset crossing midnight.""" + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 23, 52, 0, tzinfo=UTC)) + # Current: 23:45, +1 = 00:00 next day + result = time_service.get_interval_offset_time(1) + assert result == datetime(2025, 11, 23, 0, 0, 0, tzinfo=UTC) + + +# ============================================================================= +# Time Comparison Helpers +# ============================================================================= + + +def test_is_current_interval_true() -> None: + """Test is_current_interval returns True when time is in interval.""" + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 37, 0, tzinfo=UTC)) + start = datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC) + end = datetime(2025, 11, 22, 14, 45, 0, tzinfo=UTC) + assert time_service.is_current_interval(start, end) is True + + +def test_is_current_interval_false_before() -> None: + """Test is_current_interval returns False when time is before interval.""" + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 29, 0, tzinfo=UTC)) + start = datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC) + end = datetime(2025, 11, 22, 14, 45, 0, tzinfo=UTC) + assert time_service.is_current_interval(start, end) is False + + +def test_is_current_interval_false_after() -> None: + """Test is_current_interval returns False when time is after interval.""" + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 45, 0, tzinfo=UTC)) + start = datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC) + end = datetime(2025, 11, 22, 14, 45, 0, tzinfo=UTC) + # end is exclusive, so exactly at end is False + assert time_service.is_current_interval(start, end) is False + + +def test_is_current_interval_at_start() -> None: + """Test is_current_interval returns True when exactly at start.""" + time_service = TibberPricesTimeService(datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC)) + start = datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC) + end = datetime(2025, 11, 22, 14, 45, 0, tzinfo=UTC) + # start is inclusive + assert time_service.is_current_interval(start, end) is True + + +# ============================================================================= +# Time-Travel (Reference Time Injection) +# ============================================================================= + + +def test_reference_time_consistency() -> None: + """Test that reference time stays consistent throughout service lifetime.""" + ref_time = datetime(2025, 11, 22, 14, 37, 23, tzinfo=UTC) + time_service = TibberPricesTimeService(ref_time) + + # Multiple calls should return same value + assert time_service.now() == ref_time + assert time_service.now() == ref_time + assert time_service.now() == ref_time + + +def test_time_travel_simulation() -> None: + """Test time-travel capability (inject specific time).""" + # Simulate being at a specific moment in the past + past_time = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + time_service = TibberPricesTimeService(past_time) + + assert time_service.now() == past_time + assert time_service.now().year == 2024 + assert time_service.now().month == 1 + + +# ============================================================================= +# Minutes Calculation and Rounding +# ============================================================================= + + +def test_minutes_until_rounded_standard_rounding() -> None: + """Test minutes_until_rounded uses standard rounding (0.5 rounds up).""" + ref_time = datetime(2025, 11, 22, 14, 0, 0, tzinfo=UTC) + time_service = TibberPricesTimeService(ref_time) + + # 44.2 minutes → 44 + future = datetime(2025, 11, 22, 14, 44, 12, tzinfo=UTC) + assert time_service.minutes_until_rounded(future) == 44 + + # 44.5 minutes → 45 (rounds up) + future = datetime(2025, 11, 22, 14, 44, 30, tzinfo=UTC) + assert time_service.minutes_until_rounded(future) == 45 + + # 44.7 minutes → 45 + future = datetime(2025, 11, 22, 14, 44, 42, tzinfo=UTC) + assert time_service.minutes_until_rounded(future) == 45 + + +def test_minutes_until_rounded_zero() -> None: + """Test minutes_until_rounded returns 0 for past times.""" + ref_time = datetime(2025, 11, 22, 14, 0, 0, tzinfo=UTC) + time_service = TibberPricesTimeService(ref_time) + + past = datetime(2025, 11, 22, 13, 0, 0, tzinfo=UTC) + assert time_service.minutes_until_rounded(past) == -60 + + +def test_minutes_until_rounded_string_input() -> None: + """Test minutes_until_rounded accepts ISO string input.""" + ref_time = datetime(2025, 11, 22, 14, 0, 0, tzinfo=UTC) + time_service = TibberPricesTimeService(ref_time) + + # Should parse and calculate + result = time_service.minutes_until_rounded("2025-11-22T15:00:00+00:00") + assert result == 60