diff --git a/README.md b/README.md index 5af12d1..45f5578 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ -# Subaru STARLINK integration for Home Assistant +# Subaru STARLINK Integration for Home Assistant +[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration) **NOTE:** The [Subaru](https://www.home-assistant.io/integrations/subaru/) integration is now part of Home Assistant Core (as of release [2021.3](https://www.home-assistant.io/blog/2021/03/03/release-20213/)), however not all features have been implemented. Currently, only the sensor platform is available. Additional PRs will be submitted to include all features of this custom component into Home Assistant Core. @@ -22,62 +23,64 @@ This integration requires an active vehicle subscription to the [Subaru STARLINK Subaru has deployed two generations of telematics, Gen 1 and Gen 2. Use the tables below to determine which capabilities are available for your vehicle. -| Model | Gen 1 | Gen 2 | -|-----------|-----------|-------| -| Ascent | | 2019+ | -| Crosstrek | 2016-2018 | 2019+ | -| Forester | 2016-2018 | 2019+ | -| Impreza | 2016-2018 | 2019+ | -| Legacy | 2016-2019 | 2020+ | -| Outback | 2016-2019 | 2020+ | -| WRX | 2017+ | | - - -| Sensor | Gen 1 | Gen 2 | -|--------------------------|---------|---------| -| 12V battery voltage | | ✓ | -| Average fuel consumption | | ✓ | -| Distance to empty | | ✓ | -| EV battery level | | ✓ | -| EV range | | ✓ | -| EV time to full charge | | ✓ | -| External temperature | | ✓ | -| Odometer | ✓*| ✓ | -| Tire pressures | | ✓ | +NOTE: There now appears to be a Gen 3, although it is unclear which model years have this capability. From analysis of the official Android mobile app, Gen 3 uses the same API endpoints as Gen 2, but may offer additional capability (tailgate unlock and hints of future remote window open/close?). + +| Model | Gen 1 | Gen 2 | Gen 3 | +|-----------|-----------|-------|-------| +| Ascent | | 2019+ | ? | +| Crosstrek | 2016-2018 | 2019+ | ? | +| Forester | 2016-2018 | 2019+ | ? | +| Impreza | 2016-2018 | 2019+ | ? | +| Legacy | 2016-2019 | 2020+ | ? | +| Outback | 2016-2019 | 2020+ | ? | +| WRX | 2017+ | | ? | + + +| Sensor | Gen 1 | Gen 2 | Gen 3 | +|--------------------------|---------|---------|---------| +| 12V battery voltage | | ✓ | ✓ | +| Average fuel consumption | | ✓ | ✓ | +| Distance to empty | | ✓ | ✓ | +| EV battery level | | ✓ | ✓ | +| EV range | | ✓ | ✓ | +| EV time to full charge | | ✓ | ✓ | +| External temperature | | ✓ | ✓ | +| Odometer | ✓*| ✓ | ✓ | +| Tire pressures | | ✓ | ✓ | \* Gen 1 odometer only updates every 500 miles
-| Binary Sensor | Gen 1 | Gen 2 | -|--------------------------|---------|---------| -| Door/Trunk/Hood Status | | ✓ | -| Window Status | | ✓*| -| Ignition Status | | ✓ | -| EV Plug/Charging Status | | ✓*| +| Binary Sensor | Gen 1 | Gen 2 | Gen 3 | +|--------------------------|---------|---------|---------| +| Door/Trunk/Hood Status | | ✓ | ✓ | +| Window Status | | ✓*| ✓ | +| Ignition Status | | ✓ | ✓ | +| EV Plug/Charging Status | | ✓*| ✓ | \* Not supported by all vehicles
-Device tracker, lock, and services all require a STARLINK Security Plus subscription: -| Device Tracker | Gen 1 | Gen 2 | -|--------------------------|---------|---------| -| Vehicle Location | ✓ | ✓ | +Device tracker, lock, and buttons (except refresh) all require a STARLINK Security Plus subscription: +| Device Tracker | Gen 1 | Gen 2 | Gen 3 | +|--------------------------|---------|---------|---------| +| Vehicle Location | ✓ | ✓ | ✓ | -| Lock | Gen 1 | Gen 2 | -|--------------------------|---------|---------| -| Remote lock/unlock | ✓ | ✓ | +| Lock | Gen 1 | Gen 2 | Gen 3 | +|--------------------------|---------|---------|---------| +| Remote lock/unlock | ✓ | ✓ | ✓ | -| Services | Gen 1 | Gen 2 | -|--------------------------|---------|---------| -| Lock/Unlock | ✓ | ✓ | -| Start/Stop Horn/Lights | ✓ | ✓ | -| Poll vehicle | ✓ | ✓ | -| Refresh data | ✓ | ✓ | -| Start/Stop Horn/Lights | ✓ | ✓ | -| Start/Stop Engine | | ✓*| -| Start EV charging | | ✓*| +| Buttons | Gen 1 | Gen 2 | Gen 3 | +|--------------------------|---------|---------|---------| +| Lock/Unlock | ✓ | ✓ | ✓ | +| Start/Stop Horn/Lights | ✓ | ✓ | ✓ | +| Poll vehicle | ✓ | ✓ | ✓ | +| Refresh data | ✓ | ✓ | ✓ | +| Start/Stop Horn/Lights | ✓ | ✓ | ✓ | +| Start/Stop Engine | | ✓*| ✓*| +| Start EV charging | | ✓*| ✓*| \* Not supported by all vehicles
@@ -85,7 +88,7 @@ Device tracker, lock, and services all require a STARLINK Security Plus subscrip ## Installation ### HACS -Add `https://github.com/G-Two/homeassistant-subaru` as a custom integration repository and install the **Subaru (HACS)** integration. +Add `https://github.com/G-Two/homeassistant-subaru` as a custom integration repository and install the **Subaru (HACS)** integration. Restart Home Assistant. ### Manual Clone or download this repository, and copy the `custom_components/subaru` directory into the `config/custom_components` directory of your Home Assistant instance. Restart Home Assistant. @@ -93,7 +96,7 @@ Clone or download this repository, and copy the `custom_components/subaru` direc Once installed, the Subaru integration is configured via the Home Assistant UI: -**Configuration** -> **Integrations** -> **Add** -> **Subaru (HACS)** +**Configuration** -> **Devices & Services** -> **Add Integration** -> **Subaru (HACS)** **NOTE:** After installation and HA restart, you may need to clear your browser cache for the new integration to appear. @@ -116,7 +119,7 @@ If the PIN prompt does not appear, no supported remote services vehicles were fo Subaru integration options are set via: -**Configuration** -> **Integrations** -> **Subaru (HACS)** -> **Options**. +**Configuration** -> **Devices & Services** -> **Subaru (HACS)** -> **Configure**. All options involve remote commands, thus only apply to vehicles with Security Plus subscriptions: @@ -131,9 +134,26 @@ All options involve remote commands, thus only apply to vehicles with Security P ## Services -Services provided by this integration are shown below: +As of v0.6.0, the following Subaru entities now use the native Home Assistant services: +- Lock +- Button (Remote Start, Lights/Horn, Locate, Refresh) +- Select (Climate Control Preset) -**NOTE:** Subaru lock uses the services provided by the built-in Home Assistant [Lock](https://www.home-assistant.io/integrations/lock/) platform +The legacy Subaru integration specific services that required the VIN are no longer needed to access the features above and will be removed in a future release. + +The Lock entity's "Unlock" will always unlock all doors. The Subaru API supports selecting a specific door to unlock. Users that desire this functionality may use a Subaru integration specific service which allows the user to choose the door to unlock. See the Services UI in Developer Tools for usage. Example YAML for this service is: +```yaml +service: subaru.unlock_specific_door +target: + entity_id: lock.subaru_door_locks +data: + # Valid values for door are 'all', 'driver', 'tailgate' (note that 'tailgate' is not supported by all vehicles) + door: driver +``` + +--- +### Legacy Services +**NOTE:** All the legacy services below will be removed in a future release: | Service | Description | | ---------------------- | ----------- | @@ -143,29 +163,80 @@ Services provided by this integration are shown below: |`subaru.horn_cancel` | Stop sounding the horn and flash the lights of the vehicle | |`subaru.lights` | Flash the lights of the vehicle | |`subaru.lights_cancel` | Stop flashing the lights of the vehicle | -|`subaru.remote_start` | Start the engine and climate control of the vehicle using the most recent climate control settings | |`subaru.remote_stop` | Stop the engine and climate control of the vehicle | |`subaru.update` | Sends request to vehicle to update data which will update cache on Subaru servers | All of the above services require the same service data attribute shown below. The service will be invoked on the vehicle identified by `vin`. -| Service Data Attribute | Required | Description | -| ---------------------- | -------- | -------------------------------------------------- | -| `vin` | yes | The vehicle identification number (VIN) of the vehicle, 17 characters | +| Service Data Attribute | Required | Type | Description | +| ---------------------- | -------- | ------ | -------------------------------------------------- | +| `vin` | yes | String | The vehicle identification number (VIN) of the vehicle, 17 characters | -## Lovelace Example +#### Remote Climate Control + +For supported vehicles, this integration supports selecting specific remote climate control presets when remotely starting the engine via the following service: + +| Service | Description | +| ---------------------- | ----------- | +|`subaru.remote_start` | Start the engine and climate control of the vehicle using the user specified climate control preset | -![hass_screenshot](https://user-images.githubusercontent.com/7310260/146694159-ba5da7b1-ec66-4fe5-91a5-2351c2783a34.png) +`subaru.remote_start` requires an additional data attribute, `preset_name`, which is a preconfigured set of climate control settings. There are 3 "built-in" Subaru presets: +`Auto` (not available for EVs), `Full Cool`, and `Full Heat`. In addition you may configure up to 4 additional custom presets from the MySubaru website or the +official mobile app. Although the underlying subarulink python package does support the creation of new presets, that functionality has not yet been implemented in this +integration. + +| Service Data Attribute | Required | Type | Description | +| ---------------------- | -------- | ------ | -------------------------------------------------- | +| `vin` | yes | String | The vehicle identification number (VIN) of the vehicle, 17 characters | +| `preset_name` | yes | String | Either a Subaru or user defined climate control preset name | + +## Lovelace Example +
Example Lovelace YAML

