From 8a43d1a12ca46fca950764bd16df7822b9740c43 Mon Sep 17 00:00:00 2001 From: Andres Ruiz Date: Sat, 11 Apr 2026 10:22:19 -0400 Subject: [PATCH] Add remote start/stop button for supported Subaru vehicles (#167100) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/subaru/button.py | 99 ++++++ homeassistant/components/subaru/const.py | 3 + homeassistant/components/subaru/icons.json | 8 + .../components/subaru/remote_service.py | 4 +- homeassistant/components/subaru/strings.json | 8 + tests/components/subaru/test_button.py | 301 ++++++++++++++++++ 6 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/subaru/button.py create mode 100644 tests/components/subaru/test_button.py diff --git a/homeassistant/components/subaru/button.py b/homeassistant/components/subaru/button.py new file mode 100644 index 00000000000..b0587bcb5a2 --- /dev/null +++ b/homeassistant/components/subaru/button.py @@ -0,0 +1,99 @@ +"""Support for Subaru remote service buttons.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from subarulink import Controller as SubaruAPI + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import get_device_info +from .const import ( + SERVICE_REMOTE_START, + SERVICE_REMOTE_STOP, + VEHICLE_HAS_EV, + VEHICLE_HAS_REMOTE_START, + VEHICLE_VIN, +) +from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator +from .remote_service import async_call_remote_service + + +@dataclass(frozen=True, kw_only=True) +class SubaruButtonEntityDescription(ButtonEntityDescription): + """Describes a Subaru button entity.""" + + arg: Callable[[dict[str, Any]], str | None] | None = None + + +REMOTE_BUTTONS = [ + SubaruButtonEntityDescription( + key=SERVICE_REMOTE_START, + translation_key="remote_start", + arg=lambda _: "Auto", + ), + SubaruButtonEntityDescription( + key=SERVICE_REMOTE_STOP, + translation_key="remote_stop", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: SubaruConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Subaru remote service buttons by config_entry.""" + coordinator = config_entry.runtime_data.coordinator + controller = config_entry.runtime_data.controller + vehicle_info = config_entry.runtime_data.vehicles + async_add_entities( + SubaruButton(vehicle, controller, coordinator, description) + for vehicle in vehicle_info.values() + if vehicle[VEHICLE_HAS_REMOTE_START] or vehicle[VEHICLE_HAS_EV] + for description in REMOTE_BUTTONS + ) + + +class SubaruButton(ButtonEntity): + """Class for a Subaru button.""" + + _attr_has_entity_name = True + entity_description: SubaruButtonEntityDescription + + def __init__( + self, + vehicle_info: dict[str, Any], + controller: SubaruAPI, + coordinator: SubaruDataUpdateCoordinator, + description: SubaruButtonEntityDescription, + ) -> None: + """Initialize the button for the vehicle.""" + self.controller = controller + self.coordinator = coordinator + self.vehicle_info = vehicle_info + self.entity_description = description + vin = vehicle_info[VEHICLE_VIN] + self._attr_unique_id = f"{vin}_{description.key}" + self._attr_device_info = get_device_info(vehicle_info) + + async def async_press(self) -> None: + """Press the button.""" + arg = ( + self.entity_description.arg(self.vehicle_info) + if self.entity_description.arg + else None + ) + await async_call_remote_service( + self.controller, + self.entity_description.key, + self.vehicle_info, + arg, + ) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py index c9a02e09f62..0ff9d6bec2c 100644 --- a/homeassistant/components/subaru/const.py +++ b/homeassistant/components/subaru/const.py @@ -32,12 +32,15 @@ API_GEN_3 = "g3" MANUFACTURER = "Subaru" PLATFORMS = [ + Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.LOCK, Platform.SENSOR, ] SERVICE_LOCK = "lock" +SERVICE_REMOTE_START = "remote_start" +SERVICE_REMOTE_STOP = "remote_stop" SERVICE_UNLOCK = "unlock" SERVICE_UNLOCK_SPECIFIC_DOOR = "unlock_specific_door" diff --git a/homeassistant/components/subaru/icons.json b/homeassistant/components/subaru/icons.json index be9628303b7..ffae30aecd3 100644 --- a/homeassistant/components/subaru/icons.json +++ b/homeassistant/components/subaru/icons.json @@ -1,5 +1,13 @@ { "entity": { + "button": { + "remote_start": { + "default": "mdi:power" + }, + "remote_stop": { + "default": "mdi:stop-circle-outline" + } + }, "device_tracker": { "location": { "default": "mdi:car" diff --git a/homeassistant/components/subaru/remote_service.py b/homeassistant/components/subaru/remote_service.py index acd71e186da..1a20ad04d72 100644 --- a/homeassistant/components/subaru/remote_service.py +++ b/homeassistant/components/subaru/remote_service.py @@ -6,7 +6,7 @@ from subarulink.exceptions import SubaruException from homeassistant.exceptions import HomeAssistantError -from .const import SERVICE_UNLOCK, VEHICLE_NAME, VEHICLE_VIN +from .const import SERVICE_REMOTE_START, SERVICE_UNLOCK, VEHICLE_NAME, VEHICLE_VIN _LOGGER = logging.getLogger(__name__) @@ -20,7 +20,7 @@ async def async_call_remote_service(controller, cmd, vehicle_info, arg=None): success = False err_msg = "" try: - if cmd == SERVICE_UNLOCK: + if cmd in (SERVICE_UNLOCK, SERVICE_REMOTE_START): success = await getattr(controller, cmd)(vin, arg) else: success = await getattr(controller, cmd)(vin) diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 699dca1f05d..5e72848e46b 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -47,6 +47,14 @@ } }, "entity": { + "button": { + "remote_start": { + "name": "Remote start" + }, + "remote_stop": { + "name": "Remote stop" + } + }, "lock": { "door_locks": { "name": "Door locks" diff --git a/tests/components/subaru/test_button.py b/tests/components/subaru/test_button.py new file mode 100644 index 00000000000..09e30054b2f --- /dev/null +++ b/tests/components/subaru/test_button.py @@ -0,0 +1,301 @@ +"""Test Subaru buttons.""" + +from unittest.mock import patch + +import pytest +from subarulink import SubaruException + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.components.subaru.const import ( + VEHICLE_HAS_EV, + VEHICLE_HAS_REMOTE_START, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .api_responses import ( + TEST_VIN_2_EV, + TEST_VIN_3_G3, + VEHICLE_DATA, + VEHICLE_STATUS_EV, + VEHICLE_STATUS_G3, +) +from .conftest import ( + MOCK_API, + MOCK_API_FETCH, + MOCK_API_GET_DATA, + setup_subaru_config_entry, +) + +from tests.common import MockConfigEntry + +MOCK_API_REMOTE_START = f"{MOCK_API}remote_start" +MOCK_API_REMOTE_STOP = f"{MOCK_API}remote_stop" + +VEHICLE_BUTTONS = { + TEST_VIN_2_EV: { + "remote_start": "button.test_vehicle_2_remote_start", + "remote_stop": "button.test_vehicle_2_remote_stop", + }, + TEST_VIN_3_G3: { + "remote_start": "button.test_vehicle_3_remote_start", + "remote_stop": "button.test_vehicle_3_remote_stop", + }, +} + + +@pytest.mark.parametrize("vin", [TEST_VIN_2_EV, TEST_VIN_3_G3]) +async def test_device_exists( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + subaru_config_entry: MockConfigEntry, + vin: str, +) -> None: + """Test subaru remote button entities exist.""" + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[vin], + vehicle_data=VEHICLE_DATA[vin], + ) + entry = entity_registry.async_get(VEHICLE_BUTTONS[vin]["remote_start"]) + assert entry + entry = entity_registry.async_get(VEHICLE_BUTTONS[vin]["remote_stop"]) + assert entry + + +@pytest.mark.parametrize( + ("vin", "vehicle_status"), + [ + (TEST_VIN_2_EV, VEHICLE_STATUS_EV), + (TEST_VIN_3_G3, VEHICLE_STATUS_G3), + ], +) +async def test_remote_start( + hass: HomeAssistant, + subaru_config_entry: MockConfigEntry, + vin: str, + vehicle_status: dict, +) -> None: + """Test subaru remote start button.""" + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[vin], + vehicle_data=VEHICLE_DATA[vin], + ) + with ( + patch(MOCK_API_REMOTE_START) as mock_remote_start, + patch(MOCK_API_FETCH), + patch(MOCK_API_GET_DATA, return_value=vehicle_status), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: VEHICLE_BUTTONS[vin]["remote_start"]}, + blocking=True, + ) + await hass.async_block_till_done() + mock_remote_start.assert_called_once() + + +@pytest.mark.parametrize( + ("vin", "vehicle_status"), + [ + (TEST_VIN_2_EV, VEHICLE_STATUS_EV), + (TEST_VIN_3_G3, VEHICLE_STATUS_G3), + ], +) +async def test_remote_stop( + hass: HomeAssistant, + subaru_config_entry: MockConfigEntry, + vin: str, + vehicle_status: dict, +) -> None: + """Test subaru remote stop button.""" + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[vin], + vehicle_data=VEHICLE_DATA[vin], + ) + with ( + patch(MOCK_API_REMOTE_STOP) as mock_remote_stop, + patch(MOCK_API_FETCH), + patch(MOCK_API_GET_DATA, return_value=vehicle_status), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: VEHICLE_BUTTONS[vin]["remote_stop"]}, + blocking=True, + ) + await hass.async_block_till_done() + mock_remote_stop.assert_called_once() + + +@pytest.mark.parametrize( + ("vin", "vehicle_status"), + [ + (TEST_VIN_2_EV, VEHICLE_STATUS_EV), + (TEST_VIN_3_G3, VEHICLE_STATUS_G3), + ], +) +async def test_remote_start_fails( + hass: HomeAssistant, + subaru_config_entry: MockConfigEntry, + vin: str, + vehicle_status: dict, +) -> None: + """Test subaru remote start button failure.""" + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[vin], + vehicle_data=VEHICLE_DATA[vin], + ) + with ( + patch(MOCK_API_REMOTE_START, return_value=False), + patch(MOCK_API_FETCH), + patch(MOCK_API_GET_DATA, return_value=vehicle_status), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: VEHICLE_BUTTONS[vin]["remote_start"]}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("vin", "vehicle_status"), + [ + (TEST_VIN_2_EV, VEHICLE_STATUS_EV), + (TEST_VIN_3_G3, VEHICLE_STATUS_G3), + ], +) +async def test_remote_start_exception( + hass: HomeAssistant, + subaru_config_entry: MockConfigEntry, + vin: str, + vehicle_status: dict, +) -> None: + """Test subaru remote start button with SubaruException.""" + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[vin], + vehicle_data=VEHICLE_DATA[vin], + ) + with ( + patch( + MOCK_API_REMOTE_START, + side_effect=SubaruException("Remote service failed"), + ), + patch(MOCK_API_FETCH), + patch(MOCK_API_GET_DATA, return_value=vehicle_status), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: VEHICLE_BUTTONS[vin]["remote_start"]}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("vin", "vehicle_status"), + [ + (TEST_VIN_2_EV, VEHICLE_STATUS_EV), + (TEST_VIN_3_G3, VEHICLE_STATUS_G3), + ], +) +async def test_remote_stop_fails( + hass: HomeAssistant, + subaru_config_entry: MockConfigEntry, + vin: str, + vehicle_status: dict, +) -> None: + """Test subaru remote stop button failure.""" + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[vin], + vehicle_data=VEHICLE_DATA[vin], + ) + with ( + patch(MOCK_API_REMOTE_STOP, return_value=False), + patch(MOCK_API_FETCH), + patch(MOCK_API_GET_DATA, return_value=vehicle_status), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: VEHICLE_BUTTONS[vin]["remote_stop"]}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("vin", "vehicle_status"), + [ + (TEST_VIN_2_EV, VEHICLE_STATUS_EV), + (TEST_VIN_3_G3, VEHICLE_STATUS_G3), + ], +) +async def test_remote_stop_exception( + hass: HomeAssistant, + subaru_config_entry: MockConfigEntry, + vin: str, + vehicle_status: dict, +) -> None: + """Test subaru remote stop button with SubaruException.""" + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[vin], + vehicle_data=VEHICLE_DATA[vin], + ) + with ( + patch( + MOCK_API_REMOTE_STOP, + side_effect=SubaruException("Remote service failed"), + ), + patch(MOCK_API_FETCH), + patch(MOCK_API_GET_DATA, return_value=vehicle_status), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: VEHICLE_BUTTONS[vin]["remote_stop"]}, + blocking=True, + ) + + +async def test_no_buttons_without_remote_start( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + subaru_config_entry: MockConfigEntry, +) -> None: + """Test no buttons created for vehicle without remote start or EV.""" + vehicle_data = { + **VEHICLE_DATA[TEST_VIN_3_G3], + VEHICLE_HAS_REMOTE_START: False, + VEHICLE_HAS_EV: False, + } + await setup_subaru_config_entry( + hass, + subaru_config_entry, + vehicle_list=[TEST_VIN_3_G3], + vehicle_data=vehicle_data, + ) + entry = entity_registry.async_get(VEHICLE_BUTTONS[TEST_VIN_3_G3]["remote_start"]) + assert entry is None + entry = entity_registry.async_get(VEHICLE_BUTTONS[TEST_VIN_3_G3]["remote_stop"]) + assert entry is None