diff --git a/.github/actions/setup-python/action.yml b/.github/actions/setup-python/action.yml index d2da08e..85cf343 100644 --- a/.github/actions/setup-python/action.yml +++ b/.github/actions/setup-python/action.yml @@ -8,14 +8,14 @@ runs: using: composite steps: - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ inputs.python-version }} - name: Display Python version run: python3 -c "import sys; print(sys.version)" shell: bash - name: Cache pip - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cache/pip diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 6274588..30dc28c 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -4,10 +4,10 @@ runs: using: composite steps: - name: Install dependencies - run: sudo apt install libffi-dev libncurses5-dev zlib1g zlib1g-dev libssl-dev libreadline-dev libbz2-dev libsqlite3-dev + run: sudo apt install libffi-dev libncurses5-dev zlib1g zlib1g-dev libssl-dev libreadline-dev libbz2-dev libsqlite3-dev liblzma-dev shell: bash - name: asdf_install - uses: asdf-vm/actions/install@v3 + uses: asdf-vm/actions/install@v4 - name: Install Python modules run: | pip install -r requirements.test.txt diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6b47fef..2af94a5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -20,15 +20,15 @@ jobs: name: Build docs runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 sparse-checkout: | _docs - name: asdf_install - uses: asdf-vm/actions/install@v3 + uses: asdf-vm/actions/install@v4 - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - - uses: actions/cache@v4 + - uses: actions/cache@v5 with: key: mkdocs-material-${{ env.cache_id }} path: .cache @@ -44,15 +44,15 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 sparse-checkout: | _docs - name: asdf_install - uses: asdf-vm/actions/install@v3 + uses: asdf-vm/actions/install@v4 - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - - uses: actions/cache@v4 + - uses: actions/cache@v5 with: key: mkdocs-material-${{ env.cache_id }} path: .cache diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml index 90a6850..4ce43d9 100644 --- a/.github/workflows/issues.yml +++ b/.github/workflows/issues.yml @@ -12,7 +12,7 @@ jobs: pull-requests: write discussions: write steps: - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: days-before-issue-stale: 30 days-before-issue-close: 14 @@ -23,7 +23,7 @@ jobs: days-before-pr-close: -1 exempt-issue-labels: enhancement repo-token: ${{ secrets.GITHUB_TOKEN }} - - uses: dessant/lock-threads@v5 + - uses: dessant/lock-threads@v6 with: issue-inactive-days: '30' issue-comment: 'This issue has been automatically locked due to inactivity for more than 30 days. This is to reduce noise for original parties. Please open a new issue for related bugs.' diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 02ef40d..60d1e10 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -29,7 +29,7 @@ jobs: name: Validate runs-on: "ubuntu-latest" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: "home-assistant/actions/hassfest@master" - name: HACS Action uses: "hacs/action@main" @@ -45,7 +45,7 @@ jobs: python-version: ["3.13", "3.14"] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup @@ -66,11 +66,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: asdf_install - uses: asdf-vm/actions/install@v3 + uses: asdf-vm/actions/install@v4 - name: Install dependencies run: npm ci - name: Release @@ -79,7 +79,7 @@ jobs: run: npm run release - name: Merge main into develop if: ${{ github.repository_owner == 'BottlecapDave' && github.ref == 'refs/heads/main' }} - uses: devmasx/merge-branch@master + uses: devmasx/merge-branch@1.4.0 with: type: now message: "chore: Merged main into develop" @@ -88,7 +88,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} - name: Merge main into beta if: ${{ github.repository_owner == 'BottlecapDave' && github.ref == 'refs/heads/beta' }} - uses: devmasx/merge-branch@master + uses: devmasx/merge-branch@1.4.0 with: type: now message: "chore: Merged main into beta" diff --git a/.tool-versions b/.tool-versions index c50d650..4c5462d 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ nodejs 22.4.0 -python 3.12.4 \ No newline at end of file +python 3.13.7 \ No newline at end of file diff --git a/custom_components/target_timeframes/entities/__init__.py b/custom_components/target_timeframes/entities/__init__.py index aa86535..7e6a883 100644 --- a/custom_components/target_timeframes/entities/__init__.py +++ b/custom_components/target_timeframes/entities/__init__.py @@ -44,6 +44,24 @@ def is_target_timeframe_complete_in_period(current_date: datetime, start_time: d target_timeframes[-1]["end"] <= current_date ) +def _adjust_to_current_timezone(context: str, current_date: datetime, target_date: str): + if current_date.tzinfo is not None: + tz = current_date.tzinfo + if hasattr(tz, 'key'): + zone = tz.key + elif hasattr(tz, 'zone'): + zone = tz.zone + else: + zone = None + if zone: + _LOGGER.debug(f'{context} - Localizing target to timezone: {zone}; target before localization: {target_date}') + return target_date.replace(tzinfo=ZoneInfo(zone)) + else: + _LOGGER.debug(f'{context} - Unable to determine timezone from current date tzinfo, falling back to using current date tzinfo for target; target before localization: {target_date}') + return target_date.replace(tzinfo=current_date.tzinfo) + + return target_date + def get_start_and_end_times(current_date: datetime, target_start_time: str, target_end_time: str, minimum_slot_minutes = None, context: str = None): _LOGGER.debug(f'{context} - Current date: {current_date}; Target start time: {target_start_time}; Target end time: {target_end_time}; Minimum slot minutes: {minimum_slot_minutes}') if (target_start_time is not None): @@ -56,11 +74,15 @@ def get_start_and_end_times(current_date: datetime, target_start_time: str, targ else: target_end = parse_datetime(current_date.strftime(f"%Y-%m-%dT00:00:00Z")) + timedelta(days=1) + target_start = _adjust_to_current_timezone(context, current_date, target_start) + target_end = _adjust_to_current_timezone(context, current_date, target_end) + if (target_start >= target_end): - _LOGGER.debug(f'{context} - {target_start} is equal or after {target_end}, so setting target end to tomorrow') if target_start > current_date: + _LOGGER.debug(f'{context} - {target_start} is after {current_date}, so setting target start to previous day') target_start = target_start - timedelta(days=1) else: + _LOGGER.debug(f'{context} - {target_start} is before {current_date}, so setting target end to next day') target_end = target_end + timedelta(days=1) if (minimum_slot_minutes is not None and target_start < current_date and current_date < target_end): @@ -80,22 +102,8 @@ def get_start_and_end_times(current_date: datetime, target_start_time: str, targ target_end = target_end + timedelta(days=1) # Make sure we have the correct timezone, as our new dates might have shifted due to daylight savings - if current_date.tzinfo is not None: - tz = current_date.tzinfo - if hasattr(tz, 'key'): - zone = tz.key - elif hasattr(tz, 'zone'): - zone = tz.zone - else: - zone = None - if zone: - _LOGGER.debug(f'{context} - Localizing target start and end to timezone: {zone}; target start before localization: {target_start}; target end before localization: {target_end}') - target_start = target_start.replace(tzinfo=ZoneInfo(zone)) - target_end = target_end.replace(tzinfo=ZoneInfo(zone)) - else: - _LOGGER.debug(f'{context} - Unable to determine timezone from current date tzinfo, falling back to using current date tzinfo for target start and end') - target_start = target_start.replace(tzinfo=current_date.tzinfo) - target_end = target_end.replace(tzinfo=current_date.tzinfo) + target_start = _adjust_to_current_timezone(context, current_date, target_start) + target_end = _adjust_to_current_timezone(context, current_date, target_end) _LOGGER.debug(f'{context} - current: {current_date}; Target start: {target_start}; Target end: {target_end}') diff --git a/tests/unit/target_rates/test_calculate_continuous_times.py b/tests/unit/target_rates/test_calculate_continuous_times.py index 026e8af..7575b6e 100644 --- a/tests/unit/target_rates/test_calculate_continuous_times.py +++ b/tests/unit/target_rates/test_calculate_continuous_times.py @@ -1156,6 +1156,74 @@ def test_continuous_times_when_moving_into_bst(): assert target_start_datetime == datetime.strptime("2026-03-29T15:00:00Z", "%Y-%m-%dT%H:%M:%S%z") assert target_end_datetime == datetime.strptime("2026-03-29T18:00:00Z", "%Y-%m-%dT%H:%M:%S%z") + assert result is not None + assert len(result) == 6 + # Check that the returned periods are within the expected window + for r in result: + assert r["start"] >= target_start_datetime + assert r["end"] <= target_end_datetime + +# https://github.com/BottlecapDave/HomeAssistant-TargetTimeframes/issues/73 +def test_continuous_times_for_issue_73(): + # Arrange + import pytz + tz = pytz.timezone("Europe/London") + current_date = tz.localize(datetime.strptime("2026-04-05T20:26:00", "%Y-%m-%dT%H:%M:%S")) + assert current_date == datetime.strptime("2026-04-05T20:26:00+01:00", "%Y-%m-%dT%H:%M:%S%z") + target_start_time = "20:00" + target_end_time = "19:30" + target_hours = 3 + minimum_slot_minutes = 25 + # Provided data (converted to datetime objects) + values = [] + values.extend( + create_data_source_data(datetime.strptime("2026-04-05T00:00:00+01:00", "%Y-%m-%dT%H:%M:%S%z"), + datetime.strptime("2026-04-05T05:30:00+01:00", "%Y-%m-%dT%H:%M:%S%z"), + [7.99995]) + ) + values.extend( + create_data_source_data(datetime.strptime("2026-04-05T05:30:00+01:00", "%Y-%m-%dT%H:%M:%S%z"), + datetime.strptime("2026-04-05T22:30:00+01:00", "%Y-%m-%dT%H:%M:%S%z"), + [33.32238]) + ) + values.extend( + create_data_source_data(datetime.strptime("2026-04-05T22:30:00+01:00", "%Y-%m-%dT%H:%M:%S%z"), + datetime.strptime("2026-04-06T05:30:00+01:00", "%Y-%m-%dT%H:%M:%S%z"), + [7.99995]) + ) + values.extend( + create_data_source_data(datetime.strptime("2026-04-06T05:30:00+01:00", "%Y-%m-%dT%H:%M:%S%z"), + datetime.strptime("2026-04-06T22:30:00+01:00", "%Y-%m-%dT%H:%M:%S%z"), + [33.32238]) + ) + values.extend( + create_data_source_data(datetime.strptime("2026-04-06T22:30:00+01:00", "%Y-%m-%dT%H:%M:%S%z"), + datetime.strptime("2026-04-07T00:00:00+01:00", "%Y-%m-%dT%H:%M:%S%z"), + [7.99995]) + ) + + (target_start_datetime, target_end_datetime) = get_start_and_end_times(current_date, target_start_time, target_end_time, minimum_slot_minutes) + applicable_time_periods = get_fixed_applicable_time_periods( + target_start_datetime, + target_end_datetime, + values + ) + + expected_start = datetime.strptime("2026-04-05T19:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + expected_end = datetime.strptime("2026-04-06T18:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + + # Act + result = calculate_continuous_times( + applicable_time_periods, + target_hours, + False, + False + ) + + # Assert + assert target_start_datetime == expected_start + assert target_end_datetime == expected_end + assert result is not None assert len(result) == 6 # Check that the returned periods are within the expected window diff --git a/tests/unit/target_rates/test_get_start_and_end_times.py b/tests/unit/target_rates/test_get_start_and_end_times.py index c500561..ce13563 100644 --- a/tests/unit/target_rates/test_get_start_and_end_times.py +++ b/tests/unit/target_rates/test_get_start_and_end_times.py @@ -368,5 +368,26 @@ async def test_when_moving_out_of_bst_then_correct_times_are_returned(): expected_start = datetime.strptime("2026-10-25T16:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z") expected_end = datetime.strptime("2026-10-25T19:00:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + assert target_start == expected_start + assert target_end == expected_end + +@pytest.mark.asyncio +async def test_for_issue_73(): + # Arrange + import pytz + tz = pytz.timezone("Europe/London") + current_date = tz.localize(datetime.strptime("2026-04-05T20:26:00", "%Y-%m-%dT%H:%M:%S")) + assert current_date == datetime.strptime("2026-04-05T20:26:00+01:00", "%Y-%m-%dT%H:%M:%S%z") + target_start_time = "20:00" + target_end_time = "19:30" + minimum_slot_minutes = 25 + + expected_start = datetime.strptime("2026-04-05T19:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + expected_end = datetime.strptime("2026-04-06T18:30:00+00:00", "%Y-%m-%dT%H:%M:%S%z") + + # Act + target_start, target_end = get_start_and_end_times(current_date, target_start_time, target_end_time, minimum_slot_minutes) + + # Assert assert target_start == expected_start assert target_end == expected_end \ No newline at end of file