diff --git a/.coveragerc b/.coveragerc index f3c34d26f73..a4faa19abed 100644 --- a/.coveragerc +++ b/.coveragerc @@ -124,7 +124,6 @@ omit = homeassistant/components/bluetooth_tracker/* homeassistant/components/bmw_connected_drive/__init__.py homeassistant/components/bmw_connected_drive/binary_sensor.py - homeassistant/components/bmw_connected_drive/button.py homeassistant/components/bmw_connected_drive/coordinator.py homeassistant/components/bmw_connected_drive/lock.py homeassistant/components/bmw_connected_drive/notify.py diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index 2fd0ea91d08..6edb1a3f2ac 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -34,6 +34,7 @@ class BMWButtonEntityDescription(ButtonEntityDescription): [MyBMWVehicle], Coroutine[Any, Any, RemoteServiceStatus] ] | None = None account_function: Callable[[BMWDataUpdateCoordinator], Coroutine] | None = None + is_available: Callable[[MyBMWVehicle], bool] = lambda _: True BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( @@ -55,6 +56,13 @@ BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( icon="mdi:hvac", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning(), ), + BMWButtonEntityDescription( + key="deactivate_air_conditioning", + icon="mdi:hvac-off", + name="Deactivate air conditioning", + remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning_stop(), + is_available=lambda vehicle: vehicle.is_remote_climate_stop_enabled, + ), BMWButtonEntityDescription( key="find_vehicle", translation_key="find_vehicle", @@ -86,7 +94,7 @@ async def async_setup_entry( [ BMWButton(coordinator, vehicle, description) for description in BUTTON_TYPES - if not coordinator.read_only + if (not coordinator.read_only and description.is_available(vehicle)) or (coordinator.read_only and description.enabled_when_read_only) ] ) diff --git a/tests/components/bmw_connected_drive/__init__.py b/tests/components/bmw_connected_drive/__init__.py index b1f1db305b8..89a34682c1b 100644 --- a/tests/components/bmw_connected_drive/__init__.py +++ b/tests/components/bmw_connected_drive/__init__.py @@ -1,9 +1,11 @@ """Tests for the for the BMW Connected Drive integration.""" from pathlib import Path +from urllib.parse import urlparse from bimmer_connected.api.authentication import MyBMWAuthentication from bimmer_connected.const import ( + REMOTE_SERVICE_POSITION_URL, VEHICLE_CHARGING_DETAILS_URL, VEHICLE_STATE_URL, VEHICLES_URL, @@ -115,6 +117,18 @@ def mock_vehicles() -> respx.Router: router.get(VEHICLE_CHARGING_DETAILS_URL).mock( side_effect=vehicle_charging_sideeffect ) + + # Get vehicle position after remote service + router.post(urlparse(REMOTE_SERVICE_POSITION_URL).netloc).mock( + httpx.Response( + 200, + json=load_json_object_fixture( + FIXTURE_PATH / "remote_service" / "eventposition.json", + integration=BMW_DOMAIN, + ), + ) + ) + return router diff --git a/tests/components/bmw_connected_drive/fixtures/remote_service/eventposition.json b/tests/components/bmw_connected_drive/fixtures/remote_service/eventposition.json new file mode 100644 index 00000000000..92d1a6a9db0 --- /dev/null +++ b/tests/components/bmw_connected_drive/fixtures/remote_service/eventposition.json @@ -0,0 +1,12 @@ +{ + "positionData": { + "status": "OK", + "position": { + "latitude": 123.456, + "longitude": 34.5678, + "formattedAddress": "some_formatted_address", + "heading": 121 + } + }, + "errorDetails": null +} diff --git a/tests/components/bmw_connected_drive/snapshots/test_button.ambr b/tests/components/bmw_connected_drive/snapshots/test_button.ambr new file mode 100644 index 00000000000..a7520a6bce0 --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_button.ambr @@ -0,0 +1,137 @@ +# serializer version: 1 +# name: test_entity_state_attrs + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Flash lights', + 'icon': 'mdi:car-light-alert', + }), + 'context': , + 'entity_id': 'button.i4_edrive40_flash_lights', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Sound horn', + 'icon': 'mdi:bullhorn', + }), + 'context': , + 'entity_id': 'button.i4_edrive40_sound_horn', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Activate air conditioning', + 'icon': 'mdi:hvac', + }), + 'context': , + 'entity_id': 'button.i4_edrive40_activate_air_conditioning', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Deactivate air conditioning', + 'icon': 'mdi:hvac-off', + }), + 'context': , + 'entity_id': 'button.i4_edrive40_deactivate_air_conditioning', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Find vehicle', + 'icon': 'mdi:crosshairs-question', + }), + 'context': , + 'entity_id': 'button.i4_edrive40_find_vehicle', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Refresh from cloud', + 'icon': 'mdi:refresh', + }), + 'context': , + 'entity_id': 'button.i4_edrive40_refresh_from_cloud', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Flash lights', + 'icon': 'mdi:car-light-alert', + }), + 'context': , + 'entity_id': 'button.i3_rex_flash_lights', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Sound horn', + 'icon': 'mdi:bullhorn', + }), + 'context': , + 'entity_id': 'button.i3_rex_sound_horn', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Activate air conditioning', + 'icon': 'mdi:hvac', + }), + 'context': , + 'entity_id': 'button.i3_rex_activate_air_conditioning', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Find vehicle', + 'icon': 'mdi:crosshairs-question', + }), + 'context': , + 'entity_id': 'button.i3_rex_find_vehicle', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Refresh from cloud', + 'icon': 'mdi:refresh', + }), + 'context': , + 'entity_id': 'button.i3_rex_refresh_from_cloud', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- diff --git a/tests/components/bmw_connected_drive/test_button.py b/tests/components/bmw_connected_drive/test_button.py new file mode 100644 index 00000000000..236dd76ce9f --- /dev/null +++ b/tests/components/bmw_connected_drive/test_button.py @@ -0,0 +1,79 @@ +"""Test BMW buttons.""" +from bimmer_connected.vehicle.remote_services import RemoteServices +import pytest +import respx +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.bmw_connected_drive.coordinator import ( + BMWDataUpdateCoordinator, +) +from homeassistant.core import HomeAssistant + +from . import setup_mocked_integration + + +async def test_entity_state_attrs( + hass: HomeAssistant, + bmw_fixture: respx.Router, + snapshot: SnapshotAssertion, +) -> None: + """Test button options and values.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Get all button entities + assert hass.states.async_all("button") == snapshot + + +@pytest.mark.parametrize( + ("entity_id"), + [ + ("button.i4_edrive40_flash_lights"), + ("button.i4_edrive40_sound_horn"), + ("button.i4_edrive40_activate_air_conditioning"), + ("button.i4_edrive40_deactivate_air_conditioning"), + ("button.i4_edrive40_find_vehicle"), + ], +) +async def test_update_triggers_success( + hass: HomeAssistant, + entity_id: str, + bmw_fixture: respx.Router, +) -> None: + """Test button press.""" + + # Setup component + assert await setup_mocked_integration(hass) + BMWDataUpdateCoordinator.async_update_listeners.reset_mock() + + # Test + await hass.services.async_call( + "button", + "press", + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 1 + assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 1 + + +async def test_refresh_from_cloud( + hass: HomeAssistant, + bmw_fixture: respx.Router, +) -> None: + """Test button press for deprecated service.""" + + # Setup component + assert await setup_mocked_integration(hass) + BMWDataUpdateCoordinator.async_update_listeners.reset_mock() + + # Test + await hass.services.async_call( + "button", + "press", + blocking=True, + target={"entity_id": "button.i4_edrive40_refresh_from_cloud"}, + ) + assert RemoteServices.trigger_remote_service.call_count == 0 + assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 2