Trigger location update on certain events for the Volvo integration (#172651)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Thomas D
2026-06-22 23:53:42 +02:00
committed by GitHub
co-authored by Joost Lekkerkerker
parent dd688986f1
commit 8aca6115f4
5 changed files with 423 additions and 18 deletions
+24 -11
View File
@@ -50,19 +50,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> boo
api = await _async_auth_and_create_api(hass, entry)
context = await _async_create_context(api)
# Order is important! Faster intervals must come first.
# Different interval coordinators are in place to keep the number
# of requests under 5000 per day. This lets users use the same
# API key for two vehicles (as the limit is 10000 per day).
coordinators = (
VolvoFastIntervalCoordinator(hass, entry, context),
VolvoMediumIntervalCoordinator(hass, entry, context),
VolvoSlowIntervalCoordinator(hass, entry, context),
VolvoVerySlowIntervalCoordinator(hass, entry, context),
)
await asyncio.gather(*(c.async_config_entry_first_refresh() for c in coordinators))
# of requests under 10000 per day.
fast_coordinator = VolvoFastIntervalCoordinator(hass, entry, context)
medium_coordinator = VolvoMediumIntervalCoordinator(hass, entry, context)
slow_coordinator = VolvoSlowIntervalCoordinator(hass, entry, context)
very_slow_coordinator = VolvoVerySlowIntervalCoordinator(hass, entry, context)
entry.runtime_data = VolvoRuntimeData(coordinators, context)
await asyncio.gather(
*(
c.async_config_entry_first_refresh()
for c in (
fast_coordinator,
medium_coordinator,
slow_coordinator,
very_slow_coordinator,
)
)
)
entry.runtime_data = VolvoRuntimeData(
fast_coordinator,
medium_coordinator,
slow_coordinator,
very_slow_coordinator,
context,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
+96 -2
View File
@@ -46,14 +46,32 @@ class VolvoContext:
class VolvoRuntimeData:
"""Volvo runtime data."""
interval_coordinators: tuple[VolvoBaseCoordinator, ...]
fast_coordinator: VolvoFastIntervalCoordinator
medium_coordinator: VolvoMediumIntervalCoordinator
slow_coordinator: VolvoSlowIntervalCoordinator
very_slow_coordinator: VolvoVerySlowIntervalCoordinator
context: VolvoContext
@property
def interval_coordinators(self) -> tuple[VolvoBaseCoordinator, ...]:
"""Return all coordinators."""
return (
self.fast_coordinator,
self.medium_coordinator,
self.slow_coordinator,
self.very_slow_coordinator,
)
type VolvoConfigEntry = ConfigEntry[VolvoRuntimeData]
type CoordinatorData = dict[str, VolvoCarsApiBaseModel | None]
def schedule_location_update(coordinator: VolvoBaseCoordinator) -> None:
"""Schedule a location-only update."""
coordinator.config_entry.runtime_data.slow_coordinator.async_schedule_location_update()
def _is_invalid_api_field(field: VolvoCarsApiBaseModel | None) -> bool:
if not field:
return True
@@ -180,7 +198,7 @@ class VolvoBaseCoordinator(DataUpdateCoordinator[CoordinatorData]):
def get_api_field(self, api_field: str | None) -> VolvoCarsApiBaseModel | None:
"""Get the API field based on the entity description."""
return self.data.get(api_field) if api_field else None
return self.data.get(api_field) if self.data and api_field else None
@abstractmethod
async def _async_determine_api_calls(
@@ -255,6 +273,23 @@ class VolvoSlowIntervalCoordinator(VolvoBaseCoordinator):
"Volvo slow interval coordinator",
)
self._location_supported = False
self._location_update_task: asyncio.Task[None] | None = None
def async_schedule_location_update(self) -> None:
"""Schedule a single location update if none is in-flight."""
if not self._location_supported:
return
if self._location_update_task and not self._location_update_task.done():
return
self._location_update_task = self.config_entry.async_create_background_task(
self.hass,
self._async_update_location(),
"Volvo location update",
)
@override
async def _async_determine_api_calls(
self,
@@ -282,9 +317,35 @@ class VolvoSlowIntervalCoordinator(VolvoBaseCoordinator):
if location and location.get("location") is not None:
api_calls.append(api.async_get_location)
self._location_supported = True
return api_calls
async def _async_update_location(self) -> None:
"""Fetch only the location data and update listeners."""
if not self._location_supported:
return
try:
location = await self.context.api.async_get_location()
except (VolvoApiException, VolvoAuthException) as ex:
_LOGGER.debug(
"%s - Location update failed: %s",
self.config_entry.entry_id,
ex.message,
)
return
valid_location: dict[str, VolvoCarsApiBaseModel | None] = {
key: field
for key, field in location.items()
if not _is_invalid_api_field(field)
}
if valid_location:
self.data = dict(self.data or {}) | valid_location
self.async_update_listeners()
class VolvoMediumIntervalCoordinator(VolvoBaseCoordinator):
"""Volvo coordinator with medium update rate."""
@@ -307,6 +368,23 @@ class VolvoMediumIntervalCoordinator(VolvoBaseCoordinator):
self._supported_capabilities: list[str] = []
async def _async_update_data(self) -> CoordinatorData:
"""Fetch data and trigger location update on engine-off."""
previous_state = self.get_api_field("engineStatus")
data = await super()._async_update_data()
new_state = data.get("engineStatus")
if (
isinstance(previous_state, VolvoCarsValue)
and previous_state.value == "RUNNING"
and isinstance(new_state, VolvoCarsValue)
and new_state.value
and new_state.value != "RUNNING"
):
schedule_location_update(self)
return data
@override
async def _async_determine_api_calls(
self,
@@ -388,3 +466,19 @@ class VolvoFastIntervalCoordinator(VolvoBaseCoordinator):
api.async_get_doors_status,
api.async_get_window_states,
]
async def _async_update_data(self) -> CoordinatorData:
"""Fetch data and trigger location update on lock."""
previous_state = self.get_api_field("centralLock")
data = await super()._async_update_data()
new_state = data.get("centralLock")
if (
isinstance(previous_state, VolvoCarsValue)
and previous_state.value != "LOCKED"
and isinstance(new_state, VolvoCarsValue)
and new_state.value == "LOCKED"
):
schedule_location_update(self)
return data
+2 -1
View File
@@ -12,7 +12,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import VolvoConfigEntry
from .coordinator import VolvoConfigEntry, schedule_location_update
from .entity import VolvoEntity, VolvoEntityDescription
PARALLEL_UPDATES = 0
@@ -119,6 +119,7 @@ class VolvoLock(VolvoEntity, LockEntity):
if locked:
api_field.value = self.entity_description.api_lock_value
schedule_location_update(self.coordinator)
else:
api_field.value = self.entity_description.api_unlock_value
+253 -4
View File
@@ -1,7 +1,9 @@
"""Test Volvo coordinator."""
import asyncio
from collections.abc import Awaitable, Callable
from datetime import timedelta
import logging
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
@@ -14,13 +16,20 @@ from volvocarsapi.models import (
)
from homeassistant.components.volvo.const import DOMAIN
from homeassistant.components.volvo.coordinator import VERY_SLOW_INTERVAL
from homeassistant.components.volvo.coordinator import (
FAST_INTERVAL,
MEDIUM_INTERVAL,
VERY_SLOW_INTERVAL,
VolvoConfigEntry,
schedule_location_update,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from . import configure_mock
from tests.common import async_fire_time_changed
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00")
@@ -131,13 +140,13 @@ async def test_update_coordinator_all_error(
@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00")
async def test_coordinator_location_auth_exception(
async def test_coordinator_location_exception(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
setup_integration: Callable[[], Awaitable[bool]],
mock_api: VolvoCarsApi,
) -> None:
"""Test coordinator setup when location returns VolvoAuthException."""
"""Test coordinator setup when location returns an exception."""
configure_mock(
mock_api.async_get_location, side_effect=VolvoAuthException(403, "Forbidden")
)
@@ -157,6 +166,235 @@ async def test_coordinator_location_auth_exception(
assert state.state == "30000"
@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00")
async def test_engine_off_triggers_location_update(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
setup_integration: Callable[[], Awaitable[bool]],
mock_api: VolvoCarsApi,
) -> None:
"""Test that engine turning off triggers a location update."""
# Start with engine RUNNING
configure_mock(
mock_api.async_get_engine_status,
return_value={"engineStatus": VolvoCarsValueField(value="RUNNING")},
)
assert await setup_integration()
location_call_count_before: int = mock_api.async_get_location.call_count
# Engine turns off on next poll
configure_mock(
mock_api.async_get_engine_status,
return_value={"engineStatus": VolvoCarsValueField(value="STOPPED")},
)
freezer.tick(timedelta(minutes=MEDIUM_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert mock_api.async_get_location.call_count > location_call_count_before
@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00")
async def test_engine_stays_running_no_extra_location(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
setup_integration: Callable[[], Awaitable[bool]],
mock_api: VolvoCarsApi,
) -> None:
"""Test that engine staying RUNNING does not trigger extra location update."""
configure_mock(
mock_api.async_get_engine_status,
return_value={"engineStatus": VolvoCarsValueField(value="RUNNING")},
)
assert await setup_integration()
location_call_count_before: int = mock_api.async_get_location.call_count
# Engine stays RUNNING on next poll
freezer.tick(timedelta(minutes=MEDIUM_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert mock_api.async_get_location.call_count == location_call_count_before
@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00")
async def test_physical_lock_triggers_location_update(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
setup_integration: Callable[[], Awaitable[bool]],
mock_api: VolvoCarsApi,
) -> None:
"""Test that physical lock (key fob) triggers a location update."""
# Start with car unlocked
doors_unlocked = _get_doors_data(mock_api, "UNLOCKED")
configure_mock(mock_api.async_get_doors_status, return_value=doors_unlocked)
assert await setup_integration()
location_call_count_before: int = mock_api.async_get_location.call_count
# Car gets locked physically on next poll
doors_locked = _get_doors_data(mock_api, "LOCKED")
configure_mock(mock_api.async_get_doors_status, return_value=doors_locked)
freezer.tick(timedelta(minutes=FAST_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert mock_api.async_get_location.call_count > location_call_count_before
@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00")
async def test_physical_unlock_does_not_trigger_location_update(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
setup_integration: Callable[[], Awaitable[bool]],
mock_api: VolvoCarsApi,
) -> None:
"""Test that unlocking does not trigger a location update."""
assert await setup_integration()
location_call_count_before: int = mock_api.async_get_location.call_count
# Car gets unlocked on next poll
doors_unlocked = _get_doors_data(mock_api, "UNLOCKED")
configure_mock(mock_api.async_get_doors_status, return_value=doors_unlocked)
freezer.tick(timedelta(minutes=FAST_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert mock_api.async_get_location.call_count == location_call_count_before
@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00")
async def test_location_update_not_supported(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
setup_integration: Callable[[], Awaitable[bool]],
mock_api: VolvoCarsApi,
) -> None:
"""Test that async_update_location is a no-op when location is unsupported."""
configure_mock(
mock_api.async_get_location, side_effect=VolvoAuthException(403, "Forbidden")
)
configure_mock(
mock_api.async_get_engine_status,
return_value={"engineStatus": VolvoCarsValueField(value="RUNNING")},
)
assert await setup_integration()
mock_api.async_get_location.reset_mock()
configure_mock(
mock_api.async_get_engine_status,
return_value={"engineStatus": VolvoCarsValueField(value="STOPPED")},
)
freezer.tick(timedelta(minutes=MEDIUM_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
mock_api.async_get_location.assert_not_called()
async def test_coordinator_setup_auth_exception(
hass: HomeAssistant,
setup_integration: Callable[[], Awaitable[bool]],
mock_config_entry: MockConfigEntry,
mock_api: VolvoCarsApi,
) -> None:
"""Test coordinator setup when determine API calls raises auth exception."""
configure_mock(
mock_api.async_get_energy_capabilities,
side_effect=VolvoAuthException(401, "Unauthorized"),
)
assert not await setup_integration()
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
async def test_coordinator_setup_not_ready_exception(
hass: HomeAssistant,
setup_integration: Callable[[], Awaitable[bool]],
mock_config_entry: MockConfigEntry,
mock_api: VolvoCarsApi,
) -> None:
"""Test coordinator setup when determine API calls raises API exception."""
configure_mock(
mock_api.async_get_energy_capabilities, side_effect=VolvoApiException
)
assert not await setup_integration()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize(
"exception",
[
VolvoApiException("Location failed"),
VolvoAuthException(401, "Unauthorized"),
],
)
async def test_update_location_exception_logs_debug(
hass: HomeAssistant,
setup_integration: Callable[[], Awaitable[bool]],
mock_api: VolvoCarsApi,
caplog: pytest.LogCaptureFixture,
exception: VolvoApiException | VolvoAuthException,
) -> None:
"""Test async_update_location logs debug when location call fails."""
assert await setup_integration()
entry: VolvoConfigEntry = hass.config_entries.async_entries(DOMAIN)[0]
configure_mock(mock_api.async_get_location, side_effect=exception)
with caplog.at_level(
logging.DEBUG, logger="homeassistant.components.volvo.coordinator"
):
await entry.runtime_data.slow_coordinator._async_update_location()
assert "Location update failed" in caplog.text
async def test_schedule_location_update_is_coalesced(
hass: HomeAssistant,
setup_integration: Callable[[], Awaitable[bool]],
mock_api: VolvoCarsApi,
) -> None:
"""Test location update scheduling coalesces in-flight updates."""
assert await setup_integration()
entry: VolvoConfigEntry = hass.config_entries.async_entries(DOMAIN)[0]
coordinator = entry.runtime_data.interval_coordinators[0]
location_data = mock_api.async_get_location.return_value
started = asyncio.Event()
release = asyncio.Event()
async def _delayed_location_update() -> dict[str, VolvoCarsValueField]:
started.set()
await release.wait()
return location_data
configure_mock(mock_api.async_get_location, side_effect=_delayed_location_update)
schedule_location_update(coordinator)
schedule_location_update(coordinator)
schedule_location_update(coordinator)
await started.wait()
# Only one in-flight background task should be allowed.
assert mock_api.async_get_location.call_count == 1
release.set()
await hass.async_block_till_done(wait_background_tasks=True)
schedule_location_update(coordinator)
await hass.async_block_till_done(wait_background_tasks=True)
# A new schedule should run after the previous task has finished.
assert mock_api.async_get_location.call_count == 2
def _mock_api_failure(mock_api: VolvoCarsApi) -> AsyncMock:
"""Mock the Volvo API so that it raises an exception for all calls."""
@@ -179,3 +417,14 @@ def _mock_api_failure(mock_api: VolvoCarsApi) -> AsyncMock:
mock_api.async_get_window_states.side_effect = VolvoApiException()
return mock_api
def _get_doors_data(
mock_api: VolvoCarsApi, lock_value: str
) -> dict[str, VolvoCarsValueField]:
"""Build doors data with a specific centralLock value."""
# Reuse the structure from the original mock but override centralLock
original = mock_api.async_get_doors_status.return_value
result = dict(original)
result["centralLock"] = VolvoCarsValueField(value=lock_value)
return result
+48
View File
@@ -162,3 +162,51 @@ async def test_lock_unavailable_when_api_field_missing(
await hass.async_block_till_done(wait_background_tasks=True)
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("full_model")
async def test_ha_lock_triggers_location_update(
hass: HomeAssistant,
setup_integration: Callable[[], Awaitable[bool]],
mock_api: VolvoCarsApi,
) -> None:
"""Test that locking via HA triggers a location update."""
with patch("homeassistant.components.volvo.PLATFORMS", [Platform.LOCK]):
assert await setup_integration()
location_call_count_before: int = mock_api.async_get_location.call_count
await hass.services.async_call(
LOCK_DOMAIN,
SERVICE_LOCK,
{ATTR_ENTITY_ID: "lock.volvo_xc40_lock"},
blocking=True,
)
await hass.async_block_till_done(wait_background_tasks=True)
assert mock_api.async_get_location.call_count > location_call_count_before
@pytest.mark.usefixtures("full_model")
async def test_ha_unlock_does_not_trigger_location_update(
hass: HomeAssistant,
setup_integration: Callable[[], Awaitable[bool]],
mock_api: VolvoCarsApi,
) -> None:
"""Test that unlocking via HA does not trigger a location update."""
with patch("homeassistant.components.volvo.PLATFORMS", [Platform.LOCK]):
assert await setup_integration()
location_call_count_before: int = mock_api.async_get_location.call_count
await hass.services.async_call(
LOCK_DOMAIN,
SERVICE_UNLOCK,
{ATTR_ENTITY_ID: "lock.volvo_xc40_lock"},
blocking=True,
)
await hass.async_block_till_done(wait_background_tasks=True)
assert mock_api.async_get_location.call_count == location_call_count_before