```yaml -# Example YAML for the dashboard shown above. Replace entity names and VIN with your vehicle info. -title: Home +# Example YAML for the dashboard shown above. +title: Status views: - - badges: [] + - icon: '' + title: Status + badges: [] cards: + - type: horizontal-stack + cards: + - entity: button.subaru_refresh + hold_action: + action: more-info + show_icon: true + show_name: false + show_state: false + tap_action: + action: call-service + service: button.press + service_data: {} + target: + entity_id: button.subaru_refresh + type: button + icon_height: 48px + - entity: button.subaru_locate + hold_action: + action: more-info + show_icon: true + show_name: false + show_state: false + tap_action: + action: call-service + confirmation: + text: Poll Vehicle? + service: button.press + service_data: {} + target: + entity_id: button.subaru_locate + type: button + icon_height: 48px + title: Update Data - cards: - entity: sensor.subaru_odometer name: Odometer @@ -226,234 +297,106 @@ views: type: vertical-stack title: Tire Pressure - type: vertical-stack + title: Remote Commands cards: - - type: horizontal-stack - cards: - - entity: '' - hold_action: + - cards: + - type: button + tap_action: action: more-info - icon: mdi:refresh - icon_height: 32px - name: Refresh - show_icon: true - show_name: true + entity: lock.subaru_door_locks show_state: false + show_name: false + icon_height: 48px + type: horizontal-stack + - cards: + - type: button tap_action: action: call-service - service: subaru.fetch - service_data: - vin: - type: button - - entity: '' - hold_action: - action: more-info - icon: mdi:car-connected - icon_height: 32px - name: Poll Vehicle + confirmation: + text: Flash lights? + service: button.press + service_data: {} + target: + entity_id: + - button.subaru_lights_start + entity: button.subaru_lights_start + show_state: false + show_name: false + icon_height: 48px + - entity: button.subaru_lights_stop + icon_height: 48px show_icon: true - show_name: true + show_name: false + tap_action: + action: call-service + confirmation: + text: Stop lights? + service: button.press + service_data: {} + target: + entity_id: button.subaru_lights_stop + type: button + type: horizontal-stack + - cards: + - type: button + tap_action: + action: call-service + confirmation: + text: Sound horn? + service: button.press + service_data: {} + target: + entity_id: button.subaru_horn_start + entity: button.subaru_horn_start show_state: false + show_name: false + icon_height: 48px + - entity: button.subaru_horn_stop + icon_height: 48px + show_icon: true + show_name: false tap_action: action: call-service confirmation: - text: Poll Vehicle? - service: subaru.update - service_data: - vin: + text: Stop horn? + service: button.press + service_data: {} + target: + entity_id: button.subaru_horn_stop type: button - title: Update Data - - type: vertical-stack - title: Remote Commands - cards: - - cards: - - icon: mdi:lock - icon_height: 32px - name: Lock - show_icon: true - show_name: true - tap_action: - action: call-service - confirmation: - text: Lock Doors? - service: lock.lock - service_data: {} - target: - entity_id: lock.subaru_door_lock - type: button - - entity: '' - icon: mdi:lock-open-variant - icon_height: 32px - name: Unlock - show_icon: true - show_name: true - show_state: false - tap_action: - action: call-service - confirmation: - text: Unlock Doors? - service: lock.unlock - service_data: {} - target: - entity_id: lock.subaru_door_lock - type: button - type: horizontal-stack - - cards: - - entity: '' - hold_action: - action: more-info - icon: mdi:lightbulb-on - icon_height: 32px - name: Flash Lights - show_icon: true - show_name: true - show_state: false - tap_action: - action: call-service - confirmation: - text: Flash lights? - service: subaru.lights - service_data: - vin: - type: button - - entity: '' - hold_action: - action: more-info - icon_height: 32px - icon: mdi:lightbulb-off - name: Stop Lights - show_icon: true - show_name: true - show_state: false - tap_action: - action: call-service - confirmation: - text: Stop lights? - service: subaru.lights_stop - service_data: - vin: - type: button - type: horizontal-stack - - cards: - - entity: '' - hold_action: - action: more-info - icon: mdi:volume-high - icon_height: 32px - name: Sound Horn - show_icon: true - show_name: true - show_state: false - tap_action: - action: call-service - confirmation: - text: Sound horn? - service: subaru.horn - service_data: - vin: - type: button - - entity: '' - hold_action: - action: more-info - icon_height: 32px - icon: mdi:volume-off - name: Stop Horn - show_icon: true - show_name: true - show_state: false - tap_action: - action: call-service - confirmation: - text: Stop horn? - service: subaru.horn_stop - service_data: - vin: - type: button - type: horizontal-stack - - cards: - - type: button - hold_action: - action: more-info - icon: mdi:power - icon_height: 32px - name: Remote Start - show_icon: true - show_name: true - tap_action: - action: call-service - confirmation: - text: Remote Start? - service: subaru.remote_start - service_data: - vin: - - entity: '' - hold_action: - action: more-info - icon: mdi:stop - icon_height: 32px - name: Remote Stop - show_icon: true - show_name: true - tap_action: - action: call-service - confirmation: - text: Remote Stop? - service: subaru.remote_stop - service_data: - vin: - type: button - type: horizontal-stack + type: horizontal-stack - cards: - - cards: - - entity: '' - hold_action: - action: more-info - icon: mdi:battery-charging - icon_height: 32px - name: Begin Charging - show_icon: true - show_name: true - show_state: false - tap_action: - action: call-service - confirmation: - text: Begin Charging? - service: subaru.charge_start - service_data: - vin: - type: button - - entity: sensor.subaru_ev_battery_level - name: EV Battery Level - type: entity - type: horizontal-stack - - type: vertical-stack - cards: - - type: horizontal-stack - cards: - - type: entity - entity: binary_sensor.subaru_ev_charge_port - name: Plugged In - - type: conditional - conditions: - - entity: sensor.subaru_ev_time_to_full_charge - state_not: '1970-01-01T00:00:00' - card: - type: markdown - content: > - {% set time = - (as_timestamp(states.sensor.subaru_ev_time_to_full_charge.state) - - as_timestamp(now())) %} - - {% set hours = time // 3600 %} - - {% set minutes = time // 60 % 60 %} - - {% if int(hours) > 0 %} - - {{ int(hours) }} hours {% endif %}{{ int(minutes) }} - minutes - title: Time to Full Charge - type: vertical-stack - title: EV Functions + - entity: button.subaru_remote_start + icon_height: 48px + show_icon: true + show_name: false + tap_action: + action: call-service + confirmation: + text: Remote Start Car? + service: button.press + service_data: {} + target: + entity_id: button.subaru_remote_start + type: button + - type: button + tap_action: + action: call-service + confirmation: + text: Remote Stop Car? + service: button.press + service_data: {} + target: + entity_id: button.subaru_remote_stop + entity: button.subaru_remote_stop + show_state: false + show_name: false + icon_height: 48px + type: horizontal-stack + - type: entities + entities: + - entity: select.subaru_climate_preset + show_header_toggle: true - type: vertical-stack cards: - detail: -2 @@ -467,7 +410,7 @@ views: type: sensor - type: entity entity: binary_sensor.subaru_ignition - name: Subaru Ignition + name: ' ' title: Miscellaneous Data - card: type: glance @@ -507,14 +450,62 @@ views: state_filter: - 'on' show_empty: false + - type: vertical-stack + title: EV Functions + cards: + - cards: + - entity: button.subaru_charge_ev + hold_action: + action: more-info + show_icon: true + show_name: false + show_state: false + tap_action: + action: call-service + confirmation: + text: Begin Charging? + service: button.press + service_data: {} + target: + entity_id: button.subaru_charge_ev + type: button + icon_height: 48px + - entity: sensor.subaru_ev_battery_level + name: EV Battery Level + type: entity + type: horizontal-stack + - type: vertical-stack + cards: + - type: horizontal-stack + cards: + - type: entity + entity: binary_sensor.subaru_ev_charge_port + name: Plugged In + - type: conditional + conditions: + - entity: sensor.subaru_ev_time_to_full_charge + state_not: '1969-12-31T19:00:00' + card: + type: markdown + content: > + {% set time = + (as_timestamp(states.sensor.subaru_ev_time_to_full_charge.state) + - as_timestamp(now())) %} + + {% set hours = time // 3600 %} + + {% set minutes = time // 60 % 60 %} + + {% if int(hours) > 0 %} + + {{ int(hours) }} hours {% endif %}{{ int(minutes) }} + minutes + title: Time to Full Charge - type: map entities: - entity: device_tracker.subaru_location hours_to_show: 0 - title: Subaru Location - default_zoom: 14 - icon: '' - title: Subaru + default_zoom: 3 ```

