Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/actions/setup-python/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/issues.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.'
Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
nodejs 22.4.0
python 3.12.4
python 3.13.7
42 changes: 25 additions & 17 deletions custom_components/target_timeframes/entities/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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}')

Expand Down
68 changes: 68 additions & 0 deletions tests/unit/target_rates/test_calculate_continuous_times.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/target_rates/test_get_start_and_end_times.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading