From 9eedc8a602351af8253a4360db92044d988fbbb3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 24 Jun 2023 22:09:26 -0500 Subject: [PATCH] Fix esphome binary sensors when state is missing (#95140) * Fix esphome binary sensors when state is missing * Fix esphome binary sensors when state is missing * Fix esphome binary sensors when state is missing --- .../components/esphome/binary_sensor.py | 5 +- tests/components/esphome/conftest.py | 66 ++++++++++++++--- .../components/esphome/test_binary_sensor.py | 70 +++++++++++++++++-- 3 files changed, 125 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index ce77c28e349..81ffee1a380 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -49,10 +49,9 @@ class EsphomeBinarySensor( # Status binary sensors indicated connected state. # So in their case what's usually _availability_ is now state return self._entry_data.available - state = self._state - if not self._has_state or state.missing_state: + if not self._has_state or self._state.missing_state: return None - return state.state + return self._state.state @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index e5e78ca3bf1..8a2fe1a3d4a 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations from asyncio import Event -from collections.abc import Callable +from collections.abc import Awaitable, Callable from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -142,13 +142,30 @@ async def mock_dashboard(hass): yield data +class MockESPHomeDevice: + """Mock an esphome device.""" + + def __init__(self, entry: MockConfigEntry) -> None: + """Init the mock.""" + self.entry = entry + self.state_callback: Callable[[EntityState], None] + + def set_state_callback(self, state_callback: Callable[[EntityState], None]) -> None: + """Set the state callback.""" + self.state_callback = state_callback + + def set_state(self, state: EntityState) -> None: + """Mock setting state.""" + self.state_callback(state) + + async def _mock_generic_device_entry( hass: HomeAssistant, mock_client: APIClient, mock_device_info: dict[str, Any], mock_list_entities_services: tuple[list[EntityInfo], list[UserService]], states: list[EntityState], -) -> MockConfigEntry: +) -> MockESPHomeDevice: entry = MockConfigEntry( domain=DOMAIN, data={ @@ -158,6 +175,7 @@ async def _mock_generic_device_entry( }, ) entry.add_to_hass(hass) + mock_device = MockESPHomeDevice(entry) device_info = DeviceInfo( name="test", @@ -169,6 +187,7 @@ async def _mock_generic_device_entry( async def _subscribe_states(callback: Callable[[EntityState], None]) -> None: """Subscribe to state.""" + mock_device.set_state_callback(callback) for state in states: callback(state) @@ -194,7 +213,7 @@ async def _mock_generic_device_entry( await hass.async_block_till_done() - return entry + return mock_device @pytest.fixture @@ -205,9 +224,11 @@ async def mock_voice_assistant_entry( """Set up an ESPHome entry with voice assistant.""" async def _mock_voice_assistant_entry(version: int) -> MockConfigEntry: - return await _mock_generic_device_entry( - hass, mock_client, {"voice_assistant_version": version}, ([], []), [] - ) + return ( + await _mock_generic_device_entry( + hass, mock_client, {"voice_assistant_version": version}, ([], []), [] + ) + ).entry return _mock_voice_assistant_entry @@ -227,8 +248,11 @@ async def mock_voice_assistant_v2_entry(mock_voice_assistant_entry) -> MockConfi @pytest.fixture async def mock_generic_device_entry( hass: HomeAssistant, -) -> MockConfigEntry: - """Set up an ESPHome entry.""" +) -> Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockConfigEntry], +]: + """Set up an ESPHome entry and return the MockConfigEntry.""" async def _mock_device_entry( mock_client: APIClient, @@ -236,8 +260,32 @@ async def mock_generic_device_entry( user_service: list[UserService], states: list[EntityState], ) -> MockConfigEntry: + return ( + await _mock_generic_device_entry( + hass, mock_client, {}, (entity_info, user_service), states + ) + ).entry + + return _mock_device_entry + + +@pytest.fixture +async def mock_esphome_device( + hass: HomeAssistant, +) -> Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], +]: + """Set up an ESPHome entry and return the MockESPHomeDevice.""" + + async def _mock_device( + mock_client: APIClient, + entity_info: list[EntityInfo], + user_service: list[UserService], + states: list[EntityState], + ) -> MockESPHomeDevice: return await _mock_generic_device_entry( hass, mock_client, {}, (entity_info, user_service), states ) - return _mock_device_entry + return _mock_device diff --git a/tests/components/esphome/test_binary_sensor.py b/tests/components/esphome/test_binary_sensor.py index 8f1d5a670c4..231bd51c0a3 100644 --- a/tests/components/esphome/test_binary_sensor.py +++ b/tests/components/esphome/test_binary_sensor.py @@ -1,11 +1,24 @@ """Test ESPHome binary sensors.""" -from aioesphomeapi import APIClient, BinarySensorInfo, BinarySensorState +from collections.abc import Awaitable, Callable + +from aioesphomeapi import ( + APIClient, + BinarySensorInfo, + BinarySensorState, + EntityInfo, + EntityState, + UserService, +) import pytest from homeassistant.components.esphome import DomainData from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from .conftest import MockESPHomeDevice + +from tests.common import MockConfigEntry + async def test_assist_in_progress( hass: HomeAssistant, @@ -37,7 +50,10 @@ async def test_binary_sensor_generic_entity( hass: HomeAssistant, mock_client: APIClient, binary_state: tuple[bool, str], - mock_generic_device_entry, + mock_generic_device_entry: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockConfigEntry], + ], ) -> None: """Test a generic binary_sensor entity.""" entity_info = [ @@ -63,7 +79,12 @@ async def test_binary_sensor_generic_entity( async def test_status_binary_sensor( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockConfigEntry], + ], ) -> None: """Test a generic binary_sensor entity.""" entity_info = [ @@ -89,7 +110,12 @@ async def test_status_binary_sensor( async def test_binary_sensor_missing_state( - hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockConfigEntry], + ], ) -> None: """Test a generic binary_sensor that is missing state.""" entity_info = [ @@ -111,3 +137,39 @@ async def test_binary_sensor_missing_state( state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_UNKNOWN + + +async def test_binary_sensor_has_state_false( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a generic binary_sensor where has_state is false.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + ) + ] + states = [] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("binary_sensor.test_my_binary_sensor") + assert state is not None + assert state.state == STATE_UNKNOWN + + mock_device.set_state(BinarySensorState(key=1, state=True, missing_state=False)) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test_my_binary_sensor") + assert state is not None + assert state.state == STATE_ON