From 4ad35e842150f7fef363d5c98b173846a62ec788 Mon Sep 17 00:00:00 2001 From: Assaf Inbal Date: Mon, 28 Jul 2025 14:18:43 +0300 Subject: [PATCH] Add charging binary sensor to `ituran` (#149562) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/ituran/__init__.py | 1 + .../components/ituran/binary_sensor.py | 75 +++++++++++++++++++ tests/components/ituran/conftest.py | 2 + .../ituran/snapshots/test_binary_sensor.ambr | 50 +++++++++++++ tests/components/ituran/test_binary_sensor.py | 73 ++++++++++++++++++ 5 files changed, 201 insertions(+) create mode 100644 homeassistant/components/ituran/binary_sensor.py create mode 100644 tests/components/ituran/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/ituran/test_binary_sensor.py diff --git a/homeassistant/components/ituran/__init__.py b/homeassistant/components/ituran/__init__.py index bf9cff238cd..41392c5cee1 100644 --- a/homeassistant/components/ituran/__init__.py +++ b/homeassistant/components/ituran/__init__.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant from .coordinator import IturanConfigEntry, IturanDataUpdateCoordinator PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.SENSOR, ] diff --git a/homeassistant/components/ituran/binary_sensor.py b/homeassistant/components/ituran/binary_sensor.py new file mode 100644 index 00000000000..8a18cca8968 --- /dev/null +++ b/homeassistant/components/ituran/binary_sensor.py @@ -0,0 +1,75 @@ +"""Binary sensors for Ituran vehicles.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from propcache.api import cached_property +from pyituran import Vehicle + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import IturanConfigEntry +from .coordinator import IturanDataUpdateCoordinator +from .entity import IturanBaseEntity + + +@dataclass(frozen=True, kw_only=True) +class IturanBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Ituran binary sensor entity.""" + + value_fn: Callable[[Vehicle], bool] + supported_fn: Callable[[Vehicle], bool] = lambda _: True + + +BINARY_SENSOR_TYPES: list[IturanBinarySensorEntityDescription] = [ + IturanBinarySensorEntityDescription( + key="is_charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + value_fn=lambda vehicle: vehicle.is_charging, + supported_fn=lambda vehicle: vehicle.is_electric_vehicle, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: IturanConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Ituran binary sensors from config entry.""" + coordinator = config_entry.runtime_data + async_add_entities( + IturanBinarySensor(coordinator, vehicle.license_plate, description) + for vehicle in coordinator.data.values() + for description in BINARY_SENSOR_TYPES + if description.supported_fn(vehicle) + ) + + +class IturanBinarySensor(IturanBaseEntity, BinarySensorEntity): + """Ituran binary sensor.""" + + entity_description: IturanBinarySensorEntityDescription + + def __init__( + self, + coordinator: IturanDataUpdateCoordinator, + license_plate: str, + description: IturanBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator, license_plate, description.key) + self.entity_description = description + + @cached_property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self.vehicle) diff --git a/tests/components/ituran/conftest.py b/tests/components/ituran/conftest.py index 1cb922b94e9..7582a2a6645 100644 --- a/tests/components/ituran/conftest.py +++ b/tests/components/ituran/conftest.py @@ -65,9 +65,11 @@ class MockVehicle: if is_electric_vehicle: self.battery_level = 42 self.battery_range = 150 + self.is_charging = True else: self.battery_level = 0 self.battery_range = 0 + self.is_charging = False @pytest.fixture diff --git a/tests/components/ituran/snapshots/test_binary_sensor.ambr b/tests/components/ituran/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..fed9f2b487c --- /dev/null +++ b/tests/components/ituran/snapshots/test_binary_sensor.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_ev_binary_sensor[True][binary_sensor.mock_model_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.mock_model_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'ituran', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '12345678-is_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_ev_binary_sensor[True][binary_sensor.mock_model_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'mock model Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_model_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/ituran/test_binary_sensor.py b/tests/components/ituran/test_binary_sensor.py new file mode 100644 index 00000000000..1eb2fca6f4c --- /dev/null +++ b/tests/components/ituran/test_binary_sensor.py @@ -0,0 +1,73 @@ +"""Test the Ituran binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pyituran.exceptions import IturanApiError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ituran.const import UPDATE_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("mock_ituran", [True], indirect=True) +async def test_ev_binary_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state of sensor.""" + with patch("homeassistant.components.ituran.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize("mock_ituran", [True], indirect=True) +async def test_ev_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ituran: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor is marked as unavailable when we can't reach the Ituran service.""" + entities = [ + "binary_sensor.mock_model_charging", + ] + + await setup_integration(hass, mock_config_entry) + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + mock_ituran.get_vehicles.side_effect = IturanApiError + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + mock_ituran.get_vehicles.side_effect = None + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity_id in entities: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE