diff --git a/homeassistant/components/nexblue/binary_sensor.py b/homeassistant/components/nexblue/binary_sensor.py new file mode 100644 index 000000000000..5b253294781f --- /dev/null +++ b/homeassistant/components/nexblue/binary_sensor.py @@ -0,0 +1,91 @@ +"""Binary sensors for the NexBlue integration.""" + +from typing import override + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import NexBlueConfigEntry, NexBlueDataUpdateCoordinator + +BINARY_SENSOR_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="is_lock", + translation_key="is_lock", + device_class=BinarySensorDeviceClass.LOCK, + entity_category=EntityCategory.DIAGNOSTIC, + ), + BinarySensorEntityDescription( + key="is_disable", + translation_key="is_disable", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NexBlueConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up NexBlue binary sensors for every discovered charger.""" + coordinator = entry.runtime_data + async_add_entities( + NexBlueBinarySensor(coordinator, serial_number, description) + for serial_number in coordinator.data + for description in BINARY_SENSOR_DESCRIPTIONS + ) + + +class NexBlueBinarySensor( + CoordinatorEntity[NexBlueDataUpdateCoordinator], BinarySensorEntity +): + """Expose NexBlue charger boolean telemetry.""" + + _attr_has_entity_name = True + entity_description: BinarySensorEntityDescription + + def __init__( + self, + coordinator: NexBlueDataUpdateCoordinator, + serial_number: str, + description: BinarySensorEntityDescription, + ) -> None: + """Initialize a binary sensor for one charger metric.""" + super().__init__(coordinator) + self._serial_number = serial_number + self.entity_description = description + self._attr_unique_id = f"{serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_number)}, + manufacturer="NexBlue", + name=serial_number, + serial_number=serial_number, + ) + + @property + @override + def available(self) -> bool: + """Return whether this charger is currently reachable.""" + return ( + super().available + and self.coordinator.data.get(self._serial_number) is not None + ) + + @property + @override + def is_on(self) -> bool: + """Return the binary sensor state.""" + status = self.coordinator.data[self._serial_number] + assert status is not None + if self.entity_description.key == "is_disable": + return not status.is_disable + return not status.is_lock diff --git a/homeassistant/components/nexblue/const.py b/homeassistant/components/nexblue/const.py index b158ea663197..f857b66ca7c7 100644 --- a/homeassistant/components/nexblue/const.py +++ b/homeassistant/components/nexblue/const.py @@ -9,5 +9,9 @@ DOMAIN = "nexblue" CONF_REFRESH_TOKEN = "refresh_token" DEFAULT_API_URL = "https://api.nexblue.com/third_party" LOGGER = logging.getLogger(__package__) -PLATFORMS = [Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, +] UPDATE_INTERVAL = timedelta(minutes=1) diff --git a/homeassistant/components/nexblue/icons.json b/homeassistant/components/nexblue/icons.json index 76fb9f90be14..ba0775c3516b 100755 --- a/homeassistant/components/nexblue/icons.json +++ b/homeassistant/components/nexblue/icons.json @@ -1,5 +1,13 @@ { "entity": { + "binary_sensor": { + "is_disable": { + "default": "mdi:power", + "state": { + "off": "mdi:power-off" + } + } + }, "sensor": { "access_level": { "default": "mdi:account-lock" diff --git a/homeassistant/components/nexblue/strings.json b/homeassistant/components/nexblue/strings.json index caaf0f16d717..063e69f1750b 100644 --- a/homeassistant/components/nexblue/strings.json +++ b/homeassistant/components/nexblue/strings.json @@ -23,6 +23,10 @@ } }, "entity": { + "binary_sensor": { + "is_disable": { "name": "Charging enabled" }, + "is_lock": { "name": "Cable lock state" } + }, "sensor": { "access_level": { "name": "Access level", diff --git a/tests/components/nexblue/snapshots/test_binary_sensor.ambr b/tests/components/nexblue/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..65e78430659e --- /dev/null +++ b/tests/components/nexblue/snapshots/test_binary_sensor.ambr @@ -0,0 +1,102 @@ +# serializer version: 1 +# name: test_binary_sensor_entities_snapshot[binary_sensor.nb123456_cable_lock_state-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.nb123456_cable_lock_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cable lock state', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cable lock state', + 'platform': 'nexblue', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'is_lock', + 'unique_id': 'NB123456_is_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities_snapshot[binary_sensor.nb123456_cable_lock_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'lock', + : 'NB123456 Cable lock state', + }), + 'context': , + 'entity_id': 'binary_sensor.nb123456_cable_lock_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_entities_snapshot[binary_sensor.nb123456_charging_enabled-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.nb123456_charging_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging enabled', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging enabled', + 'platform': 'nexblue', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'is_disable', + 'unique_id': 'NB123456_is_disable', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities_snapshot[binary_sensor.nb123456_charging_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NB123456 Charging enabled', + }), + 'context': , + 'entity_id': 'binary_sensor.nb123456_charging_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/nexblue/test_binary_sensor.py b/tests/components/nexblue/test_binary_sensor.py new file mode 100644 index 000000000000..116905deac4a --- /dev/null +++ b/tests/components/nexblue/test_binary_sensor.py @@ -0,0 +1,98 @@ +"""Tests for NexBlue binary sensors.""" + +from collections.abc import Generator +from dataclasses import replace +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from nexblue_api import NexBlueConnectionError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import CHARGER_STATUS + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +def fixture_platforms() -> Generator[None]: + """Limit this module's setup to the binary sensor platform.""" + with patch("homeassistant.components.nexblue.PLATFORMS", [Platform.BINARY_SENSOR]): + yield + + +async def test_binary_sensor_entities_snapshot( + hass: HomeAssistant, + init_integration: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the complete NexBlue binary sensor platform through a snapshot.""" + await snapshot_platform( + hass, + entity_registry, + snapshot, + init_integration.entry_id, + ) + + +async def test_cable_lock_state_is_on_when_charger_is_unlocked( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test cable lock state is on when the cable is unlocked.""" + mock_client.async_get_charger_status.return_value = replace( + CHARGER_STATUS, is_lock=False + ) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.nb123456_cable_lock_state").state == "on" + + +async def test_charging_enabled_is_off_when_charger_is_disabled( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test charging enabled is off when the charger is disabled.""" + mock_client.async_get_charger_status.return_value = replace( + CHARGER_STATUS, is_disable=True + ) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.nb123456_charging_enabled").state == "off" + + +async def test_binary_sensors_unavailable_when_coordinator_update_fails( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test a failed coordinator update makes binary sensors unavailable.""" + mock_client.async_list_chargers.side_effect = NexBlueConnectionError + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("binary_sensor.nb123456_cable_lock_state").state + == STATE_UNAVAILABLE + ) + assert ( + hass.states.get("binary_sensor.nb123456_charging_enabled").state + == STATE_UNAVAILABLE + )