diff --git a/custom_components/car_wash/binary_sensor.py b/custom_components/car_wash/binary_sensor.py index 8129d17..8d632b4 100644 --- a/custom_components/car_wash/binary_sensor.py +++ b/custom_components/car_wash/binary_sensor.py @@ -10,22 +10,26 @@ from collections.abc import Callable from datetime import datetime import logging -from typing import Optional import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.components.weather import ( - ATTR_FORECAST, ATTR_FORECAST_CONDITION, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_WEATHER_TEMPERATURE, + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + WeatherEntityFeature, ) from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, + CONF_ENTITY_ID, CONF_NAME, + CONF_TYPE, CONF_UNIQUE_ID, EVENT_HOMEASSISTANT_START, UnitOfTemperature, @@ -51,7 +55,6 @@ _LOGGER = logging.getLogger(__name__) - PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( { vol.Required(CONF_WEATHER): cv.entity_id, @@ -90,7 +93,7 @@ class CarWashBinarySensor(BinarySensorEntity): def __init__( self, - unique_id: Optional[str], + unique_id: str | None, friendly_name: str, weather_entity: str, days: int, @@ -138,32 +141,50 @@ def sensor_startup(event): self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, sensor_startup) @staticmethod - def _temp2c(temperature: Optional[float], temperature_unit: str) -> Optional[float]: + def _temp2c(temperature: float | None, temperature_unit: str) -> float | None: """Convert weather temperature to Celsius degree.""" if temperature is not None and temperature_unit != UnitOfTemperature.CELSIUS: - temperature = TemperatureConverter.convert(temperature, temperature_unit, UnitOfTemperature.CELSIUS) + temperature = TemperatureConverter.convert( + temperature, temperature_unit, UnitOfTemperature.CELSIUS + ) return temperature # pylint: disable=too-many-branches,too-many-statements async def async_update(self): """Update the sensor state.""" - wdata = self.hass.states.get(self._weather_entity) - - if wdata is None: + wstate = self.hass.states.get(self._weather_entity) + if wstate is None: raise HomeAssistantError( f"Unable to find an entity called {self._weather_entity}" ) tmpu = self.hass.config.units.temperature_unit - temp = wdata.attributes.get(ATTR_WEATHER_TEMPERATURE) - cond = wdata.state - forecast = wdata.attributes.get(ATTR_FORECAST) - - if forecast is None: + temp = wstate.attributes.get(ATTR_WEATHER_TEMPERATURE) + cond = wstate.state + + wfeatures = wstate.attributes.get(ATTR_SUPPORTED_FEATURES) + if ( + not isinstance(wfeatures, WeatherEntityFeature) + or (wfeatures & WeatherEntityFeature.FORECAST_DAILY) == 0 + ): + raise HomeAssistantError("Weather entity doesn't support 'daily' forecast") + + try: + forecast = await self.hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + { + CONF_TYPE: "daily", + CONF_ENTITY_ID: self._weather_entity, + }, + blocking=True, + return_response=True, + ) + except HomeAssistantError as ex: self._attr_is_on = None raise HomeAssistantError( "Can't get forecast data! Are you sure it's the weather provider?" - ) + ) from ex _LOGGER.debug("Current temperature %s, condition '%s'", temp, cond) @@ -181,15 +202,14 @@ async def async_update(self): ).strftime("%F") _LOGGER.debug("Inspect weather forecast from now till %s", stop_date) - for fcast in forecast: + for fcast in forecast[self._weather_entity]["forecast"]: fc_date = fcast.get(ATTR_FORECAST_TIME) if isinstance(fc_date, int): fc_date = dt_util.as_local( - datetime.utcfromtimestamp(fc_date / 1000) - ).isoformat() + datetime.fromtimestamp(fc_date / 1000, dt_util.UTC) + ).strftime("%F") elif isinstance(fc_date, datetime): - fc_date = dt_util.as_local(fc_date).isoformat() - fc_date = fc_date[:10] + fc_date = dt_util.as_local(fc_date).strftime("%F") if fc_date < cur_date: continue if fc_date == stop_date: diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py index 6ca543e..24f2489 100644 --- a/tests/test_binary_sensor.py +++ b/tests/test_binary_sensor.py @@ -1,80 +1,77 @@ """The test for the binary sensor platform.""" + # pylint: disable=redefined-outer-name +from typing import Final +from unittest.mock import MagicMock + import pytest from pytest import raises -from pytest_homeassistant_custom_component.common import assert_setup_component +from pytest_homeassistant_custom_component.common import async_mock_service -from custom_components.car_wash.binary_sensor import CarWashBinarySensor +from custom_components.car_wash.binary_sensor import ( + CarWashBinarySensor, + async_setup_platform, +) from custom_components.car_wash.const import CONF_WEATHER, DOMAIN, ICON from homeassistant.components.weather import ( ATTR_CONDITION_RAINY, ATTR_CONDITION_SUNNY, - ATTR_FORECAST, ATTR_FORECAST_CONDITION, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_WEATHER_TEMPERATURE, + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + WeatherEntityFeature, ) from homeassistant.const import ( - CONF_NAME, + ATTR_SUPPORTED_FEATURES, CONF_PLATFORM, - STATE_OFF, - STATE_ON, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, + UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.exceptions import HomeAssistantError -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -TEST_UNIQUE_ID = "test_id" -TEST_NAME = "test_name" -TEST_WEATHER = "weather.test_monitored" -TEST_DAYS = 2 +MOCK_ENTITY: Final = DOMAIN + ".test" +MOCK_UNIQUE_ID: Final = "test_id" +MOCK_NAME: Final = "test_name" +MOCK_WEATHER_ENTITY_NAME: Final = "test" +MOCK_WEATHER_ENTITY: Final = WEATHER_DOMAIN + "." + MOCK_WEATHER_ENTITY_NAME +MOCK_DAYS: Final = 2 -TEST_CONFIG = { +MOCK_CONFIG: Final = { CONF_PLATFORM: DOMAIN, - CONF_NAME: "test", - CONF_WEATHER: "weather.test_monitored", + CONF_WEATHER: MOCK_WEATHER_ENTITY, } @pytest.fixture() -async def mock_weather(hass: HomeAssistant): - """Mock weather entity.""" - assert await async_setup_component( - hass, - "weather", - { - "weather": { - "platform": "template", - "name": "test_monitored", - "condition_template": "{{ 0 }}", - "temperature_template": "{{ 0 }}", - "humidity_template": "{{ 0 }}", - } - }, +def default_sensor(hass: HomeAssistant): + """Create an AverageSensor with default values.""" + entity = CarWashBinarySensor( + MOCK_UNIQUE_ID, MOCK_NAME, MOCK_WEATHER_ENTITY, MOCK_DAYS ) - await hass.async_block_till_done() + entity.hass = hass + return entity -async def test_entity_initialization(hass: HomeAssistant): +@pytest.mark.parametrize( + "uid, expected_uid", + [ + (None, None), + ("__legacy__", DOMAIN + "-" + MOCK_WEATHER_ENTITY_NAME), + (MOCK_UNIQUE_ID, MOCK_UNIQUE_ID), + ], +) +async def test_entity_initialization(hass: HomeAssistant, uid, expected_uid): """Test sensor initialization.""" - entity = CarWashBinarySensor(None, TEST_NAME, TEST_WEATHER, TEST_DAYS) - - assert entity.unique_id is None - - entity = CarWashBinarySensor("__legacy__", TEST_NAME, TEST_WEATHER, TEST_DAYS) + entity = CarWashBinarySensor(uid, MOCK_NAME, MOCK_WEATHER_ENTITY, MOCK_DAYS) - assert entity.unique_id == "car_wash-test_monitored" - - entity = CarWashBinarySensor(TEST_UNIQUE_ID, TEST_NAME, TEST_WEATHER, TEST_DAYS) - - assert entity.unique_id == TEST_UNIQUE_ID - assert entity.name == TEST_NAME + assert entity.unique_id == expected_uid + assert entity.name == MOCK_NAME assert entity.device_class == f"{DOMAIN}__" assert entity.should_poll is False assert entity.available is False @@ -82,160 +79,284 @@ async def test_entity_initialization(hass: HomeAssistant): assert entity.icon is ICON -async def test_async_setup_platform(hass: HomeAssistant, mock_weather): +async def test_async_setup_platform(hass: HomeAssistant): """Test platform setup.""" - with assert_setup_component(1, "binary_sensor"): - assert await async_setup_component( - hass, - "binary_sensor", - { - "binary_sensor": TEST_CONFIG, - }, - ) - await hass.async_block_till_done() - - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") - assert state is not None - assert state.state == STATE_ON - - hass.states.async_set( - "weather.test_monitored", ATTR_CONDITION_RAINY, {ATTR_FORECAST: {}} - ) - await hass.async_block_till_done() + async_add_entities = MagicMock() - state = hass.states.get("binary_sensor.test") - assert state is not None - assert state.state == STATE_OFF + await async_setup_platform(hass, MOCK_CONFIG, async_add_entities, None) + assert async_add_entities.called # pylint: disable=protected-access -async def test__temp2c(): +@pytest.mark.parametrize( + "temp1, temp2", + [(0, -17.78), (10, -12.22), (20, -6.67), (30, -1.11), (40, 4.44), (50, 10)], +) +async def test__temp2c(temp1, temp2): """Test temperature conversions.""" - assert CarWashBinarySensor._temp2c(10, TEMP_CELSIUS) == 10 - assert round(CarWashBinarySensor._temp2c(10, TEMP_FAHRENHEIT), 2) == -12.22 - assert CarWashBinarySensor._temp2c(None, TEMP_CELSIUS) is None + assert CarWashBinarySensor._temp2c(temp1, UnitOfTemperature.CELSIUS) == temp1 + assert ( + round(CarWashBinarySensor._temp2c(temp1, UnitOfTemperature.FAHRENHEIT), 2) + == temp2 + ) + assert CarWashBinarySensor._temp2c(None, UnitOfTemperature.CELSIUS) is None -async def test_async_update(hass: HomeAssistant, mock_weather): - """Test platform setup.""" +async def test_async_update_fail(hass: HomeAssistant): + """Test component update fail.""" entity = CarWashBinarySensor( - TEST_UNIQUE_ID, TEST_NAME, "weather.nonexistent", TEST_DAYS + MOCK_UNIQUE_ID, MOCK_NAME, WEATHER_DOMAIN + ".nonexistent", MOCK_DAYS ) entity.hass = hass with raises(HomeAssistantError): await entity.async_update() - entity = CarWashBinarySensor(TEST_UNIQUE_ID, TEST_NAME, TEST_WEATHER, TEST_DAYS) - entity.hass = hass - assert entity.is_on is None - hass.states.async_set("weather.test_monitored", None) - with raises(HomeAssistantError): - await entity.async_update() +async def test_async_update_forecast_fail(hass: HomeAssistant, default_sensor): + """Test sensor update on forecast fail.""" + async_mock_service( + hass, + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + supports_response=SupportsResponse.OPTIONAL, + ) + + with raises(HomeAssistantError, match="Unable to find an entity"): + await default_sensor.async_update() + + hass.states.async_set( + MOCK_WEATHER_ENTITY, + "State", + attributes={ + ATTR_WEATHER_TEMPERATURE: -1, + }, + ) + + with raises(HomeAssistantError, match="doesn't support 'daily' forecast"): + await default_sensor.async_update() + + hass.states.async_set( + MOCK_WEATHER_ENTITY, + "State", + attributes={ + ATTR_WEATHER_TEMPERATURE: -1, + ATTR_SUPPORTED_FEATURES: "unexpected", + }, + ) + + with raises(HomeAssistantError, match="doesn't support 'daily' forecast"): + await default_sensor.async_update() hass.states.async_set( - "weather.test_monitored", ATTR_CONDITION_RAINY, {ATTR_FORECAST: []} + MOCK_WEATHER_ENTITY, + "State", + attributes={ + ATTR_WEATHER_TEMPERATURE: -1, + ATTR_SUPPORTED_FEATURES: WeatherEntityFeature.FORECAST_HOURLY, + }, ) - await entity.async_update() - assert entity.is_on is False + + with raises(HomeAssistantError, match="doesn't support 'daily' forecast"): + await default_sensor.async_update() + + hass.states.async_set( + MOCK_WEATHER_ENTITY, + "State", + attributes={ + ATTR_WEATHER_TEMPERATURE: -1, + ATTR_SUPPORTED_FEATURES: WeatherEntityFeature.FORECAST_HOURLY + | WeatherEntityFeature.FORECAST_DAILY, + }, + ) + + with raises(HomeAssistantError, match="Can't get forecast data!"): + await default_sensor.async_update() + + +async def test_async_update(hass: HomeAssistant, default_sensor): + """Test component update.""" + assert default_sensor.is_on is None + + hass.states.async_set(MOCK_WEATHER_ENTITY, None) + + with raises(HomeAssistantError): + await default_sensor.async_update() + + hass.states.async_set(MOCK_WEATHER_ENTITY, "State") + + with raises(HomeAssistantError): + await default_sensor.async_update() today = dt_util.start_of_local_day() today_ts = int(today.timestamp() * 1000) day = days = 86400000 hass.states.async_set( - "weather.test_monitored", + MOCK_WEATHER_ENTITY, + ATTR_CONDITION_RAINY, + attributes={ + ATTR_WEATHER_TEMPERATURE: 12, + ATTR_SUPPORTED_FEATURES: WeatherEntityFeature.FORECAST_DAILY, + }, + ) + async_mock_service( + hass, + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + response={MOCK_WEATHER_ENTITY: {"forecast": []}}, + ) + + await default_sensor.async_update() + assert default_sensor.is_on is False + + hass.states.async_set( + MOCK_WEATHER_ENTITY, ATTR_CONDITION_SUNNY, - { - ATTR_FORECAST: [ - { - ATTR_FORECAST_TIME: int(today_ts - day), - }, - { - ATTR_FORECAST_TIME: today, - ATTR_FORECAST_PRECIPITATION: "null", - }, - { - ATTR_FORECAST_TIME: int(today_ts + 3 * days), - }, - ] + attributes={ + ATTR_WEATHER_TEMPERATURE: 12, + ATTR_SUPPORTED_FEATURES: WeatherEntityFeature.FORECAST_DAILY, + }, + ) + async_mock_service( + hass, + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + response={ + MOCK_WEATHER_ENTITY: { + "forecast": [ + { + ATTR_FORECAST_TIME: int(today_ts - day), + }, + { + ATTR_FORECAST_TIME: today, + ATTR_FORECAST_PRECIPITATION: "null", + }, + { + ATTR_FORECAST_TIME: int(today_ts + 3 * days), + }, + ] + } }, ) - await entity.async_update() - assert entity.is_on is True + + await default_sensor.async_update() + assert default_sensor.is_on is True hass.states.async_set( - "weather.test_monitored", + MOCK_WEATHER_ENTITY, ATTR_CONDITION_SUNNY, - { - ATTR_FORECAST: [ - { - ATTR_FORECAST_TIME: today, - ATTR_FORECAST_PRECIPITATION: 1, - }, - ] + attributes={ + ATTR_WEATHER_TEMPERATURE: 12, + ATTR_SUPPORTED_FEATURES: WeatherEntityFeature.FORECAST_DAILY, + }, + ) + async_mock_service( + hass, + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + response={ + MOCK_WEATHER_ENTITY: { + "forecast": [ + { + ATTR_FORECAST_TIME: today, + ATTR_FORECAST_PRECIPITATION: 1, + }, + ] + } }, ) - await entity.async_update() - assert entity.is_on is False + + await default_sensor.async_update() + assert default_sensor.is_on is False hass.states.async_set( - "weather.test_monitored", + MOCK_WEATHER_ENTITY, ATTR_CONDITION_SUNNY, - { - ATTR_FORECAST: [ - { - ATTR_FORECAST_TIME: today, - ATTR_FORECAST_CONDITION: ATTR_CONDITION_RAINY, - }, - ] + attributes={ + ATTR_WEATHER_TEMPERATURE: 12, + ATTR_SUPPORTED_FEATURES: WeatherEntityFeature.FORECAST_DAILY, }, ) - await entity.async_update() - assert entity.is_on is False + async_mock_service( + hass, + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + response={ + MOCK_WEATHER_ENTITY: { + "forecast": [ + { + ATTR_FORECAST_TIME: today, + ATTR_FORECAST_CONDITION: ATTR_CONDITION_RAINY, + }, + ] + } + }, + ) + + await default_sensor.async_update() + assert default_sensor.is_on is False hass.states.async_set( - "weather.test_monitored", + MOCK_WEATHER_ENTITY, ATTR_CONDITION_SUNNY, - { - ATTR_WEATHER_TEMPERATURE: 0, - ATTR_FORECAST: [ - { - ATTR_FORECAST_TIME: today, - }, - { - ATTR_FORECAST_TIME: int(today_ts + day), - ATTR_FORECAST_TEMP_LOW: -1, - ATTR_FORECAST_TEMP: -1, - }, - { - ATTR_FORECAST_TIME: int(today_ts + 2 * days), - ATTR_FORECAST_TEMP_LOW: 1, - }, - ], + attributes={ + ATTR_WEATHER_TEMPERATURE: 12, + ATTR_SUPPORTED_FEATURES: WeatherEntityFeature.FORECAST_DAILY, }, ) - await entity.async_update() - assert entity.is_on is False + async_mock_service( + hass, + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + response={ + MOCK_WEATHER_ENTITY: { + "forecast": [ + { + ATTR_FORECAST_TIME: today, + }, + { + ATTR_FORECAST_TIME: int(today_ts + day), + ATTR_FORECAST_TEMP_LOW: -1, + ATTR_FORECAST_TEMP: -1, + }, + { + ATTR_FORECAST_TIME: int(today_ts + 2 * days), + ATTR_FORECAST_TEMP_LOW: 1, + }, + ] + } + }, + ) + + await default_sensor.async_update() + assert default_sensor.is_on is False hass.states.async_set( - "weather.test_monitored", + MOCK_WEATHER_ENTITY, ATTR_CONDITION_SUNNY, - { + attributes={ ATTR_WEATHER_TEMPERATURE: -1, - ATTR_FORECAST: [ - { - ATTR_FORECAST_TIME: today, - }, - { - ATTR_FORECAST_TIME: int(today_ts + day), - ATTR_FORECAST_TEMP: 1, - }, - ], + ATTR_SUPPORTED_FEATURES: WeatherEntityFeature.FORECAST_DAILY, }, ) - await entity.async_update() - assert entity.is_on is False + async_mock_service( + hass, + WEATHER_DOMAIN, + SERVICE_GET_FORECASTS, + response={ + MOCK_WEATHER_ENTITY: { + "forecast": [ + { + ATTR_FORECAST_TIME: today, + }, + { + ATTR_FORECAST_TIME: int(today_ts + day), + ATTR_FORECAST_TEMP: 1, + }, + ] + } + }, + ) + + await default_sensor.async_update() + assert default_sensor.is_on is False