Repository: bimmerconnected/bimmer_connected
Pull request #617 (2024-05-26 15:29:23)

Update timezone handling for time only data
## Proposed change
<!--
  Describe the big picture of your changes here to communicate to the
  maintainers why we should accept this pull request. If it fixes a bug
  or resolves a feature request, be sure to link to that issue in the
  additional information section.
-->
Updates handling for time-only data when returned as da `datetime` object (currently only `charging_time_start`).
It is now created without timezone (`tzinfo=None`) and only works correctly if both the current timezone of the computer and the car are the same.

Unfortunatly the BMW API does not seem to provide any additional data to figure out which timezone is used for these values.

Time-only data where we have a number of minutes remaining (i.e. 250 seconds of climatization) are not impacted, as we can deterministically calculate those.

## Type of change
- [x] Bugfix (non-breaking change which fixes an issue)
- [ ] New feature (which adds functionality to this library)
- [ ] Breaking change (fix/feature causing existing functionality to break)
- [x] Code quality improvements to existing code or addition of tests

## Additional information
<!--
  Details are important, and help maintainers processing your PR.
  Please be sure to fill out additional details, if applicable.
-->

- This PR fixes or closes issue: fixes #
- This PR is related to issue: #616 (only fixed with HA update)

## Checklist
<!--
  Put an `x` in the boxes that apply. You can also fill these out after
  creating the PR. If you're unsure about any of them, don't hesitate to ask.
  We're here to help! This is simply a reminder of what we are going to look
  for before merging your code.
-->

- [x] The code change is tested and works locally.
- [ ] Tests have been added to verify that the new code works.



Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1646abc..c5d827e 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,12 +1,12 @@
 repos:
   - repo: https://github.com/charliermarsh/ruff-pre-commit
-    rev: v0.1.14
+    rev: v0.4.5
     hooks:
       - id: ruff
         args:
           - --fix
   - repo: https://github.com/psf/black
-    rev: 23.12.1
+    rev: 24.4.2
     hooks:
       - id: black
         args:
@@ -14,7 +14,7 @@ repos:
           - --quiet
         files: ^(bimmer_connected/.+)?[^/]+\.py$
   - repo: https://github.com/codespell-project/codespell
-    rev: v2.2.6
+    rev: v2.3.0
     hooks:
       - id: codespell
         args:
@@ -24,7 +24,7 @@ repos:
         exclude_types: [csv, json]
         exclude: ^test/responses/
   - repo: https://github.com/pre-commit/mirrors-mypy
-    rev: v1.8.0
+    rev: v1.10.0
     hooks: 
       - id: mypy
         name: mypy
diff --git a/bimmer_connected/utils.py b/bimmer_connected/utils.py
index 454c72a..3db1439 100644
--- a/bimmer_connected/utils.py
+++ b/bimmer_connected/utils.py
@@ -5,7 +5,6 @@ import inspect
 import json
 import logging
 import pathlib
-import time
 from enum import Enum
 from typing import TYPE_CHECKING, Dict, List, Optional, Union
 
@@ -37,13 +36,8 @@ def parse_datetime(date_str: str) -> Optional[datetime.datetime]:
     date_formats = ["%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"]
     for date_format in date_formats:
         try:
-            # Parse datetimes using `time.strptime` to allow running in some embedded python interpreters.
-            # https://bugs.python.org/issue27400
-            time_struct = time.strptime(date_str, date_format)
-            parsed = datetime.datetime(*(time_struct[0:6]))
-            if time_struct.tm_gmtoff and time_struct.tm_gmtoff != 0:
-                parsed = parsed - datetime.timedelta(seconds=time_struct.tm_gmtoff)
-            parsed = parsed.replace(tzinfo=datetime.timezone.utc)
+            parsed = datetime.datetime.strptime(date_str, date_format)
+            parsed = parsed.replace(microsecond=0)
             return parsed
         except ValueError:
             pass
@@ -51,6 +45,19 @@ def parse_datetime(date_str: str) -> Optional[datetime.datetime]:
     return None
 
 
