From e00068625cd50ea1eda5d547606602132032b1ee Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 30 Sep 2026 10:46:59 +0200 Subject: [PATCH] Add binary_sensor platform to Vitesy (#183700) --- homeassistant/components/vitesy/__init__.py | 2 +- .../components/vitesy/binary_sensor.py | 98 +++++++++++++++++++ .../vitesy/snapshots/test_binary_sensor.ambr | 52 ++++++++++ tests/components/vitesy/test_binary_sensor.py | 84 ++++++++++++++++ tests/components/vitesy/test_sensor.py | 7 +- 5 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/vitesy/binary_sensor.py create mode 100644 tests/components/vitesy/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/vitesy/test_binary_sensor.py diff --git a/homeassistant/components/vitesy/__init__.py b/homeassistant/components/vitesy/__init__.py index 18dc17866799..74d28608752c 100644 --- a/homeassistant/components/vitesy/__init__.py +++ b/homeassistant/components/vitesy/__init__.py @@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant from .coordinator import VitesyConfigEntry, VitesyDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: VitesyConfigEntry) -> bool: diff --git a/homeassistant/components/vitesy/binary_sensor.py b/homeassistant/components/vitesy/binary_sensor.py new file mode 100644 index 000000000000..a2bc9e095eea --- /dev/null +++ b/homeassistant/components/vitesy/binary_sensor.py @@ -0,0 +1,98 @@ +"""Binary sensor platform for the Vitesy integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from aiovitesy.api import VitesyDevice + +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 LOGGER +from .coordinator import VitesyConfigEntry, VitesyDataUpdateCoordinator +from .entity import VitesyEntity + +PARALLEL_UPDATES = 0 + + +def _status_flag(reading_id: str) -> Callable[[VitesyDevice], bool | None]: + """Return a value function for a boolean status_data reading.""" + + def _value(device: VitesyDevice) -> bool | None: + for entry in device.measurement.get("status_data", ()): + if entry.get("id") == reading_id: + value = entry.get("value") + if isinstance(value, bool): + return value + LOGGER.warning( + "Ignoring non-boolean value for reading %s on %s: %r", + reading_id, + device.name, + value, + ) + return None + return None + + return _value + + +@dataclass(frozen=True, kw_only=True) +class VitesyBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a Vitesy binary sensor entity.""" + + value_fn: Callable[[VitesyDevice], bool | None] + + +BINARY_SENSORS: tuple[VitesyBinarySensorEntityDescription, ...] = ( + VitesyBinarySensorEntityDescription( + key="battery_charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=_status_flag("charging"), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: VitesyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Vitesy binary sensors from a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + VitesyBinarySensor(coordinator, device_id, description) + for device_id, device in coordinator.data.items() + for description in BINARY_SENSORS + if description.value_fn(device) is not None + ) + + +class VitesyBinarySensor(VitesyEntity, BinarySensorEntity): + """Representation of a Vitesy binary sensor.""" + + entity_description: VitesyBinarySensorEntityDescription + + def __init__( + self, + coordinator: VitesyDataUpdateCoordinator, + device_id: str, + description: VitesyBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator, device_id) + self.entity_description = description + self._attr_unique_id = f"{device_id}_{description.key}" + + @property + @override + def is_on(self) -> bool | None: + """Return True when the device reports the condition as active.""" + return self.entity_description.value_fn(self.device) diff --git a/tests/components/vitesy/snapshots/test_binary_sensor.ambr b/tests/components/vitesy/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..8f715da3bf98 --- /dev/null +++ b/tests/components/vitesy/snapshots/test_binary_sensor.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.kitchen_shelfy_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.kitchen_shelfy_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'vitesy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '80:65:99:34:F9:B4_battery_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.kitchen_shelfy_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'battery_charging', + : 'Kitchen Shelfy Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.kitchen_shelfy_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vitesy/test_binary_sensor.py b/tests/components/vitesy/test_binary_sensor.py new file mode 100644 index 000000000000..0bf4a4f5e926 --- /dev/null +++ b/tests/components/vitesy/test_binary_sensor.py @@ -0,0 +1,84 @@ +"""Test the Vitesy binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from aiovitesy.api import VitesyDevice +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.vitesy.coordinator import UPDATE_INTERVAL +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import DEVICE_ID + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +BATTERY_CHARGING = "binary_sensor.kitchen_shelfy_charging" + + +def _set_charging(device: VitesyDevice, value: object) -> None: + """Replace the charging reading in the device's status data.""" + device.measurement = { + **device.measurement, + "status_data": [{"id": "charging", "value": value}], + } + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_vitesy_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.vitesy.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.parametrize( + ("value", "state"), + [ + pytest.param(True, STATE_ON, id="charging"), + pytest.param("yes", STATE_UNKNOWN, id="non_boolean"), + ], +) +async def test_battery_charging_update( + hass: HomeAssistant, + mock_vitesy_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_devices: dict[str, VitesyDevice], + freezer: FrozenDateTimeFactory, + value: object, + state: str, +) -> None: + """Test the charging state follows the latest status data.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get(BATTERY_CHARGING).state == STATE_OFF + + _set_charging(mock_devices[DEVICE_ID], value) + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(BATTERY_CHARGING).state == state + + +async def test_battery_charging_not_created_when_not_reported( + hass: HomeAssistant, + mock_vitesy_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_devices: dict[str, VitesyDevice], +) -> None: + """Test no charging entity is created for a device that doesn't report it.""" + mock_devices[DEVICE_ID].measurement = {"score": 0.5} + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(BATTERY_CHARGING) is None diff --git a/tests/components/vitesy/test_sensor.py b/tests/components/vitesy/test_sensor.py index 4463739b1d8d..179de35b3f37 100644 --- a/tests/components/vitesy/test_sensor.py +++ b/tests/components/vitesy/test_sensor.py @@ -1,7 +1,7 @@ """Test the Vitesy sensor platform.""" from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from aiovitesy.api import VitesyDevice from freezegun.api import FrozenDateTimeFactory @@ -9,7 +9,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.vitesy.coordinator import UPDATE_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -31,7 +31,8 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - await setup_integration(hass, mock_config_entry) + with patch("homeassistant.components.vitesy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)