\ No newline at end of file diff --git a/custom_components/subaru/__init__.py b/custom_components/subaru/__init__.py index 480d9ea..5d0e778 100644 --- a/custom_components/subaru/__init__.py +++ b/custom_components/subaru/__init__.py @@ -24,7 +24,8 @@ ENTRY_COORDINATOR, ENTRY_VEHICLES, FETCH_INTERVAL, - REMOTE_SERVICE_FETCH, + REMOTE_CLIMATE_PRESET_NAME, + REMOTE_SERVICE_REMOTE_START, SUPPORTED_PLATFORMS, UPDATE_INTERVAL, VEHICLE_API_GEN, @@ -32,21 +33,20 @@ VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_HAS_REMOTE_START, VEHICLE_HAS_SAFETY_SERVICE, + VEHICLE_LAST_FETCH, VEHICLE_LAST_UPDATE, VEHICLE_NAME, VEHICLE_VIN, ) from .remote_service import ( - SERVICES_THAT_NEED_FETCH, async_call_remote_service, get_supported_services, + refresh_subaru, update_subaru, ) _LOGGER = logging.getLogger(__name__) -REMOTE_SERVICE_SCHEMA = vol.Schema({vol.Required(VEHICLE_VIN): cv.string}) - async def async_setup(hass, base_config): """Do nothing since this integration does not support configuration.yml setup.""" @@ -57,7 +57,7 @@ async def async_setup(hass, base_config): async def async_setup_entry(hass, entry): """Set up Subaru from a config entry.""" config = entry.data - websession = aiohttp_client.async_get_clientsession(hass) + websession = aiohttp_client.async_create_clientsession(hass) # Backwards compatibility for configs made before v0.3.0 country = config.get(CONF_COUNTRY) @@ -119,18 +119,20 @@ async def async_update_data(): async def async_call_service(call): """Execute subaru service.""" vin = call.data[VEHICLE_VIN].upper() + arg = None + if call.service == REMOTE_SERVICE_REMOTE_START: + arg = call.data[REMOTE_CLIMATE_PRESET_NAME] if vin in vehicles: - if call.service != REMOTE_SERVICE_FETCH: - await async_call_remote_service( - hass, - controller, - call.service, - vehicles[vin], - entry.options.get(CONF_NOTIFICATION_OPTION), - ) - if call.service in SERVICES_THAT_NEED_FETCH: - await coordinator.async_refresh() + await async_call_remote_service( + hass, + controller, + call.service, + vehicles[vin], + arg, + entry.options.get(CONF_NOTIFICATION_OPTION), + ) + await coordinator.async_refresh() return hass.components.persistent_notification.create( @@ -141,9 +143,25 @@ async def async_call_service(call): supported_services = get_supported_services(vehicles) for service in supported_services: - hass.services.async_register( - DOMAIN, service, async_call_service, schema=REMOTE_SERVICE_SCHEMA - ) + if service == REMOTE_SERVICE_REMOTE_START: + hass.services.async_register( + DOMAIN, + service, + async_call_service, + schema=vol.Schema( + { + vol.Required(VEHICLE_VIN): cv.string, + vol.Required(REMOTE_CLIMATE_PRESET_NAME): cv.string, + } + ), + ) + else: + hass.services.async_register( + DOMAIN, + service, + async_call_service, + schema=vol.Schema({vol.Required(VEHICLE_VIN): cv.string}), + ) return True @@ -185,7 +203,7 @@ async def refresh_subaru_data(config_entry, vehicle_info, controller): await update_subaru(vehicle, controller) # Fetch data from Subaru servers - await controller.fetch(vin, force=True) + await refresh_subaru(vehicle, controller) # Update our local data that will go to entity states received_data = await controller.get_data(vin) @@ -206,5 +224,6 @@ def get_vehicle_info(controller, vin): VEHICLE_HAS_REMOTE_SERVICE: controller.get_remote_status(vin), VEHICLE_HAS_SAFETY_SERVICE: controller.get_safety_status(vin), VEHICLE_LAST_UPDATE: 0, + VEHICLE_LAST_FETCH: 0, } return info diff --git a/custom_components/subaru/binary_sensor.py b/custom_components/subaru/binary_sensor.py index 6c307af..d398d49 100644 --- a/custom_components/subaru/binary_sensor.py +++ b/custom_components/subaru/binary_sensor.py @@ -103,6 +103,12 @@ SENSOR_CLASS: DEVICE_CLASS_WINDOW, SENSOR_ON_VALUE: sc.WINDOW_OPEN, }, + { + SENSOR_TYPE: "Sunroof", + SENSOR_FIELD: sc.WINDOW_SUNROOF_STATUS, + SENSOR_CLASS: DEVICE_CLASS_WINDOW, + SENSOR_ON_VALUE: sc.WINDOW_OPEN, + }, ] # Binary Sensor data available to "Subaru Safety Plus" subscribers with PHEV vehicles diff --git a/custom_components/subaru/button.py b/custom_components/subaru/button.py new file mode 100644 index 0000000..b3a9e9e --- /dev/null +++ b/custom_components/subaru/button.py @@ -0,0 +1,123 @@ +"""Support for Subaru buttons.""" +import logging + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonEntity + +from . import DOMAIN as SUBARU_DOMAIN +from .const import ( + CONF_NOTIFICATION_OPTION, + ENTRY_CONTROLLER, + ENTRY_COORDINATOR, + ENTRY_VEHICLES, + ICONS, + REMOTE_SERVICE_CHARGE_START, + REMOTE_SERVICE_FETCH, + REMOTE_SERVICE_HORN, + REMOTE_SERVICE_HORN_STOP, + REMOTE_SERVICE_LIGHTS, + REMOTE_SERVICE_LIGHTS_STOP, + REMOTE_SERVICE_REMOTE_START, + REMOTE_SERVICE_REMOTE_STOP, + REMOTE_SERVICE_UPDATE, + VEHICLE_CLIMATE_SELECTED_PRESET, + VEHICLE_HAS_EV, + VEHICLE_HAS_REMOTE_SERVICE, + VEHICLE_HAS_REMOTE_START, +) +from .entity import SubaruEntity +from .remote_service import async_call_remote_service + +_LOGGER = logging.getLogger(__name__) + +BUTTON_TYPE = "type" +BUTTON_SERVICE = "service" + +G1_REMOTE_BUTTONS = [ + {BUTTON_TYPE: "Horn Start", BUTTON_SERVICE: REMOTE_SERVICE_HORN}, + {BUTTON_TYPE: "Horn Stop", BUTTON_SERVICE: REMOTE_SERVICE_HORN_STOP}, + {BUTTON_TYPE: "Lights Start", BUTTON_SERVICE: REMOTE_SERVICE_LIGHTS}, + {BUTTON_TYPE: "Lights Stop", BUTTON_SERVICE: REMOTE_SERVICE_LIGHTS_STOP}, + {BUTTON_TYPE: "Locate", BUTTON_SERVICE: REMOTE_SERVICE_UPDATE}, + {BUTTON_TYPE: "Refresh", BUTTON_SERVICE: REMOTE_SERVICE_FETCH}, +] + +RES_REMOTE_BUTTONS = [ + {BUTTON_TYPE: "Remote Start", BUTTON_SERVICE: REMOTE_SERVICE_REMOTE_START}, + {BUTTON_TYPE: "Remote Stop", BUTTON_SERVICE: REMOTE_SERVICE_REMOTE_STOP}, +] + +EV_REMOTE_BUTTONS = [ + {BUTTON_TYPE: "Charge EV", BUTTON_SERVICE: REMOTE_SERVICE_CHARGE_START} +] + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Subaru button by config_entry.""" + coordinator = hass.data[SUBARU_DOMAIN][config_entry.entry_id][ENTRY_COORDINATOR] + vehicle_info = hass.data[SUBARU_DOMAIN][config_entry.entry_id][ENTRY_VEHICLES] + entities = [] + for vin in vehicle_info: + entities.extend( + create_vehicle_buttons(vehicle_info[vin], coordinator, config_entry) + ) + async_add_entities(entities, True) + + +def create_vehicle_buttons(vehicle_info, coordinator, config_entry): + """Instantiate all available buttons for the vehicle.""" + buttons_to_add = [] + if vehicle_info[VEHICLE_HAS_REMOTE_SERVICE]: + buttons_to_add.extend(G1_REMOTE_BUTTONS) + + if vehicle_info[VEHICLE_HAS_REMOTE_START] or vehicle_info[VEHICLE_HAS_EV]: + buttons_to_add.extend(RES_REMOTE_BUTTONS) + + if vehicle_info[VEHICLE_HAS_EV]: + buttons_to_add.extend(EV_REMOTE_BUTTONS) + + return [ + SubaruButton( + vehicle_info, coordinator, config_entry, b[BUTTON_TYPE], b[BUTTON_SERVICE], + ) + for b in buttons_to_add + ] + + +class SubaruButton(SubaruEntity, ButtonEntity): + """Representation of a Subaru button.""" + + def __init__(self, vehicle_info, coordinator, config_entry, entity_type, service): + """Initialize the button for the vehicle.""" + super().__init__(vehicle_info, coordinator) + self.entity_type = entity_type + self.hass_type = BUTTON_DOMAIN + self.config_entry = config_entry + self.service = service + self.arg = None + + @property + def icon(self): + """Return the icon of the sensor.""" + if not self.device_class: + return ICONS.get(self.entity_type) + + async def async_press(self): + """Press the button.""" + _LOGGER.debug("%s button pressed for %s", self.entity_type, self.car_name) + arg = None + if self.service == REMOTE_SERVICE_REMOTE_START: + arg = self.coordinator.data.get(self.vin).get( + VEHICLE_CLIMATE_SELECTED_PRESET + ) + controller = self.hass.data[SUBARU_DOMAIN][self.config_entry.entry_id][ + ENTRY_CONTROLLER + ] + await async_call_remote_service( + self.hass, + controller, + self.service, + self.vehicle_info, + arg, + self.config_entry.options.get(CONF_NOTIFICATION_OPTION), + ) + await self.coordinator.async_refresh() diff --git a/custom_components/subaru/const.py b/custom_components/subaru/const.py index 23f6c22..9516d04 100644 --- a/custom_components/subaru/const.py +++ b/custom_components/subaru/const.py @@ -1,6 +1,8 @@ """Constants for the Subaru integration.""" from enum import Enum +import subarulink.const as sc + from homeassistant.const import Platform DOMAIN = "subaru" @@ -49,12 +51,17 @@ def get_by_value(cls, value): VEHICLE_HAS_REMOTE_SERVICE = "has_remote" VEHICLE_HAS_SAFETY_SERVICE = "has_safety" VEHICLE_LAST_UPDATE = "last_update" +VEHICLE_LAST_FETCH = "last_fetch" VEHICLE_STATUS = "status" +VEHICLE_CLIMATE = "climate" +VEHICLE_CLIMATE_SELECTED_PRESET = "preset_name" API_GEN_1 = "g1" API_GEN_2 = "g2" MANUFACTURER = "Subaru Corp." +ATTR_DOOR = "door" + REMOTE_SERVICE_FETCH = "fetch" REMOTE_SERVICE_UPDATE = "update" REMOTE_SERVICE_LOCK = "lock" @@ -66,12 +73,25 @@ def get_by_value(cls, value): REMOTE_SERVICE_REMOTE_START = "remote_start" REMOTE_SERVICE_REMOTE_STOP = "remote_stop" REMOTE_SERVICE_CHARGE_START = "charge_start" +REMOTE_CLIMATE_PRESET_NAME = "preset_name" + +SERVICE_UNLOCK_SPECIFIC_DOOR = "unlock_specific_door" +UNLOCK_DOOR_ALL = "all" +UNLOCK_DOOR_DRIVERS = "driver" +UNLOCK_DOOR_TAILGATE = "tailgate" +UNLOCK_VALID_DOORS = { + UNLOCK_DOOR_ALL: sc.ALL_DOORS, + UNLOCK_DOOR_DRIVERS: sc.DRIVERS_DOOR, + UNLOCK_DOOR_TAILGATE: sc.TAILGATE_DOOR, +} SUPPORTED_PLATFORMS = [ Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.LOCK, Platform.SENSOR, + Platform.BUTTON, + Platform.SELECT, ] ICONS = { @@ -79,4 +99,14 @@ def get_by_value(cls, value): "EV Range": "mdi:ev-station", "Odometer": "mdi:road-variant", "Range": "mdi:gas-station", + "Horn Start": "mdi:volume-high", + "Horn Stop": "mdi:volume-off", + "Lights Start": "mdi:lightbulb-on", + "Lights Stop": "mdi:lightbulb-off", + "Locate": "mdi:car-connected", + "Refresh": "mdi:refresh", + "Remote Start": "mdi:power", + "Remote Stop": "mdi:stop-circle-outline", + "Charge EV": "mdi:ev-station", + "Climate Preset": "mdi:thermometer-lines", } diff --git a/custom_components/subaru/lock.py b/custom_components/subaru/lock.py index fc5f2ae..8b38498 100644 --- a/custom_components/subaru/lock.py +++ b/custom_components/subaru/lock.py @@ -1,15 +1,22 @@ """Support for Subaru door locks.""" import logging +import voluptuous as vol + from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity from homeassistant.const import SERVICE_LOCK, SERVICE_UNLOCK +from homeassistant.helpers import config_validation as cv, entity_platform from . import DOMAIN as SUBARU_DOMAIN from .const import ( + ATTR_DOOR, CONF_NOTIFICATION_OPTION, ENTRY_CONTROLLER, ENTRY_COORDINATOR, ENTRY_VEHICLES, + SERVICE_UNLOCK_SPECIFIC_DOOR, + UNLOCK_DOOR_ALL, + UNLOCK_VALID_DOORS, VEHICLE_HAS_REMOTE_SERVICE, ) from .entity import SubaruEntity @@ -29,6 +36,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities.append(SubaruLock(vehicle, coordinator, controller, config_entry)) async_add_entities(entities, True) + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service( + SERVICE_UNLOCK_SPECIFIC_DOOR, + {vol.Required(ATTR_DOOR): cv.string}, + "async_unlock_specific_door", + ) + class SubaruLock(SubaruEntity, LockEntity): """ @@ -41,29 +56,44 @@ class SubaruLock(SubaruEntity, LockEntity): def __init__(self, vehicle_info, coordinator, controller, config_entry): """Initialize the locks for the vehicle.""" super().__init__(vehicle_info, coordinator) - self.entity_type = "Door Lock" + self.entity_type = "Door Locks" self.hass_type = LOCK_DOMAIN self.controller = controller self.config_entry = config_entry async def async_lock(self, **kwargs): """Send the lock command.""" - _LOGGER.debug("Locking doors for: %s", self.vin) + _LOGGER.debug("Locking doors for: %s", self.car_name) await async_call_remote_service( self.hass, self.controller, SERVICE_LOCK, self.vehicle_info, + None, self.config_entry.options.get(CONF_NOTIFICATION_OPTION), ) async def async_unlock(self, **kwargs): """Send the unlock command.""" - _LOGGER.debug("Unlocking doors for: %s", self.vin) + _LOGGER.debug("Unlocking doors for: %s", self.car_name) await async_call_remote_service( self.hass, self.controller, SERVICE_UNLOCK, self.vehicle_info, + UNLOCK_VALID_DOORS[UNLOCK_DOOR_ALL], self.config_entry.options.get(CONF_NOTIFICATION_OPTION), ) + + async def async_unlock_specific_door(self, door): + """Send the unlock command for a specified door.""" + _LOGGER.debug("Unlocking %s door for: %s", self, self.car_name) + if door in UNLOCK_VALID_DOORS: + await async_call_remote_service( + self.hass, + self.controller, + SERVICE_UNLOCK, + self.vehicle_info, + UNLOCK_VALID_DOORS[door], + self.config_entry.options.get(CONF_NOTIFICATION_OPTION), + ) diff --git a/custom_components/subaru/manifest.json b/custom_components/subaru/manifest.json index d2532af..beeee5e 100644 --- a/custom_components/subaru/manifest.json +++ b/custom_components/subaru/manifest.json @@ -4,8 +4,8 @@ "config_flow": true, "documentation": "https://github.com/G-Two/homeassistant-subaru", "issue_tracker": "https://github.com/G-Two/homeassistant-subaru/issues", - "requirements": ["subarulink==0.3.16"], + "requirements": ["subarulink==0.4.0"], "codeowners": ["@G-Two"], - "version": "0.5.2", + "version": "0.6.0", "iot_class": "cloud_polling" } diff --git a/custom_components/subaru/remote_service.py b/custom_components/subaru/remote_service.py index 77862ce..621cdd7 100644 --- a/custom_components/subaru/remote_service.py +++ b/custom_components/subaru/remote_service.py @@ -8,6 +8,7 @@ from .const import ( DOMAIN, + FETCH_INTERVAL, REMOTE_SERVICE_CHARGE_START, REMOTE_SERVICE_FETCH, REMOTE_SERVICE_HORN, @@ -16,12 +17,14 @@ REMOTE_SERVICE_LIGHTS_STOP, REMOTE_SERVICE_REMOTE_START, REMOTE_SERVICE_REMOTE_STOP, + REMOTE_SERVICE_UNLOCK, REMOTE_SERVICE_UPDATE, UPDATE_INTERVAL, VEHICLE_HAS_EV, VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_HAS_REMOTE_START, VEHICLE_HAS_SAFETY_SERVICE, + VEHICLE_LAST_FETCH, VEHICLE_LAST_UPDATE, VEHICLE_NAME, VEHICLE_VIN, @@ -39,8 +42,10 @@ ] -async def async_call_remote_service(hass, controller, cmd, vehicle_info, notify_option): - """Execute subarulink remote command with start/end notification.""" +async def async_call_remote_service( + hass, controller, cmd, vehicle_info, arg, notify_option +): + """Execute subarulink remote command with optional start/end notification.""" car_name = vehicle_info[VEHICLE_NAME] vin = vehicle_info[VEHICLE_VIN] notify = NotificationOptions.get_by_value(notify_option) @@ -58,8 +63,18 @@ async def async_call_remote_service(hass, controller, cmd, vehicle_info, notify_ success = await update_subaru( vehicle_info, controller, override_interval=True ) + elif cmd in [REMOTE_SERVICE_REMOTE_START, REMOTE_SERVICE_UNLOCK]: + success = await getattr(controller, cmd)(vin, arg) + elif cmd == REMOTE_SERVICE_FETCH: + pass else: success = await getattr(controller, cmd)(vin) + + if cmd in SERVICES_THAT_NEED_FETCH: + success = await refresh_subaru( + vehicle_info, controller, override_interval=True + ) + except SubaruException as err: err_msg = err.message @@ -114,3 +129,17 @@ async def update_subaru(vehicle, controller, override_interval=False): vehicle[VEHICLE_LAST_UPDATE] = cur_time return success + + +async def refresh_subaru(vehicle, controller, override_interval=False): + """Refresh data from Subaru servers.""" + cur_time = time.time() + last_fetch = vehicle[VEHICLE_LAST_FETCH] + vin = vehicle[VEHICLE_VIN] + success = None + + if (cur_time - last_fetch) > FETCH_INTERVAL or override_interval: + success = await controller.fetch(vin, force=True) + vehicle[VEHICLE_LAST_FETCH] = cur_time + + return success diff --git a/custom_components/subaru/select.py b/custom_components/subaru/select.py new file mode 100644 index 0000000..b4f460d --- /dev/null +++ b/custom_components/subaru/select.py @@ -0,0 +1,78 @@ +"""Support for Subaru selectors.""" +import logging + +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity +from homeassistant.helpers.restore_state import RestoreEntity + +from . import DOMAIN as SUBARU_DOMAIN +from .const import ( + ENTRY_COORDINATOR, + ENTRY_VEHICLES, + VEHICLE_CLIMATE, + VEHICLE_CLIMATE_SELECTED_PRESET, + VEHICLE_HAS_EV, + VEHICLE_HAS_REMOTE_START, +) +from .entity import SubaruEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Subaru selectors by config_entry.""" + coordinator = hass.data[SUBARU_DOMAIN][config_entry.entry_id][ENTRY_COORDINATOR] + vehicle_info = hass.data[SUBARU_DOMAIN][config_entry.entry_id][ENTRY_VEHICLES] + climate_select = [] + for vin in vehicle_info: + if ( + vehicle_info[vin][VEHICLE_HAS_REMOTE_START] + or vehicle_info[vin][VEHICLE_HAS_EV] + ): + climate_select.append( + SubaruClimateSelect( + "Climate Preset", vehicle_info[vin], coordinator, config_entry + ) + ) + async_add_entities(climate_select, True) + + +class SubaruClimateSelect(SubaruEntity, SelectEntity, RestoreEntity): + """Representation of a Subaru climate preset selector entity.""" + + def __init__(self, type, vehicle_info, coordinator, config_entry): + """Initialize the selector for the vehicle.""" + super().__init__(vehicle_info, coordinator) + self.entity_type = type + self.hass_type = SELECT_DOMAIN + self.config_entry = config_entry + self._attr_current_option = None + + @property + def options(self): + """Return a set of selectable options.""" + vehicle_data = self.coordinator.data.get(self.vin) + if vehicle_data: + preset_data = vehicle_data.get(VEHICLE_CLIMATE) + if isinstance(preset_data, list): + return [preset["name"] for preset in preset_data] + + async def async_added_to_hass(self): + """Restore previous state of this selector.""" + await super().async_added_to_hass() + state = await self.async_get_last_state() + if state and state.state in self.options: + self._attr_current_option = state.state + self.coordinator.data.get(self.vin)[ + VEHICLE_CLIMATE_SELECTED_PRESET + ] = state.state + self.async_write_ha_state() + + async def async_select_option(self, option): + """Change the selected option.""" + _LOGGER.debug("Selecting %s climate preset for %s", option, self.car_name) + if option in self.options: + self._attr_current_option = option + self.coordinator.data.get(self.vin)[ + VEHICLE_CLIMATE_SELECTED_PRESET + ] = option + self.async_write_ha_state() diff --git a/custom_components/subaru/services.yaml b/custom_components/subaru/services.yaml index 41436a9..5e0c2b3 100644 --- a/custom_components/subaru/services.yaml +++ b/custom_components/subaru/services.yaml @@ -2,7 +2,7 @@ lights: description: > Flash the lights of the vehicle. The vehicle is identified via the vin - (see below). + (see below). This service is deprecated and will be removed - Use button service instead. fields: vin: description: > @@ -12,7 +12,7 @@ lights: lights_stop: description: > Stop flashing the lights of the vehicle. The vehicle is identified via the vin - (see below). + (see below). This service is deprecated and will be removed - Use button service instead. fields: vin: description: > @@ -22,7 +22,7 @@ lights_stop: horn: description: > Sound the horn of the vehicle. The vehicle is identified via the vin - (see below). + (see below). This service is deprecated and will be removed - Use button service instead. fields: vin: description: > @@ -32,7 +32,7 @@ horn: horn_stop: description: > Stop sounding the horn of the vehicle. The vehicle is identified via the vin - (see below). + (see below). This service is deprecated and will be removed - Use button service instead. fields: vin: description: > @@ -42,17 +42,21 @@ horn_stop: remote_start: description: > Start the engine and climate control of the vehicle. Uses the climate control settings saved. The vehicle is identified via - the vin (see below). + the vin (see below). This service is deprecated and will be removed - Use button service instead. fields: vin: description: > The vehicle identification number (VIN) of the vehicle, 17 characters example: JF2ABCDE6L0000001 + preset_name: + description: > + The name of the climate control preset desired + example: Full Heat remote_stop: description: > Stop the engine and climate control of the vehicle. The vehicle is identified via the vin - (see below). + (see below). This service is deprecated and will be removed - Use button service instead. fields: vin: description: > @@ -62,7 +66,7 @@ remote_stop: charge_start: description: > Starts EV charging. This cannot be stopped remotely. The vehicle is identified via the vin - (see below). + (see below). This service is deprecated and will be removed - Use button service instead. fields: vin: description: > @@ -72,7 +76,7 @@ charge_start: update: description: > Sends request to vehicle to update data. The vehicle is identified via the vin - (see below). + (see below). This service is deprecated and will be removed - Use button service instead. fields: vin: description: > @@ -82,9 +86,29 @@ update: fetch: description: > Refreshes data (does not request update from vehicle). The vehicle is identified via the vin - (see below). + (see below). This service is deprecated and will be removed - Use button service instead. fields: vin: description: > The vehicle identification number (VIN) of the vehicle, 17 characters example: JF2ABCDE6L0000001 + +unlock_specific_door: + name: Unlock Specific Door + description: Unlocks only the door specified + target: + entity: + domain: lock + integration: subaru + fields: + door: + name: Door + description: "One of the following: 'all', 'driver', 'tailgate'" + example: driver + required: true + selector: + select: + options: + - "all" + - "driver" + - "tailgate" diff --git a/hacs.json b/hacs.json index 89b3317..35cd576 100644 --- a/hacs.json +++ b/hacs.json @@ -2,6 +2,6 @@ "name": "Subaru (HACS)", "content_in_root": false, "render_readme": true, - "homeassistant": "0.118.0", + "homeassistant": "2021.12", "iot_class": "Cloud Polling" } diff --git a/requirements.test.txt b/requirements.test.txt index b4bdd77..2739bfe 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -2,4 +2,4 @@ flake8 pytest pytest-cov pytest-homeassistant-custom-component -subarulink==0.3.16 +subarulink==0.4.0 diff --git a/tests/api_responses.py b/tests/api_responses.py index a179f52..01be0c5 100644 --- a/tests/api_responses.py +++ b/tests/api_responses.py @@ -11,11 +11,14 @@ API_GEN_1, API_GEN_2, VEHICLE_API_GEN, + VEHICLE_CLIMATE, + VEHICLE_CLIMATE_SELECTED_PRESET, VEHICLE_HAS_EV, VEHICLE_HAS_REMOTE_SERVICE, VEHICLE_HAS_REMOTE_START, VEHICLE_HAS_SAFETY_SERVICE, VEHICLE_NAME, + VEHICLE_STATUS, VEHICLE_VIN, ) @@ -54,7 +57,95 @@ } VEHICLE_STATUS_EV = { - "status": { + VEHICLE_CLIMATE_SELECTED_PRESET: None, + VEHICLE_CLIMATE: [ + { + "name": "Auto", + "runTimeMinutes": "10", + "climateZoneFrontTemp": "74", + "climateZoneFrontAirMode": "AUTO", + "climateZoneFrontAirVolume": "AUTO", + "outerAirCirculation": "auto", + "heatedRearWindowActive": "false", + "airConditionOn": "false", + "heatedSeatFrontLeft": "off", + "heatedSeatFrontRight": "off", + "startConfiguration": "START_ENGINE_ALLOW_KEY_IN_IGNITION", + "canEdit": "true", + "disabled": "false", + "vehicleType": "gas", + "presetType": "subaruPreset", + }, + { + "name": "Full Cool", + "runTimeMinutes": "10", + "climateZoneFrontTemp": "60", + "climateZoneFrontAirMode": "feet_face_balanced", + "climateZoneFrontAirVolume": "7", + "airConditionOn": "true", + "heatedSeatFrontLeft": "OFF", + "heatedSeatFrontRight": "OFF", + "heatedRearWindowActive": "false", + "outerAirCirculation": "outsideAir", + "startConfiguration": "START_ENGINE_ALLOW_KEY_IN_IGNITION", + "canEdit": "true", + "disabled": "true", + "vehicleType": "gas", + "presetType": "subaruPreset", + }, + { + "name": "Full Heat", + "runTimeMinutes": "10", + "climateZoneFrontTemp": "85", + "climateZoneFrontAirMode": "feet_window", + "climateZoneFrontAirVolume": "7", + "airConditionOn": "false", + "heatedSeatFrontLeft": "high_heat", + "heatedSeatFrontRight": "high_heat", + "heatedRearWindowActive": "true", + "outerAirCirculation": "outsideAir", + "startConfiguration": "START_ENGINE_ALLOW_KEY_IN_IGNITION", + "canEdit": "true", + "disabled": "true", + "vehicleType": "gas", + "presetType": "subaruPreset", + }, + { + "name": "Full Cool", + "runTimeMinutes": "10", + "climateZoneFrontTemp": "60", + "climateZoneFrontAirMode": "feet_face_balanced", + "climateZoneFrontAirVolume": "7", + "airConditionOn": "true", + "heatedSeatFrontLeft": "OFF", + "heatedSeatFrontRight": "OFF", + "heatedRearWindowActive": "false", + "outerAirCirculation": "outsideAir", + "startConfiguration": "START_CLIMATE_CONTROL_ONLY_ALLOW_KEY_IN_IGNITION", + "canEdit": "true", + "disabled": "true", + "vehicleType": "phev", + "presetType": "subaruPreset", + }, + { + "name": "Full Heat", + "runTimeMinutes": "10", + "climateZoneFrontTemp": "85", + "climateZoneFrontAirMode": "feet_window", + "climateZoneFrontAirVolume": "7", + "airConditionOn": "false", + "heatedSeatFrontLeft": "high_heat", + "heatedSeatFrontRight": "high_heat", + "heatedRearWindowActive": "true", + "outerAirCirculation": "outsideAir", + "startConfiguration": "START_CLIMATE_CONTROL_ONLY_ALLOW_KEY_IN_IGNITION", + "canEdit": "true", + "disabled": "true", + "vehicleType": "phev", + "presetType": "subaruPreset", + }, + ], + VEHICLE_STATUS: { "AVG_FUEL_CONSUMPTION": 2.3, "BATTERY_VOLTAGE": "12.0", "DISTANCE_TO_EMPTY_FUEL": 707, @@ -126,7 +217,7 @@ "heading": 170, "latitude": 40.0, "longitude": -100.0, - } + }, } VEHICLE_STATUS_EV_INVALID_ITEMS = { @@ -376,6 +467,7 @@ "WINDOW_FRONT_RIGHT_STATUS": "unavailable", "WINDOW_REAR_LEFT_STATUS": "unavailable", "WINDOW_REAR_RIGHT_STATUS": "unavailable", + "WINDOW_SUNROOF_STATUS": "unavailable", "VEHICLE_STATE_TYPE": "unavailable", "DOOR_FRONT_LEFT_POSITION": "unavailable", "DOOR_FRONT_RIGHT_POSITION": "unavailable", @@ -392,6 +484,7 @@ "WINDOW_FRONT_RIGHT_STATUS": None, "WINDOW_REAR_LEFT_STATUS": None, "WINDOW_REAR_RIGHT_STATUS": None, + "WINDOW_SUNROOF_STATUS": None, "VEHICLE_STATE_TYPE": "off", "DOOR_FRONT_LEFT_POSITION": "off", "DOOR_FRONT_RIGHT_POSITION": "off", diff --git a/tests/conftest.py b/tests/conftest.py index 4c11fae..be94eb0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ from pytest_homeassistant_custom_component.common import ( MockConfigEntry, async_fire_time_changed, + mock_restore_cache, ) from subarulink.const import COUNTRY_USA @@ -27,6 +28,7 @@ from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME +from homeassistant.core import State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -47,6 +49,8 @@ MOCK_API_GET_DATA = f"{MOCK_API}get_data" MOCK_API_UPDATE = f"{MOCK_API}update" MOCK_API_FETCH = f"{MOCK_API}fetch" +MOCK_API_REMOTE_START = f"{MOCK_API}remote_start" +MOCK_API_LIGHTS = f"{MOCK_API}lights" TEST_USERNAME = "user@email.com" TEST_PASSWORD = "password" @@ -95,8 +99,14 @@ async def setup_subaru_integration( vehicle_status=None, connect_effect=None, fetch_effect=None, + saved_cache=None, ): """Create Subaru entry.""" + if saved_cache: + mock_restore_cache( + hass, (State("select.test_vehicle_2_climate_preset", "Full Heat"),), + ) + assert await async_setup_component(hass, HA_DOMAIN, {}) assert await async_setup_component(hass, DOMAIN, {}) @@ -150,3 +160,20 @@ async def ev_entry(hass, enable_custom_integrations): assert hass.config_entries.async_get_entry(entry.entry_id) assert entry.state is ConfigEntryState.LOADED return entry + + +@pytest.fixture +async def ev_entry_with_saved_climate(hass, enable_custom_integrations): + """Create Subaru EV entity but with saved climate preset.""" + entry = await setup_subaru_integration( + hass, + vehicle_list=[TEST_VIN_2_EV], + vehicle_data=VEHICLE_DATA[TEST_VIN_2_EV], + vehicle_status=VEHICLE_STATUS_EV, + saved_cache=True, + ) + assert DOMAIN in hass.config_entries.async_domains() + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert hass.config_entries.async_get_entry(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + return entry diff --git a/tests/test_button.py b/tests/test_button.py new file mode 100644 index 0000000..9f05548 --- /dev/null +++ b/tests/test_button.py @@ -0,0 +1,59 @@ +"""Test Subaru buttons.""" + +from unittest.mock import patch + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID + +from .conftest import MOCK_API_FETCH, MOCK_API_LIGHTS, MOCK_API_REMOTE_START + +REMOTE_START_BUTTON = "button.test_vehicle_2_remote_start" +REMOTE_LIGHTS_BUTTON = "button.test_vehicle_2_lights_start" +REMOTE_REFRESH_BUTTON = "button.test_vehicle_2_refresh" + + +async def test_device_exists(hass, ev_entry): + """Test subaru button entity exists.""" + entity_registry = await hass.helpers.entity_registry.async_get_registry() + entry = entity_registry.async_get(REMOTE_START_BUTTON) + assert entry + + +async def test_button_with_fetch(hass, ev_entry): + """Test subaru button function.""" + with patch(MOCK_API_REMOTE_START) as mock_remote_start, patch( + MOCK_API_FETCH + ) as mock_fetch: + await hass.services.async_call( + BUTTON_DOMAIN, "press", {ATTR_ENTITY_ID: REMOTE_START_BUTTON}, blocking=True + ) + await hass.async_block_till_done() + mock_remote_start.assert_called_once() + mock_fetch.assert_called_once() + + +async def test_button_without_fetch(hass, ev_entry): + """Test subaru button function.""" + with patch(MOCK_API_LIGHTS) as mock_lights, patch(MOCK_API_FETCH) as mock_fetch: + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: REMOTE_LIGHTS_BUTTON}, + blocking=True, + ) + await hass.async_block_till_done() + mock_lights.assert_called_once() + mock_fetch.assert_not_called() + + +async def test_button_fetch(hass, ev_entry): + """Test subaru button function.""" + with patch(MOCK_API_FETCH) as mock_fetch: + await hass.services.async_call( + BUTTON_DOMAIN, + "press", + {ATTR_ENTITY_ID: REMOTE_REFRESH_BUTTON}, + blocking=True, + ) + await hass.async_block_till_done() + mock_fetch.assert_called_once() diff --git a/tests/test_lock.py b/tests/test_lock.py index 6d4e936..eedfbcb 100644 --- a/tests/test_lock.py +++ b/tests/test_lock.py @@ -2,6 +2,13 @@ from unittest.mock import patch +from custom_components.subaru.const import ( + ATTR_DOOR, + DOMAIN as SUBARU_DOMAIN, + SERVICE_UNLOCK_SPECIFIC_DOOR, + UNLOCK_DOOR_DRIVERS, + UNLOCK_VALID_DOORS, +) from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK @@ -9,7 +16,7 @@ MOCK_API_LOCK = f"{MOCK_API}lock" MOCK_API_UNLOCK = f"{MOCK_API}unlock" -DEVICE_ID = "lock.test_vehicle_2_door_lock" +DEVICE_ID = "lock.test_vehicle_2_door_locks" async def test_device_exists(hass, ev_entry): @@ -37,3 +44,16 @@ async def test_unlock(hass, ev_entry): ) await hass.async_block_till_done() mock_unlock.assert_called_once() + + +async def test_unlock_specific_door(hass, ev_entry): + """Test subaru unlock specific door function.""" + with patch(MOCK_API_UNLOCK) as mock_unlock: + await hass.services.async_call( + SUBARU_DOMAIN, + SERVICE_UNLOCK_SPECIFIC_DOOR, + {ATTR_ENTITY_ID: DEVICE_ID, ATTR_DOOR: UNLOCK_DOOR_DRIVERS}, + blocking=True, + ) + await hass.async_block_till_done() + mock_unlock.assert_called_once() diff --git a/tests/test_select.py b/tests/test_select.py new file mode 100644 index 0000000..83ed03c --- /dev/null +++ b/tests/test_select.py @@ -0,0 +1,26 @@ +"""Test Subaru select.""" + +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, SERVICE_SELECT_OPTION + +DEVICE_ID = "select.test_vehicle_2_climate_preset" + + +async def test_device_exists(hass, ev_entry): + """Test subaru select entity exists.""" + entity_registry = await hass.helpers.entity_registry.async_get_registry() + entry = entity_registry.async_get(DEVICE_ID) + assert entry + await hass.async_block_till_done() + + +async def test_select(hass, ev_entry_with_saved_climate): + """Test subaru select function.""" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: DEVICE_ID, ATTR_OPTION: "Full Heat"}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get(DEVICE_ID).state == "Full Heat" diff --git a/tests/test_services.py b/tests/test_services.py index 9100f77..9f63117 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -6,8 +6,10 @@ from custom_components.subaru.const import ( DOMAIN, + REMOTE_CLIMATE_PRESET_NAME, REMOTE_SERVICE_FETCH, REMOTE_SERVICE_HORN, + REMOTE_SERVICE_REMOTE_START, REMOTE_SERVICE_UPDATE, VEHICLE_VIN, ) @@ -18,6 +20,7 @@ from tests.conftest import MOCK_API, MOCK_API_FETCH, MOCK_API_GET_DATA, MOCK_API_UPDATE MOCK_API_HORN = f"{MOCK_API}horn" +MOCK_API_REMOTE_START = f"{MOCK_API}remote_start" async def test_remote_service_horn(hass, ev_entry): @@ -30,6 +33,22 @@ async def test_remote_service_horn(hass, ev_entry): mock_horn.assert_called_once() +async def test_remote_service_start(hass, ev_entry): + """Test remote service horn.""" + with patch(MOCK_API_REMOTE_START) as mock_remote_start, patch( + MOCK_API_FETCH + ) as mock_fetch: + await hass.services.async_call( + DOMAIN, + REMOTE_SERVICE_REMOTE_START, + {VEHICLE_VIN: TEST_VIN_2_EV, REMOTE_CLIMATE_PRESET_NAME: "Full Cool"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_remote_start.assert_called_once() + mock_fetch.assert_called_once() + + async def test_remote_service_fetch(hass, ev_entry): """Test remote service fetch.""" with patch(MOCK_API_GET_DATA, return_value=VEHICLE_STATUS_EV), patch(