Add binary_sensor platform to Subaru (#177907)

This commit is contained in:
John Pettitt
2026-08-07 08:11:44 +02:00
committed by GitHub
parent a198bb0ecd
commit 007f75cdce
7 changed files with 2006 additions and 0 deletions
@@ -0,0 +1,295 @@
"""Support for Subaru binary sensors."""
from collections.abc import Callable
from dataclasses import dataclass
from functools import partial
from typing import Any, override
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
GEN_2_AND_NEWER,
VEHICLE_API_GEN,
VEHICLE_FEATURES,
VEHICLE_HAS_EV,
VEHICLE_HEALTH,
VEHICLE_STATUS,
VEHICLE_VIN,
)
from .coordinator import SubaruConfigEntry, SubaruDataUpdateCoordinator
from .entity import SubaruCoordinatorEntity
# Keys returned by subarulink controller.get_data() inside vehicle_status.
DOOR_POSITION_KEYS: dict[str, str] = {
"DOOR_FRONT_LEFT_POSITION": "door_front_left",
"DOOR_FRONT_RIGHT_POSITION": "door_front_right",
"DOOR_REAR_LEFT_POSITION": "door_rear_left",
"DOOR_REAR_RIGHT_POSITION": "door_rear_right",
"DOOR_BOOT_POSITION": "door_boot",
"DOOR_ENGINE_HOOD_POSITION": "door_engine_hood",
}
WINDOW_STATUS_KEYS: dict[str, str] = {
"WINDOW_FRONT_LEFT_STATUS": "window_front_left",
"WINDOW_FRONT_RIGHT_STATUS": "window_front_right",
"WINDOW_REAR_LEFT_STATUS": "window_rear_left",
"WINDOW_REAR_RIGHT_STATUS": "window_rear_right",
"WINDOW_SUNROOF_STATUS": "window_sunroof",
}
LOCK_STATUS_KEYS: dict[str, str] = {
"LOCK_FRONT_LEFT_STATUS": "lock_status_front_left",
"LOCK_FRONT_RIGHT_STATUS": "lock_status_front_right",
"LOCK_REAR_LEFT_STATUS": "lock_status_rear_left",
"LOCK_REAR_RIGHT_STATUS": "lock_status_rear_right",
"LOCK_BOOT_STATUS": "lock_status_boot",
}
# EV_IS_PLUGGED_IN values meaning connected; other known values mean not
# connected.
EV_PLUGGED_IN_STATES = frozenset({"CHARGING", "LOCKED_CONNECTED", "UNLOCKED_CONNECTED"})
API_KEY_EV_IS_PLUGGED_IN = "EV_IS_PLUGGED_IN"
API_KEY_EV_CHARGER_STATE_TYPE = "EV_CHARGER_STATE_TYPE"
EV_CHARGING_STATE = "CHARGING"
# vehicle_health response shape (see integration debug diagnostics).
HEALTH_ISTROUBLE = "ISTROUBLE"
HEALTH_FEATURES = "FEATURES"
# Subaru MIL (Malfunction Indicator Lamp) feature codes -> translation keys.
# ATF_MIL is a transmission temperature warning, not fluid level.
MIL_TRANSLATION_KEYS: dict[str, str] = {
"SRS_MIL": "mil_srs",
"AWD_MIL": "mil_awd",
"ABS_MIL": "mil_abs",
"ATF_MIL": "mil_atf",
"BSDRCT_MIL": "mil_bsdrct",
"CEL_MIL": "mil_cel",
"EBD_MIL": "mil_ebd",
"EPB_MIL": "mil_epb",
"EOL_MIL": "mil_eol",
"ESS_MIL": "mil_ess",
"ISS_MIL": "mil_iss",
"OPL_MIL": "mil_opl",
"EPAS_MIL": "mil_epas",
"RAB_MIL": "mil_rab",
"TEL_MIL": "mil_tel",
"TPMS_MIL": "mil_tpms",
"VDC_MIL": "mil_vdc",
"WASH_MIL": "mil_wash",
"SRH_MIL": "mil_srh",
}
# "CLOSED" (doors) or "CLOSE" (windows) means closed.
OPENING_CLOSED_VALUES = frozenset({"CLOSED", "CLOSE"})
# Sentinel values meaning "no data"; compared case-insensitively.
UNKNOWN_STATUSES = frozenset({"UNKNOWN", "UNAVAILABLE", "NOT_EQUIPPED"})
@dataclass(frozen=True, kw_only=True)
class SubaruBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes a Subaru binary sensor entity."""
is_on_fn: Callable[[dict[str, Any]], bool | None]
def _vehicle_status_value(vehicle_data: dict[str, Any], api_key: str) -> str | None:
"""Return the normalized vehicle_status value for api_key, or None if missing/unknown."""
status = (vehicle_data.get(VEHICLE_STATUS) or {}).get(api_key)
if status is None:
return None
status = status.upper()
return None if status in UNKNOWN_STATUSES else status
def _opening_is_on(vehicle_data: dict[str, Any], api_key: str) -> bool | None:
"""Whether a door/window field is open."""
value = _vehicle_status_value(vehicle_data, api_key)
return None if value is None else value not in OPENING_CLOSED_VALUES
def _lock_is_on(vehicle_data: dict[str, Any], api_key: str) -> bool | None:
"""Whether a lock field is unlocked."""
value = _vehicle_status_value(vehicle_data, api_key)
return None if value is None else value != "LOCKED"
def _mil_trouble(vehicle_data: dict[str, Any], feature: str) -> bool | None:
"""Return vehicle_health.FEATURES[feature].ISTROUBLE, or None if not reported."""
features = (vehicle_data.get(VEHICLE_HEALTH) or {}).get(HEALTH_FEATURES) or {}
feature_health = features.get(feature)
if not feature_health or HEALTH_ISTROUBLE not in feature_health:
return None
return bool(feature_health[HEALTH_ISTROUBLE])
# Static descriptions for entities that are created for every Gen2+ vehicle.
# MIL diagnostics are built dynamically below based on vehicle_features.
BINARY_SENSORS: tuple[SubaruBinarySensorEntityDescription, ...] = (
*(
SubaruBinarySensorEntityDescription(
key=api_key,
translation_key=trans_key,
device_class=BinarySensorDeviceClass.DOOR,
is_on_fn=partial(_opening_is_on, api_key=api_key),
)
for api_key, trans_key in DOOR_POSITION_KEYS.items()
),
*(
SubaruBinarySensorEntityDescription(
key=api_key,
translation_key=trans_key,
device_class=BinarySensorDeviceClass.WINDOW,
is_on_fn=partial(_opening_is_on, api_key=api_key),
)
for api_key, trans_key in WINDOW_STATUS_KEYS.items()
),
*(
SubaruBinarySensorEntityDescription(
key=api_key,
translation_key=trans_key,
device_class=BinarySensorDeviceClass.LOCK,
is_on_fn=partial(_lock_is_on, api_key=api_key),
)
for api_key, trans_key in LOCK_STATUS_KEYS.items()
),
)
OVERALL_HEALTH_BINARY_SENSOR = SubaruBinarySensorEntityDescription(
key="health_istrouble",
translation_key="health_istrouble",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
is_on_fn=lambda d: (
None
if not (health := d.get(VEHICLE_HEALTH)) or HEALTH_ISTROUBLE not in health
else bool(health[HEALTH_ISTROUBLE])
),
)
EV_PLUG_BINARY_SENSOR = SubaruBinarySensorEntityDescription(
key=API_KEY_EV_IS_PLUGGED_IN,
translation_key="ev_is_plugged_in",
device_class=BinarySensorDeviceClass.PLUG,
is_on_fn=lambda d: (
None
if (v := _vehicle_status_value(d, API_KEY_EV_IS_PLUGGED_IN)) is None
else v in EV_PLUGGED_IN_STATES
),
)
EV_CHARGING_BINARY_SENSOR = SubaruBinarySensorEntityDescription(
key=API_KEY_EV_CHARGER_STATE_TYPE,
translation_key="is_charging",
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
entity_registry_enabled_default=False,
is_on_fn=lambda d: (
None
if (v := _vehicle_status_value(d, API_KEY_EV_CHARGER_STATE_TYPE)) is None
else v == EV_CHARGING_STATE
),
)
def _build_mil_descriptions(
features: list[str],
) -> list[SubaruBinarySensorEntityDescription]:
"""Return MIL descriptions for MIL feature codes that the vehicle reports.
Built once at setup; a MIL code that starts appearing later (partial
first poll, or a code only reported once triggered) needs a reload.
"""
return [
SubaruBinarySensorEntityDescription(
key=feature,
translation_key=MIL_TRANSLATION_KEYS[feature],
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
# Disabled by default to avoid ~19 mostly-off entries per
# vehicle; the overall health rollup stays enabled.
entity_registry_enabled_default=False,
is_on_fn=partial(_mil_trouble, feature=feature),
)
for feature in features
if feature in MIL_TRANSLATION_KEYS
]
def _has_data(
description: SubaruBinarySensorEntityDescription, vehicle_status: dict[str, Any]
) -> bool:
"""Whether a door/window/lock description should be created.
Doors report even on an empty vehicle_status (a failed fetch), so
they're excluded only when explicitly NOT_EQUIPPED. Windows/locks/EV
fields are omitted entirely when unsupported, so presence decides.
"""
if description.key in DOOR_POSITION_KEYS:
return vehicle_status.get(description.key, "").upper() != "NOT_EQUIPPED"
return description.key in vehicle_status
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SubaruConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Subaru binary sensors by config_entry."""
coordinator = config_entry.runtime_data.coordinator
vehicle_info = config_entry.runtime_data.vehicles
entities: list[SubaruBinarySensor] = []
for info in vehicle_info.values():
# Doors/windows/locks/health are only reported on Gen2+ vehicles.
if info[VEHICLE_API_GEN] not in GEN_2_AND_NEWER:
continue
vehicle_data = (coordinator.data or {}).get(info[VEHICLE_VIN]) or {}
vehicle_status = vehicle_data.get(VEHICLE_STATUS) or {}
descriptions: list[SubaruBinarySensorEntityDescription] = [
description
for description in BINARY_SENSORS
if _has_data(description, vehicle_status)
]
descriptions.append(OVERALL_HEALTH_BINARY_SENSOR)
if info[VEHICLE_HAS_EV]:
if EV_PLUG_BINARY_SENSOR.key in vehicle_status:
descriptions.append(EV_PLUG_BINARY_SENSOR)
if EV_CHARGING_BINARY_SENSOR.key in vehicle_status:
descriptions.append(EV_CHARGING_BINARY_SENSOR)
features = vehicle_data.get(VEHICLE_FEATURES) or []
descriptions.extend(_build_mil_descriptions(features))
entities.extend(
SubaruBinarySensor(info, coordinator, description)
for description in descriptions
)
async_add_entities(entities)
class SubaruBinarySensor(SubaruCoordinatorEntity, BinarySensorEntity):
"""Representation of a Subaru binary sensor."""
entity_description: SubaruBinarySensorEntityDescription
def __init__(
self,
vehicle_info: dict[str, Any],
coordinator: SubaruDataUpdateCoordinator,
description: SubaruBinarySensorEntityDescription,
) -> None:
"""Initialize the binary sensor."""
super().__init__(vehicle_info, coordinator, description.key)
self.entity_description = description
@property
@override
def is_on(self) -> bool | None:
"""Return True if the sensor is on (open / unlocked / has trouble)."""
return self.entity_description.is_on_fn(self.coordinator.data[self.vin])
+5
View File
@@ -25,6 +25,7 @@ VEHICLE_HAS_SAFETY_SERVICE = "has_safety"
VEHICLE_LAST_UPDATE = "last_update"
VEHICLE_STATUS = "vehicle_status"
VEHICLE_HEALTH = "vehicle_health"
VEHICLE_FEATURES = "vehicle_features"
# Synthetic keys for sensors that don't read a single field directly; used
# as both unique_id suffix and translation_key, so they must stay stable
@@ -38,9 +39,13 @@ API_GEN_1 = "g1"
API_GEN_2 = "g2"
API_GEN_3 = "g3"
API_GEN_4 = "g4"
# Generations that report vehicle_status/vehicle_health data, used to gate
# binary_sensor entity creation.
GEN_2_AND_NEWER = (API_GEN_2, API_GEN_3, API_GEN_4)
MANUFACTURER = "Subaru"
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.DEVICE_TRACKER,
Platform.LOCK,
@@ -47,6 +47,122 @@
}
},
"entity": {
"binary_sensor": {
"door_boot": {
"name": "Tailgate"
},
"door_engine_hood": {
"name": "Hood"
},
"door_front_left": {
"name": "Door front left"
},
"door_front_right": {
"name": "Door front right"
},
"door_rear_left": {
"name": "Door rear left"
},
"door_rear_right": {
"name": "Door rear right"
},
"ev_is_plugged_in": {
"name": "EV plug"
},
"health_istrouble": {
"name": "Vehicle health"
},
"is_charging": {
"name": "Charging"
},
"lock_status_boot": {
"name": "Lock status tailgate"
},
"lock_status_front_left": {
"name": "Lock status front left"
},
"lock_status_front_right": {
"name": "Lock status front right"
},
"lock_status_rear_left": {
"name": "Lock status rear left"
},
"lock_status_rear_right": {
"name": "Lock status rear right"
},
"mil_abs": {
"name": "ABS warning"
},
"mil_atf": {
"name": "Transmission temperature warning"
},
"mil_awd": {
"name": "AWD warning"
},
"mil_bsdrct": {
"name": "Blind spot and rear cross traffic warning"
},
"mil_cel": {
"name": "Check engine"
},
"mil_ebd": {
"name": "Electronic brake force distribution warning"
},
"mil_eol": {
"name": "Engine oil level warning"
},
"mil_epas": {
"name": "Electric power steering warning"
},
"mil_epb": {
"name": "Electric parking brake warning"
},
"mil_ess": {
"name": "EyeSight warning"
},
"mil_iss": {
"name": "Idle stop & start warning"
},
"mil_opl": {
"name": "Oil pressure warning"
},
"mil_rab": {
"name": "Reverse automatic braking warning"
},
"mil_srh": {
"name": "Steering responsive headlights warning"
},
"mil_srs": {
"name": "Airbag warning"
},
"mil_tel": {
"name": "Telematics warning"
},
"mil_tpms": {
"name": "Tire pressure warning"
},
"mil_vdc": {
"name": "Vehicle dynamics control warning"
},
"mil_wash": {
"name": "Washer fluid warning"
},
"window_front_left": {
"name": "Window front left"
},
"window_front_right": {
"name": "Window front right"
},
"window_rear_left": {
"name": "Window rear left"
},
"window_rear_right": {
"name": "Window rear right"
},
"window_sunroof": {
"name": "Sunroof"
}
},
"button": {
"remote_start": {
"name": "Remote start"
+14
View File
@@ -8,6 +8,7 @@ from homeassistant.components.subaru.const import (
API_GEN_3,
API_GEN_4,
VEHICLE_API_GEN,
VEHICLE_FEATURES,
VEHICLE_HAS_EV,
VEHICLE_HAS_REMOTE_SERVICE,
VEHICLE_HAS_REMOTE_START,
@@ -92,6 +93,11 @@ VEHICLE_STATUS_EV = {
"EV_STATE_OF_CHARGE_MODE": "EV_MODE",
"EV_STATE_OF_CHARGE_PERCENT": 20,
"EV_TIME_TO_FULLY_CHARGED_UTC": MOCK_DATETIME,
"LOCK_BOOT_STATUS": "LOCKED",
"LOCK_FRONT_LEFT_STATUS": "LOCKED",
"LOCK_FRONT_RIGHT_STATUS": "LOCKED",
"LOCK_REAR_LEFT_STATUS": "LOCKED",
"LOCK_REAR_RIGHT_STATUS": "LOCKED",
"ODOMETER": 1234,
"TIMESTAMP": 1595560000.0,
"TRANSMISSION_MODE": "UNKNOWN",
@@ -111,7 +117,15 @@ VEHICLE_STATUS_EV = {
},
VEHICLE_HEALTH: {
"RECOMMENDED_TIRE_PRESSURE": {"FRONT_TIRES": 35, "REAR_TIRES": 33},
# subarulink ORs MIL ISTROUBLE into the top-level one, so it can't
# be False here while a feature below is True.
"ISTROUBLE": True,
"FEATURES": {
"TPMS_MIL": {"ISTROUBLE": False, "ONDATE": None},
"CEL_MIL": {"ISTROUBLE": True, "ONDATE": None},
},
},
VEHICLE_FEATURES: ["TPMS_MIL", "CEL_MIL"],
}
File diff suppressed because it is too large Load Diff
@@ -10,7 +10,22 @@
}),
'data': list([
dict({
'vehicle_features': list([
'TPMS_MIL',
'CEL_MIL',
]),
'vehicle_health': dict({
'FEATURES': dict({
'CEL_MIL': dict({
'ISTROUBLE': True,
'ONDATE': None,
}),
'TPMS_MIL': dict({
'ISTROUBLE': False,
'ONDATE': None,
}),
}),
'ISTROUBLE': True,
'RECOMMENDED_TIRE_PRESSURE': dict({
'FRONT_TIRES': 35,
'REAR_TIRES': 33,
@@ -34,6 +49,11 @@
'EV_STATE_OF_CHARGE_PERCENT': 20,
'EV_TIME_TO_FULLY_CHARGED_UTC': '2020-07-24T03:06:40+00:00',
'LATITUDE': '**REDACTED**',
'LOCK_BOOT_STATUS': 'LOCKED',
'LOCK_FRONT_LEFT_STATUS': 'LOCKED',
'LOCK_FRONT_RIGHT_STATUS': 'LOCKED',
'LOCK_REAR_LEFT_STATUS': 'LOCKED',
'LOCK_REAR_RIGHT_STATUS': 'LOCKED',
'LONGITUDE': '**REDACTED**',
'ODOMETER': '**REDACTED**',
'TIMESTAMP': 1595560000.0,
@@ -67,7 +87,22 @@
'username': '**REDACTED**',
}),
'data': dict({
'vehicle_features': list([
'TPMS_MIL',
'CEL_MIL',
]),
'vehicle_health': dict({
'FEATURES': dict({
'CEL_MIL': dict({
'ISTROUBLE': True,
'ONDATE': None,
}),
'TPMS_MIL': dict({
'ISTROUBLE': False,
'ONDATE': None,
}),
}),
'ISTROUBLE': True,
'RECOMMENDED_TIRE_PRESSURE': dict({
'FRONT_TIRES': 35,
'REAR_TIRES': 33,
@@ -91,6 +126,11 @@
'EV_STATE_OF_CHARGE_PERCENT': 20,
'EV_TIME_TO_FULLY_CHARGED_UTC': '2020-07-24T03:06:40+00:00',
'LATITUDE': '**REDACTED**',
'LOCK_BOOT_STATUS': 'LOCKED',
'LOCK_FRONT_LEFT_STATUS': 'LOCKED',
'LOCK_FRONT_RIGHT_STATUS': 'LOCKED',
'LOCK_REAR_LEFT_STATUS': 'LOCKED',
'LOCK_REAR_RIGHT_STATUS': 'LOCKED',
'LONGITUDE': '**REDACTED**',
'ODOMETER': '**REDACTED**',
'TIMESTAMP': 1595560000.0,
@@ -0,0 +1,464 @@
"""Test Subaru binary sensors."""
import copy
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.subaru.binary_sensor import (
BINARY_SENSORS,
EV_CHARGING_BINARY_SENSOR,
EV_PLUG_BINARY_SENSOR,
LOCK_STATUS_KEYS,
MIL_TRANSLATION_KEYS,
OVERALL_HEALTH_BINARY_SENSOR,
)
from homeassistant.components.subaru.const import DOMAIN, VEHICLE_STATUS
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .api_responses import (
TEST_VIN_1_G1,
TEST_VIN_2_EV,
TEST_VIN_3_G3,
TEST_VIN_4_G4,
VEHICLE_DATA,
VEHICLE_STATUS_EV,
VEHICLE_STATUS_G3,
)
from .conftest import setup_subaru_config_entry
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
subaru_config_entry: MockConfigEntry,
) -> None:
"""Snapshot all binary sensors created for an EV vehicle."""
with patch(
"homeassistant.components.subaru.PLATFORMS",
[Platform.BINARY_SENSOR],
):
await setup_subaru_config_entry(hass, subaru_config_entry)
await snapshot_platform(
hass, entity_registry, snapshot, subaru_config_entry.entry_id
)
@pytest.mark.parametrize("feature", ["TPMS_MIL", "CEL_MIL"])
@pytest.mark.usefixtures("ev_entry")
async def test_mil_entities_disabled_by_default(
entity_registry: er.EntityRegistry,
feature: str,
) -> None:
"""MIL entities are created for reported MIL features and disabled by default."""
entity_id = entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_2_EV}_{feature}"
)
assert entity_id is not None
entry = entity_registry.async_get(entity_id)
assert entry is not None
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
assert entry.translation_key == MIL_TRANSLATION_KEYS[feature]
@pytest.mark.parametrize(
"key",
[desc.key for desc in BINARY_SENSORS]
+ [OVERALL_HEALTH_BINARY_SENSOR.key, EV_PLUG_BINARY_SENSOR.key],
)
async def test_no_binary_sensors_for_g1(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
subaru_config_entry: MockConfigEntry,
key: str,
) -> None:
"""Gen1 vehicles do not get any binary sensors (no door/lock/health data)."""
await setup_subaru_config_entry(
hass,
subaru_config_entry,
vehicle_list=[TEST_VIN_1_G1],
vehicle_data=VEHICLE_DATA[TEST_VIN_1_G1],
)
assert (
entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_1_G1}_{key}"
)
is None
)
async def test_no_ev_plug_binary_sensor_for_g3(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
subaru_config_entry: MockConfigEntry,
) -> None:
"""Non-EV vehicles do not get the EV plug binary sensor."""
await setup_subaru_config_entry(
hass,
subaru_config_entry,
vehicle_list=[TEST_VIN_3_G3],
vehicle_data=VEHICLE_DATA[TEST_VIN_3_G3],
vehicle_status=VEHICLE_STATUS_G3,
)
assert (
entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN,
DOMAIN,
f"{TEST_VIN_3_G3}_{EV_PLUG_BINARY_SENSOR.key}",
)
is None
)
@pytest.mark.parametrize("key", list(LOCK_STATUS_KEYS))
async def test_no_lock_sensors_when_unsupported(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
subaru_config_entry: MockConfigEntry,
key: str,
) -> None:
"""Lock sensors aren't created for a vehicle whose status omits them.
VEHICLE_STATUS_G3 has no LOCK_* keys, matching a real vehicle without
lock-status support -- subarulink omits these fields entirely rather
than reporting them as unknown.
"""
await setup_subaru_config_entry(
hass,
subaru_config_entry,
vehicle_list=[TEST_VIN_3_G3],
vehicle_data=VEHICLE_DATA[TEST_VIN_3_G3],
vehicle_status=VEHICLE_STATUS_G3,
)
assert (
entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_3_G3}_{key}"
)
is None
)
@pytest.mark.parametrize("not_equipped", ["NOT_EQUIPPED", "not_equipped"])
async def test_door_not_equipped_gets_no_entity(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
subaru_config_entry: MockConfigEntry,
not_equipped: str,
) -> None:
"""A door reported as NOT_EQUIPPED gets no entity, unlike a failed fetch.
Unlike windows/locks, doors are always present in vehicle_status, so
a per-door NOT_EQUIPPED value (not the key's absence) is what signals
the trim genuinely lacks that sensor. Checked case-insensitively.
"""
vehicle_status = copy.deepcopy(VEHICLE_STATUS_EV)
vehicle_status[VEHICLE_STATUS]["DOOR_ENGINE_HOOD_POSITION"] = not_equipped
await setup_subaru_config_entry(
hass, subaru_config_entry, vehicle_status=vehicle_status
)
assert (
entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN,
DOMAIN,
f"{TEST_VIN_2_EV}_DOOR_ENGINE_HOOD_POSITION",
)
is None
)
assert (
entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN,
DOMAIN,
f"{TEST_VIN_2_EV}_DOOR_FRONT_LEFT_POSITION",
)
is not None
)
async def test_no_ev_charging_sensor_when_unsupported(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
subaru_config_entry: MockConfigEntry,
) -> None:
"""EV charging isn't created for an EV that doesn't report it, unlike EV plug."""
vehicle_status = copy.deepcopy(VEHICLE_STATUS_EV)
del vehicle_status[VEHICLE_STATUS]["EV_CHARGER_STATE_TYPE"]
await setup_subaru_config_entry(
hass, subaru_config_entry, vehicle_status=vehicle_status
)
assert (
entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN,
DOMAIN,
f"{TEST_VIN_2_EV}_{EV_CHARGING_BINARY_SENSOR.key}",
)
is None
)
assert (
entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN,
DOMAIN,
f"{TEST_VIN_2_EV}_{EV_PLUG_BINARY_SENSOR.key}",
)
is not None
)
async def test_overall_health_unknown_without_vehicle_health(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
subaru_config_entry: MockConfigEntry,
) -> None:
"""Overall vehicle health is `unknown` when the API has not yet returned health data."""
await setup_subaru_config_entry(
hass,
subaru_config_entry,
vehicle_list=[TEST_VIN_3_G3],
vehicle_data=VEHICLE_DATA[TEST_VIN_3_G3],
vehicle_status=VEHICLE_STATUS_G3,
)
overall = entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_3_G3}_health_istrouble"
)
assert overall is not None
state = hass.states.get(overall)
assert state is not None
assert state.state == STATE_UNKNOWN
async def test_binary_sensors_created_for_g4(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
subaru_config_entry: MockConfigEntry,
) -> None:
"""Gen4 vehicles get binary sensors, same as Gen2/Gen3."""
await setup_subaru_config_entry(
hass,
subaru_config_entry,
vehicle_list=[TEST_VIN_4_G4],
vehicle_data=VEHICLE_DATA[TEST_VIN_4_G4],
vehicle_status=VEHICLE_STATUS_G3,
)
assert (
entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_4_G4}_health_istrouble"
)
is not None
)
@pytest.mark.usefixtures("ev_entry")
async def test_ev_charging_disabled_by_default(
entity_registry: er.EntityRegistry,
) -> None:
"""The EV charging sensor is disabled by default, unlike the EV plug sensor."""
entity_id = entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN,
DOMAIN,
f"{TEST_VIN_2_EV}_{EV_CHARGING_BINARY_SENSOR.key}",
)
assert entity_id is not None
entry = entity_registry.async_get(entity_id)
assert entry is not None
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
@pytest.mark.parametrize("key", ["health_istrouble", "DOOR_FRONT_LEFT_POSITION"])
async def test_entities_unavailable_when_vehicle_data_fetch_fails(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
subaru_config_entry: MockConfigEntry,
key: str,
) -> None:
"""Setup survives a vehicle whose data fetch fails; its entities go unavailable.
_refresh_subaru_data only adds coordinator.data[vin] when the per-vehicle
API call succeeds, so a failure must not crash setup for descriptions
that are created unconditionally (e.g. the door/health sensors).
"""
await setup_subaru_config_entry(hass, subaru_config_entry, vehicle_status={})
assert subaru_config_entry.state is ConfigEntryState.LOADED
entity_id = entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_2_EV}_{key}"
)
assert entity_id is not None
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
async def test_no_window_or_lock_entities_when_vehicle_data_fetch_fails(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
subaru_config_entry: MockConfigEntry,
) -> None:
"""Unlike doors, windows/locks aren't created when the initial fetch fails."""
await setup_subaru_config_entry(hass, subaru_config_entry, vehicle_status={})
assert (
entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_2_EV}_WINDOW_FRONT_LEFT_STATUS"
)
is None
)
@pytest.mark.parametrize(
("status", "expected_state"),
[
("CLOSED", STATE_OFF),
("CLOSE", STATE_OFF),
("closed", STATE_OFF),
("OPEN", STATE_ON),
("VENTED", STATE_ON),
("UNKNOWN", STATE_UNKNOWN),
("UNAVAILABLE", STATE_UNKNOWN),
],
)
async def test_door_state(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
subaru_config_entry: MockConfigEntry,
status: str,
expected_state: str,
) -> None:
"""Door state reflects the raw status, case-insensitively; sentinels are unknown."""
vehicle_status = copy.deepcopy(VEHICLE_STATUS_EV)
vehicle_status[VEHICLE_STATUS]["DOOR_FRONT_LEFT_POSITION"] = status
await setup_subaru_config_entry(
hass, subaru_config_entry, vehicle_status=vehicle_status
)
entity_id = entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_2_EV}_DOOR_FRONT_LEFT_POSITION"
)
assert entity_id is not None
state = hass.states.get(entity_id)
assert state is not None
assert state.state == expected_state
@pytest.mark.parametrize(
("status", "expected_state"),
[
("LOCKED", STATE_OFF),
("UNLOCKED", STATE_ON),
],
)
async def test_lock_sensor_state(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
subaru_config_entry: MockConfigEntry,
status: str,
expected_state: str,
) -> None:
"""Lock-status sensor is on when unlocked."""
vehicle_status = copy.deepcopy(VEHICLE_STATUS_EV)
vehicle_status[VEHICLE_STATUS]["LOCK_FRONT_LEFT_STATUS"] = status
await setup_subaru_config_entry(
hass, subaru_config_entry, vehicle_status=vehicle_status
)
entity_id = entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_2_EV}_LOCK_FRONT_LEFT_STATUS"
)
assert entity_id is not None
state = hass.states.get(entity_id)
assert state is not None
assert state.state == expected_state
@pytest.mark.parametrize(
("status", "expected_state"),
[
("CHARGING", STATE_ON),
("LOCKED_CONNECTED", STATE_ON),
("UNLOCKED_CONNECTED", STATE_ON),
("UNPLUGGED", STATE_OFF),
],
)
async def test_ev_plug_state(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
subaru_config_entry: MockConfigEntry,
status: str,
expected_state: str,
) -> None:
"""EV plug sensor is on for any documented connected state."""
vehicle_status = copy.deepcopy(VEHICLE_STATUS_EV)
vehicle_status[VEHICLE_STATUS]["EV_IS_PLUGGED_IN"] = status
await setup_subaru_config_entry(
hass, subaru_config_entry, vehicle_status=vehicle_status
)
entity_id = entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_2_EV}_EV_IS_PLUGGED_IN"
)
assert entity_id is not None
state = hass.states.get(entity_id)
assert state is not None
assert state.state == expected_state
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.parametrize(
("status", "expected_state"),
[
("CHARGING", STATE_ON),
("NOT_CHARGING", STATE_OFF),
("UNPLUGGED", STATE_OFF),
],
)
async def test_ev_charging_state(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
subaru_config_entry: MockConfigEntry,
status: str,
expected_state: str,
) -> None:
"""EV charging sensor is on only while actively CHARGING."""
vehicle_status = copy.deepcopy(VEHICLE_STATUS_EV)
vehicle_status[VEHICLE_STATUS]["EV_CHARGER_STATE_TYPE"] = status
await setup_subaru_config_entry(
hass, subaru_config_entry, vehicle_status=vehicle_status
)
entity_id = entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_2_EV}_EV_CHARGER_STATE_TYPE"
)
assert entity_id is not None
state = hass.states.get(entity_id)
assert state is not None
assert state.state == expected_state
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ev_entry")
async def test_mil_sensor_state(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""MIL sensor reflects the per-feature ISTROUBLE flag."""
on_entity = entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_2_EV}_CEL_MIL"
)
off_entity = entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, f"{TEST_VIN_2_EV}_TPMS_MIL"
)
assert on_entity is not None
assert off_entity is not None
on_state = hass.states.get(on_entity)
off_state = hass.states.get(off_entity)
assert on_state is not None
assert off_state is not None
assert on_state.state == STATE_ON
assert off_state.state == STATE_OFF