+def get_next_occurrence(now: datetime.datetime, time: datetime.time) -> datetime.datetime:
+    """Get the next occurrence of a given time."""
+
+    # If current time is past the given time, add one day to the current date
+    if now.time() > time:
+        next_date = now.date() + datetime.timedelta(days=1)
+    # If current time is before the given time, use the current date
+    else:
+        next_date = now.date()
+    next_occurrence = datetime.datetime.combine(next_date, time)
+    return next_occurrence
+
+
 class MyBMWJSONEncoder(json.JSONEncoder):
     """JSON Encoder that handles data classes, properties and additional data types."""
 
diff --git a/bimmer_connected/vehicle/climate.py b/bimmer_connected/vehicle/climate.py
index fd09feb..7ef76e3 100644
--- a/bimmer_connected/vehicle/climate.py
+++ b/bimmer_connected/vehicle/climate.py
@@ -44,7 +44,7 @@ class Climate(VehicleDataBase):
             retval["activity"] = ClimateActivityState(climate_control_state["activity"])
             retval["activity_end_time"] = (
                 (
-                    datetime.datetime.now(datetime.timezone.utc)
+                    vehicle_data["fetched_at"]
                     + datetime.timedelta(seconds=int(climate_control_state["remainingSeconds"]))
                 )
                 if "remainingSeconds" in climate_control_state
diff --git a/bimmer_connected/vehicle/fuel_and_battery.py b/bimmer_connected/vehicle/fuel_and_battery.py
index 629cb69..dbcc382 100644
--- a/bimmer_connected/vehicle/fuel_and_battery.py
+++ b/bimmer_connected/vehicle/fuel_and_battery.py
@@ -7,6 +7,7 @@ from typing import Any, Dict, Optional
 
 from bimmer_connected.const import ATTR_ATTRIBUTES, ATTR_STATE
 from bimmer_connected.models import StrEnum, ValueWithUnit, VehicleDataBase
+from bimmer_connected.utils import get_next_occurrence
 from bimmer_connected.vehicle.const import COMBUSTION_ENGINE_DRIVE_TRAINS, HV_BATTERY_DRIVE_TRAINS, DriveTrainType
 
 _LOGGER = logging.getLogger(__name__)
@@ -56,7 +57,7 @@ class FuelAndBattery(VehicleDataBase):
     """Charging state of the vehicle."""
 
     charging_start_time: Optional[datetime.datetime] = None
-    """The planned time the vehicle will start charging in UTC."""
+    """The planned time the vehicle will start charging in local time."""
 
     charging_end_time: Optional[datetime.datetime] = None
     """The estimated time the vehicle will have finished charging."""
@@ -165,10 +166,9 @@ class FuelAndBattery(VehicleDataBase):
             retval["charging_target"] = int(electric_data["chargingTarget"])
 
         if retval["charging_status"] == ChargingState.WAITING_FOR_CHARGING and isinstance(charging_window, Dict):
-            retval["charging_start_time"] = datetime.datetime.combine(
-                datetime.datetime.now(datetime.timezone.utc).date(),
+            retval["charging_start_time"] = get_next_occurrence(
+                datetime.datetime.now(),
                 datetime.time(int(charging_window["start"]["hour"]), int(charging_window["start"]["minute"])),
-                tzinfo=datetime.timezone.utc,
             )
 
         return retval
diff --git a/bimmer_connected/vehicle/vehicle.py b/bimmer_connected/vehicle/vehicle.py
index f5aea2e..62841c3 100644
--- a/bimmer_connected/vehicle/vehicle.py
+++ b/bimmer_connected/vehicle/vehicle.py
@@ -146,7 +146,7 @@ class MyBMWVehicle:
         vehicle_data = self.combine_data(data, fetched_at)
         self.data = vehicle_data
 
-        update_entities: List[Tuple[Type["VehicleDataBase"], str]] = [
+        update_entities: List[Tuple[Type[VehicleDataBase], str]] = [
             (FuelAndBattery, "fuel_and_battery"),
             (VehicleLocation, "vehicle_location"),
             (DoorsAndWindows, "doors_and_windows"),
@@ -162,7 +162,7 @@ class MyBMWVehicle:
                 if getattr(self, vehicle_attribute) is None:
                     setattr(self, vehicle_attribute, cls.from_vehicle_data(vehicle_data))
                 else:
-                    curr_attr: "VehicleDataBase" = getattr(self, vehicle_attribute)
+                    curr_attr: VehicleDataBase = getattr(self, vehicle_attribute)
                     curr_attr.update_from_vehicle_data(vehicle_data)
             except (KeyError, TypeError) as ex:
                 _LOGGER.warning("Unable to update %s - (%s) %s", vehicle_attribute, type(ex).__name__, ex)
diff --git a/pyproject.toml b/pyproject.toml
index 7cc304d..e3f411e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,6 +16,11 @@ enable_error_code = "ignore-without-code"
 target-version = "py38"
 line-length = 120
 
+exclude = [
+    "bimmer_connected/coord_convert.py",
+]
+
+[tool.ruff.lint]
 select = [
     "C",  # complexity
     "D",  # docstrings
@@ -37,13 +42,9 @@ ignore = [
     "D107", # Missing docstring in `__init__`
 ]
 
-exclude = [
-    "bimmer_connected/coord_convert.py",
-]
-
 [tool.ruff.lint.per-file-ignores]
 "docs/source/conf.py" = ["D100"]
 "bimmer_connected/api/authentication.py" = ["D102", "D107"]
 
-[tool.ruff.mccabe]
+[tool.ruff.lint.mccabe]
 max-complexity = 25
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 481f098..98e7aff 100644
--- a/setup.py
+++ b/setup.py
@@ -1,4 +1,5 @@
 """Python package description."""
+
 from setuptools import setup
 
 setup(


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 0f6f64a..c86d18e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -54,7 +54,7 @@ jobs:
           pip install -r requirements.txt -r requirements-test.txt
       - name: Test with flake8
         run: |
-          ruff --config=pyproject.toml bimmer_connected
+          ruff check --config=pyproject.toml bimmer_connected
 
   black:
     runs-on: ubuntu-latest
diff --git a/bimmer_connected/tests/test_vehicle_status.py b/bimmer_connected/tests/test_vehicle_status.py
index 927efc1..d600e5f 100644
--- a/bimmer_connected/tests/test_vehicle_status.py
+++ b/bimmer_connected/tests/test_vehicle_status.py
@@ -1,6 +1,8 @@
 """Test for VehicleState."""
 
 import datetime
+import os
+import time
 
 import pytest
 import respx
@@ -180,13 +182,18 @@ async def test_charging_end_time(caplog, bmw_fixture: respx.Router):
 @pytest.mark.asyncio
 async def test_plugged_in_waiting_for_charge_window(caplog, bmw_fixture: respx.Router):
     """I01_REX is plugged in but not charging, as its waiting for charging window."""
+
+    # Make sure that local timezone for test is UTC
+    os.environ["TZ"] = "Europe/Berlin"
+    time.tzset()
+
     account = await prepare_account_with_vehicles()
     vehicle = account.get_vehicle(VIN_I01_REX)
 
     assert vehicle.fuel_and_battery.charging_end_time is None
     assert vehicle.fuel_and_battery.charging_status == ChargingState.WAITING_FOR_CHARGING
     assert vehicle.fuel_and_battery.is_charger_connected is True
-    assert vehicle.fuel_and_battery.charging_start_time == datetime.datetime(2021, 11, 28, 18, 1, tzinfo=UTC)
+    assert vehicle.fuel_and_battery.charging_start_time == datetime.datetime(2021, 11, 29, 18, 1)
     assert vehicle.fuel_and_battery.charging_target == 100
 
     assert len(get_deprecation_warning_count(caplog)) == 0