diff --git a/.core_files.yaml b/.core_files.yaml index 6f7b57c78690f..654730e56135a 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -3,103 +3,104 @@ core: &core - homeassistant/*.py - homeassistant/auth/** - - homeassistant/helpers/* + - homeassistant/helpers/** - homeassistant/package_constraints.txt - - homeassistant/util/* + - homeassistant/util/** - pyproject.toml - requirements.txt - setup.cfg # Our base platforms, that are used by other integrations base_platforms: &base_platforms - - homeassistant/components/air_quality/* - - homeassistant/components/alarm_control_panel/* - - homeassistant/components/binary_sensor/* - - homeassistant/components/button/* - - homeassistant/components/calendar/* - - homeassistant/components/camera/* - - homeassistant/components/climate/* - - homeassistant/components/cover/* - - homeassistant/components/device_tracker/* - - homeassistant/components/diagnostics/* - - homeassistant/components/fan/* - - homeassistant/components/geo_location/* - - homeassistant/components/humidifier/* - - homeassistant/components/image_processing/* - - homeassistant/components/light/* - - homeassistant/components/lock/* - - homeassistant/components/media_player/* - - homeassistant/components/notify/* - - homeassistant/components/number/* - - homeassistant/components/remote/* - - homeassistant/components/scene/* - - homeassistant/components/select/* - - homeassistant/components/sensor/* - - homeassistant/components/siren/* - - homeassistant/components/stt/* - - homeassistant/components/switch/* - - homeassistant/components/tts/* - - homeassistant/components/vacuum/* - - homeassistant/components/water_heater/* - - homeassistant/components/weather/* + - homeassistant/components/air_quality/** + - homeassistant/components/alarm_control_panel/** + - homeassistant/components/binary_sensor/** + - homeassistant/components/button/** + - homeassistant/components/calendar/** + - homeassistant/components/camera/** + - homeassistant/components/climate/** + - homeassistant/components/cover/** + - homeassistant/components/device_tracker/** + - homeassistant/components/diagnostics/** + - homeassistant/components/fan/** + - homeassistant/components/geo_location/** + - homeassistant/components/humidifier/** + - homeassistant/components/image_processing/** + - homeassistant/components/light/** + - homeassistant/components/lock/** + - homeassistant/components/media_player/** + - homeassistant/components/notify/** + - homeassistant/components/number/** + - homeassistant/components/remote/** + - homeassistant/components/scene/** + - homeassistant/components/select/** + - homeassistant/components/sensor/** + - homeassistant/components/siren/** + - homeassistant/components/stt/** + - homeassistant/components/switch/** + - homeassistant/components/tts/** + - homeassistant/components/update/** + - homeassistant/components/vacuum/** + - homeassistant/components/water_heater/** + - homeassistant/components/weather/** # Extra components that trigger the full suite components: &components - - homeassistant/components/alert/* - - homeassistant/components/alexa/* - - homeassistant/components/auth/* - - homeassistant/components/automation/* - - homeassistant/components/backup/* - - homeassistant/components/cloud/* - - homeassistant/components/config/* - - homeassistant/components/configurator/* - - homeassistant/components/conversation/* - - homeassistant/components/demo/* - - homeassistant/components/device_automation/* - - homeassistant/components/dhcp/* - - homeassistant/components/discovery/* - - homeassistant/components/energy/* - - homeassistant/components/ffmpeg/* - - homeassistant/components/frontend/* - - homeassistant/components/google_assistant/* - - homeassistant/components/group/* - - homeassistant/components/hassio/* + - homeassistant/components/alert/** + - homeassistant/components/alexa/** + - homeassistant/components/auth/** + - homeassistant/components/automation/** + - homeassistant/components/backup/** + - homeassistant/components/cloud/** + - homeassistant/components/config/** + - homeassistant/components/configurator/** + - homeassistant/components/conversation/** + - homeassistant/components/demo/** + - homeassistant/components/device_automation/** + - homeassistant/components/dhcp/** + - homeassistant/components/discovery/** + - homeassistant/components/energy/** + - homeassistant/components/ffmpeg/** + - homeassistant/components/frontend/** + - homeassistant/components/google_assistant/** + - homeassistant/components/group/** + - homeassistant/components/hassio/** - homeassistant/components/homeassistant/** - homeassistant/components/http/** - - homeassistant/components/image/* - - homeassistant/components/input_boolean/* - - homeassistant/components/input_button/* - - homeassistant/components/input_datetime/* - - homeassistant/components/input_number/* - - homeassistant/components/input_select/* - - homeassistant/components/input_text/* - - homeassistant/components/logbook/* - - homeassistant/components/logger/* - - homeassistant/components/lovelace/* - - homeassistant/components/media_source/* - - homeassistant/components/mjpeg/* - - homeassistant/components/mqtt/* - - homeassistant/components/network/* - - homeassistant/components/onboarding/* - - homeassistant/components/otp/* - - homeassistant/components/persistent_notification/* - - homeassistant/components/person/* - - homeassistant/components/recorder/* - - homeassistant/components/safe_mode/* - - homeassistant/components/script/* - - homeassistant/components/shopping_list/* - - homeassistant/components/ssdp/* - - homeassistant/components/stream/* - - homeassistant/components/sun/* - - homeassistant/components/system_health/* - - homeassistant/components/tag/* - - homeassistant/components/template/* - - homeassistant/components/timer/* - - homeassistant/components/usb/* - - homeassistant/components/webhook/* - - homeassistant/components/websocket_api/* - - homeassistant/components/zeroconf/* - - homeassistant/components/zone/* + - homeassistant/components/image/** + - homeassistant/components/input_boolean/** + - homeassistant/components/input_button/** + - homeassistant/components/input_datetime/** + - homeassistant/components/input_number/** + - homeassistant/components/input_select/** + - homeassistant/components/input_text/** + - homeassistant/components/logbook/** + - homeassistant/components/logger/** + - homeassistant/components/lovelace/** + - homeassistant/components/media_source/** + - homeassistant/components/mjpeg/** + - homeassistant/components/mqtt/** + - homeassistant/components/network/** + - homeassistant/components/onboarding/** + - homeassistant/components/otp/** + - homeassistant/components/persistent_notification/** + - homeassistant/components/person/** + - homeassistant/components/recorder/** + - homeassistant/components/safe_mode/** + - homeassistant/components/script/** + - homeassistant/components/shopping_list/** + - homeassistant/components/ssdp/** + - homeassistant/components/stream/** + - homeassistant/components/sun/** + - homeassistant/components/system_health/** + - homeassistant/components/tag/** + - homeassistant/components/template/** + - homeassistant/components/timer/** + - homeassistant/components/usb/** + - homeassistant/components/webhook/** + - homeassistant/components/websocket_api/** + - homeassistant/components/zeroconf/** + - homeassistant/components/zone/** # Testing related files that affect the whole test/linting suite tests: &tests @@ -108,26 +109,27 @@ tests: &tests - requirements_test_pre_commit.txt - requirements_test.txt - tests/auth/** - - tests/backports/* + - tests/backports/** - tests/common.py - tests/conftest.py - - tests/hassfest/* - - tests/helpers/* + - tests/hassfest/** + - tests/helpers/** - tests/ignore_uncaught_exceptions.py - - tests/mock/* - - tests/pylint/* - - tests/scripts/* - - tests/test_util/* + - tests/mock/** + - tests/pylint/** + - tests/scripts/** + - tests/test_util/** - tests/testing_config/** - tests/util/** other: &other - - .github/workflows/* + - .github/workflows/** - homeassistant/scripts/** requirements: &requirements - - .github/workflows/* + - .github/workflows/** - homeassistant/package_constraints.txt + - script/pip_check - requirements*.txt - setup.cfg diff --git a/.coveragerc b/.coveragerc index 4740042620202..47b0772442115 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1061,9 +1061,6 @@ omit = homeassistant/components/smappee/sensor.py homeassistant/components/smappee/switch.py homeassistant/components/smarty/* - homeassistant/components/smarthab/__init__.py - homeassistant/components/smarthab/cover.py - homeassistant/components/smarthab/light.py homeassistant/components/sms/__init__.py homeassistant/components/sms/const.py homeassistant/components/sms/gateway.py @@ -1469,6 +1466,7 @@ omit = homeassistant/components/zwave_me/__init__.py homeassistant/components/zwave_me/binary_sensor.py homeassistant/components/zwave_me/button.py + homeassistant/components/zwave_me/cover.py homeassistant/components/zwave_me/climate.py homeassistant/components/zwave_me/helpers.py homeassistant/components/zwave_me/light.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 259277b16669f..8bf509a37879e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -62,7 +62,7 @@ jobs: integrations=$(ls -Ad ./homeassistant/components/[!_]* | xargs -n 1 basename) touch .integration_paths.yaml for integration in $integrations; do - echo "${integration}: [homeassistant/components/${integration}/*, tests/components/${integration}/*]" \ + echo "${integration}: [homeassistant/components/${integration}/**, tests/components/${integration}/**]" \ >> .integration_paths.yaml; done echo "Result:" @@ -686,7 +686,7 @@ jobs: pip-check: runs-on: ubuntu-latest - if: needs.changes.outputs.requirements == 'true' + if: needs.changes.outputs.requirements == 'true' || github.event.inputs.full == 'true' needs: - changes - prepare-tests diff --git a/.strict-typing b/.strict-typing index 498f760bc93c2..7b1fc47b3a35a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -207,6 +207,7 @@ homeassistant.components.tts.* homeassistant.components.twentemilieu.* homeassistant.components.unifiprotect.* homeassistant.components.upcloud.* +homeassistant.components.update.* homeassistant.components.uptime.* homeassistant.components.uptimerobot.* homeassistant.components.usb.* @@ -224,6 +225,7 @@ homeassistant.components.wemo.* homeassistant.components.whois.* homeassistant.components.wiz.* homeassistant.components.worldclock.* +homeassistant.components.yale_smart_alarm.* homeassistant.components.zodiac.* homeassistant.components.zeroconf.* homeassistant.components.zone.* diff --git a/CODEOWNERS b/CODEOWNERS index 12a7b42f0d264..24a7793f6a64d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -908,8 +908,6 @@ homeassistant/components/smappee/* @bsmappee tests/components/smappee/* @bsmappee homeassistant/components/smart_meter_texas/* @grahamwetzler tests/components/smart_meter_texas/* @grahamwetzler -homeassistant/components/smarthab/* @outadoc -tests/components/smarthab/* @outadoc homeassistant/components/smartthings/* @andrewsayre tests/components/smartthings/* @andrewsayre homeassistant/components/smarttub/* @mdz @@ -1026,6 +1024,8 @@ homeassistant/components/todoist/* @boralyl tests/components/todoist/* @boralyl homeassistant/components/tolo/* @MatthiasLohr tests/components/tolo/* @MatthiasLohr +homeassistant/components/tomorrowio/* @raman325 +tests/components/tomorrowio/* @raman325 homeassistant/components/totalconnect/* @austinmroczek tests/components/totalconnect/* @austinmroczek homeassistant/components/tplink/* @rytilahti @thegardenmonkey @@ -1059,6 +1059,8 @@ tests/components/upb/* @gwww homeassistant/components/upc_connect/* @pvizeli @fabaff homeassistant/components/upcloud/* @scop tests/components/upcloud/* @scop +homeassistant/components/update/* @home-assistant/core +tests/components/update/* @home-assistant/core homeassistant/components/updater/* @home-assistant/core tests/components/updater/* @home-assistant/core homeassistant/components/upnp/* @StevenLooman @ehendrix23 diff --git a/homeassistant/components/aussie_broadband/__init__.py b/homeassistant/components/aussie_broadband/__init__.py index f3a07616d93ed..6136ff6f8f337 100644 --- a/homeassistant/components/aussie_broadband/__init__.py +++ b/homeassistant/components/aussie_broadband/__init__.py @@ -6,6 +6,7 @@ from aiohttp import ClientError from aussiebb.asyncio import AussieBB +from aussiebb.const import FETCH_TYPES from aussiebb.exceptions import AuthenticationException, UnrecognisedServiceType from homeassistant.config_entries import ConfigEntry @@ -31,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: await client.login() - services = await client.get_services() + services = await client.get_services(drop_types=FETCH_TYPES) except AuthenticationException as exc: raise ConfigEntryAuthFailed() from exc except ClientError as exc: diff --git a/homeassistant/components/aussie_broadband/config_flow.py b/homeassistant/components/aussie_broadband/config_flow.py index 5eaf39853b5a8..6e101250386e6 100644 --- a/homeassistant/components/aussie_broadband/config_flow.py +++ b/homeassistant/components/aussie_broadband/config_flow.py @@ -5,6 +5,7 @@ from aiohttp import ClientError from aussiebb.asyncio import AussieBB, AuthenticationException +from aussiebb.const import FETCH_TYPES import voluptuous as vol from homeassistant import config_entries @@ -54,7 +55,7 @@ async def async_step_user( self._abort_if_unique_id_configured() self.data = user_input - self.services = await self.client.get_services() # type: ignore[union-attr] + self.services = await self.client.get_services(drop_types=FETCH_TYPES) # type: ignore[union-attr] if not self.services: return self.async_abort(reason="no_services_found") diff --git a/homeassistant/components/aussie_broadband/manifest.json b/homeassistant/components/aussie_broadband/manifest.json index 5476371f755b6..0823000956a37 100644 --- a/homeassistant/components/aussie_broadband/manifest.json +++ b/homeassistant/components/aussie_broadband/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aussie_broadband", "requirements": [ - "pyaussiebb==0.0.11" + "pyaussiebb==0.0.14" ], "codeowners": [ "@nickw444", @@ -14,4 +14,4 @@ "loggers": [ "aussiebb" ] -} \ No newline at end of file +} diff --git a/homeassistant/components/broadlink/manifest.json b/homeassistant/components/broadlink/manifest.json index db1601edd67f8..e3df0dc829dba 100644 --- a/homeassistant/components/broadlink/manifest.json +++ b/homeassistant/components/broadlink/manifest.json @@ -2,7 +2,7 @@ "domain": "broadlink", "name": "Broadlink", "documentation": "https://www.home-assistant.io/integrations/broadlink", - "requirements": ["broadlink==0.18.0"], + "requirements": ["broadlink==0.18.1"], "codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am"], "config_flow": true, "dhcp": [ diff --git a/homeassistant/components/climacell/__init__.py b/homeassistant/components/climacell/__init__.py index e3edc77895521..e408476aad31e 100644 --- a/homeassistant/components/climacell/__init__.py +++ b/homeassistant/components/climacell/__init__.py @@ -15,7 +15,8 @@ UnknownException, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.tomorrowio import DOMAIN as TOMORROW_DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_API_VERSION, @@ -36,22 +37,6 @@ from .const import ( ATTRIBUTION, - CC_ATTR_CLOUD_COVER, - CC_ATTR_CONDITION, - CC_ATTR_HUMIDITY, - CC_ATTR_OZONE, - CC_ATTR_PRECIPITATION, - CC_ATTR_PRECIPITATION_PROBABILITY, - CC_ATTR_PRECIPITATION_TYPE, - CC_ATTR_PRESSURE, - CC_ATTR_TEMPERATURE, - CC_ATTR_TEMPERATURE_HIGH, - CC_ATTR_TEMPERATURE_LOW, - CC_ATTR_VISIBILITY, - CC_ATTR_WIND_DIRECTION, - CC_ATTR_WIND_GUST, - CC_ATTR_WIND_SPEED, - CC_SENSOR_TYPES, CC_V3_ATTR_CLOUD_COVER, CC_V3_ATTR_CONDITION, CC_V3_ATTR_HUMIDITY, @@ -142,8 +127,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if params: hass.config_entries.async_update_entry(entry, **params) - api_class = ClimaCellV3 if entry.data[CONF_API_VERSION] == 3 else ClimaCellV4 - api = api_class( + hass.async_create_task( + hass.config_entries.flow.async_init( + TOMORROW_DOMAIN, + context={"source": SOURCE_IMPORT, "old_config_entry_id": entry.entry_id}, + data=entry.data, + ) + ) + + # Eventually we will remove the code that sets up the platforms and force users to + # migrate. This will only impact users still on the V3 API because we can't + # automatically migrate them, but for V4 users, we can skip the platform setup. + if entry.data[CONF_API_VERSION] == 4: + return True + + api = ClimaCellV3( entry.data[CONF_API_KEY], entry.data.get(CONF_LATITUDE, hass.config.latitude), entry.data.get(CONF_LONGITUDE, hass.config.longitude), @@ -172,7 +170,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry, PLATFORMS ) - hass.data[DOMAIN].pop(config_entry.entry_id) + hass.data[DOMAIN].pop(config_entry.entry_id, None) if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) @@ -208,89 +206,62 @@ async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" data: dict[str, Any] = {FORECASTS: {}} try: - if self._api_version == 3: - data[CURRENT] = await self._api.realtime( - [ - CC_V3_ATTR_TEMPERATURE, - CC_V3_ATTR_HUMIDITY, - CC_V3_ATTR_PRESSURE, - CC_V3_ATTR_WIND_SPEED, - CC_V3_ATTR_WIND_DIRECTION, - CC_V3_ATTR_CONDITION, - CC_V3_ATTR_VISIBILITY, - CC_V3_ATTR_OZONE, - CC_V3_ATTR_WIND_GUST, - CC_V3_ATTR_CLOUD_COVER, - CC_V3_ATTR_PRECIPITATION_TYPE, - *(sensor_type.key for sensor_type in CC_V3_SENSOR_TYPES), - ] - ) - data[FORECASTS][HOURLY] = await self._api.forecast_hourly( - [ - CC_V3_ATTR_TEMPERATURE, - CC_V3_ATTR_WIND_SPEED, - CC_V3_ATTR_WIND_DIRECTION, - CC_V3_ATTR_CONDITION, - CC_V3_ATTR_PRECIPITATION, - CC_V3_ATTR_PRECIPITATION_PROBABILITY, - ], - None, - timedelta(hours=24), - ) - - data[FORECASTS][DAILY] = await self._api.forecast_daily( - [ - CC_V3_ATTR_TEMPERATURE, - CC_V3_ATTR_WIND_SPEED, - CC_V3_ATTR_WIND_DIRECTION, - CC_V3_ATTR_CONDITION, - CC_V3_ATTR_PRECIPITATION_DAILY, - CC_V3_ATTR_PRECIPITATION_PROBABILITY, - ], - None, - timedelta(days=14), - ) - - data[FORECASTS][NOWCAST] = await self._api.forecast_nowcast( - [ - CC_V3_ATTR_TEMPERATURE, - CC_V3_ATTR_WIND_SPEED, - CC_V3_ATTR_WIND_DIRECTION, - CC_V3_ATTR_CONDITION, - CC_V3_ATTR_PRECIPITATION, - ], - None, - timedelta( - minutes=min(300, self._config_entry.options[CONF_TIMESTEP] * 30) - ), - self._config_entry.options[CONF_TIMESTEP], - ) - else: - return await self._api.realtime_and_all_forecasts( - [ - CC_ATTR_TEMPERATURE, - CC_ATTR_HUMIDITY, - CC_ATTR_PRESSURE, - CC_ATTR_WIND_SPEED, - CC_ATTR_WIND_DIRECTION, - CC_ATTR_CONDITION, - CC_ATTR_VISIBILITY, - CC_ATTR_OZONE, - CC_ATTR_WIND_GUST, - CC_ATTR_CLOUD_COVER, - CC_ATTR_PRECIPITATION_TYPE, - *(sensor_type.key for sensor_type in CC_SENSOR_TYPES), - ], - [ - CC_ATTR_TEMPERATURE_LOW, - CC_ATTR_TEMPERATURE_HIGH, - CC_ATTR_WIND_SPEED, - CC_ATTR_WIND_DIRECTION, - CC_ATTR_CONDITION, - CC_ATTR_PRECIPITATION, - CC_ATTR_PRECIPITATION_PROBABILITY, - ], - ) + data[CURRENT] = await self._api.realtime( + [ + CC_V3_ATTR_TEMPERATURE, + CC_V3_ATTR_HUMIDITY, + CC_V3_ATTR_PRESSURE, + CC_V3_ATTR_WIND_SPEED, + CC_V3_ATTR_WIND_DIRECTION, + CC_V3_ATTR_CONDITION, + CC_V3_ATTR_VISIBILITY, + CC_V3_ATTR_OZONE, + CC_V3_ATTR_WIND_GUST, + CC_V3_ATTR_CLOUD_COVER, + CC_V3_ATTR_PRECIPITATION_TYPE, + *(sensor_type.key for sensor_type in CC_V3_SENSOR_TYPES), + ] + ) + data[FORECASTS][HOURLY] = await self._api.forecast_hourly( + [ + CC_V3_ATTR_TEMPERATURE, + CC_V3_ATTR_WIND_SPEED, + CC_V3_ATTR_WIND_DIRECTION, + CC_V3_ATTR_CONDITION, + CC_V3_ATTR_PRECIPITATION, + CC_V3_ATTR_PRECIPITATION_PROBABILITY, + ], + None, + timedelta(hours=24), + ) + + data[FORECASTS][DAILY] = await self._api.forecast_daily( + [ + CC_V3_ATTR_TEMPERATURE, + CC_V3_ATTR_WIND_SPEED, + CC_V3_ATTR_WIND_DIRECTION, + CC_V3_ATTR_CONDITION, + CC_V3_ATTR_PRECIPITATION_DAILY, + CC_V3_ATTR_PRECIPITATION_PROBABILITY, + ], + None, + timedelta(days=14), + ) + + data[FORECASTS][NOWCAST] = await self._api.forecast_nowcast( + [ + CC_V3_ATTR_TEMPERATURE, + CC_V3_ATTR_WIND_SPEED, + CC_V3_ATTR_WIND_DIRECTION, + CC_V3_ATTR_CONDITION, + CC_V3_ATTR_PRECIPITATION, + ], + None, + timedelta( + minutes=min(300, self._config_entry.options[CONF_TIMESTEP] * 30) + ), + self._config_entry.options[CONF_TIMESTEP], + ) except ( CantConnectException, InvalidAPIKeyException, @@ -341,14 +312,6 @@ def _get_cc_value( return items.get("value") - def _get_current_property(self, property_name: str) -> int | str | float | None: - """ - Get property from current conditions. - - Used for V4 API. - """ - return self.coordinator.data.get(CURRENT, {}).get(property_name) - @property def attribution(self): """Return the attribution.""" diff --git a/homeassistant/components/climacell/config_flow.py b/homeassistant/components/climacell/config_flow.py index 61cae798ff18b..ffc76479a4d65 100644 --- a/homeassistant/components/climacell/config_flow.py +++ b/homeassistant/components/climacell/config_flow.py @@ -1,84 +1,15 @@ """Config flow for ClimaCell integration.""" from __future__ import annotations -import logging from typing import Any -from pyclimacell import ClimaCellV3 -from pyclimacell.exceptions import ( - CantConnectException, - InvalidAPIKeyException, - RateLimitedException, -) -from pyclimacell.pyclimacell import ClimaCellV4 import voluptuous as vol -from homeassistant import config_entries, core -from homeassistant.const import ( - CONF_API_KEY, - CONF_API_VERSION, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_NAME, -) -from homeassistant.core import HomeAssistant, callback +from homeassistant import config_entries +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv -from .const import ( - CC_ATTR_TEMPERATURE, - CC_V3_ATTR_TEMPERATURE, - CONF_TIMESTEP, - DEFAULT_NAME, - DEFAULT_TIMESTEP, - DOMAIN, -) - -_LOGGER = logging.getLogger(__name__) - - -def _get_config_schema( - hass: core.HomeAssistant, input_dict: dict[str, Any] = None -) -> vol.Schema: - """ - Return schema defaults for init step based on user input/config dict. - - Retain info already provided for future form views by setting them as - defaults in schema. - """ - if input_dict is None: - input_dict = {} - - return vol.Schema( - { - vol.Required( - CONF_NAME, default=input_dict.get(CONF_NAME, DEFAULT_NAME) - ): str, - vol.Required(CONF_API_KEY, default=input_dict.get(CONF_API_KEY)): str, - vol.Required(CONF_API_VERSION, default=4): vol.In([3, 4]), - vol.Inclusive( - CONF_LATITUDE, - "location", - default=input_dict.get(CONF_LATITUDE, hass.config.latitude), - ): cv.latitude, - vol.Inclusive( - CONF_LONGITUDE, - "location", - default=input_dict.get(CONF_LONGITUDE, hass.config.longitude), - ): cv.longitude, - }, - extra=vol.REMOVE_EXTRA, - ) - - -def _get_unique_id(hass: HomeAssistant, input_dict: dict[str, Any]): - """Return unique ID from config data.""" - return ( - f"{input_dict[CONF_API_KEY]}" - f"_{input_dict.get(CONF_LATITUDE, hass.config.latitude)}" - f"_{input_dict.get(CONF_LONGITUDE, hass.config.longitude)}" - ) +from .const import CONF_TIMESTEP, DEFAULT_TIMESTEP, DOMAIN class ClimaCellOptionsConfigFlow(config_entries.OptionsFlow): @@ -117,45 +48,3 @@ def async_get_options_flow( ) -> ClimaCellOptionsConfigFlow: """Get the options flow for this handler.""" return ClimaCellOptionsConfigFlow(config_entry) - - async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult: - """Handle the initial step.""" - errors = {} - if user_input is not None: - await self.async_set_unique_id( - unique_id=_get_unique_id(self.hass, user_input) - ) - self._abort_if_unique_id_configured() - - try: - if user_input[CONF_API_VERSION] == 3: - api_class = ClimaCellV3 - field = CC_V3_ATTR_TEMPERATURE - else: - api_class = ClimaCellV4 - field = CC_ATTR_TEMPERATURE - await api_class( - user_input[CONF_API_KEY], - str(user_input.get(CONF_LATITUDE, self.hass.config.latitude)), - str(user_input.get(CONF_LONGITUDE, self.hass.config.longitude)), - session=async_get_clientsession(self.hass), - ).realtime([field]) - - return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input - ) - except CantConnectException: - errors["base"] = "cannot_connect" - except InvalidAPIKeyException: - errors[CONF_API_KEY] = "invalid_api_key" - except RateLimitedException: - errors[CONF_API_KEY] = "rate_limited" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - - return self.async_show_form( - step_id="user", - data_schema=_get_config_schema(self.hass, user_input), - errors=errors, - ) diff --git a/homeassistant/components/climacell/const.py b/homeassistant/components/climacell/const.py index 7ee804f42f188..f7ca21259e1d1 100644 --- a/homeassistant/components/climacell/const.py +++ b/homeassistant/components/climacell/const.py @@ -5,17 +5,7 @@ from dataclasses import dataclass from enum import IntEnum -from pyclimacell.const import ( - DAILY, - HOURLY, - NOWCAST, - HealthConcernType, - PollenIndex, - PrecipitationType, - PrimaryPollutantType, - V3PollenIndex, - WeatherCode, -) +from pyclimacell.const import DAILY, HOURLY, NOWCAST, V3PollenIndex from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription from homeassistant.components.weather import ( @@ -37,22 +27,7 @@ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, - IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, - IRRADIATION_WATTS_PER_SQUARE_METER, - LENGTH_KILOMETERS, - LENGTH_METERS, - LENGTH_MILES, - PERCENTAGE, - PRESSURE_HPA, - PRESSURE_INHG, - SPEED_METERS_PER_SECOND, - SPEED_MILES_PER_HOUR, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, ) -from homeassistant.util.distance import convert as distance_convert -from homeassistant.util.pressure import convert as pressure_convert -from homeassistant.util.temperature import convert as temp_convert CONF_TIMESTEP = "timestep" FORECAST_TYPES = [DAILY, HOURLY, NOWCAST] @@ -78,75 +53,6 @@ ATTR_CLOUD_COVER = "cloud_cover" ATTR_PRECIPITATION_TYPE = "precipitation_type" -# V4 constants -CONDITIONS = { - WeatherCode.WIND: ATTR_CONDITION_WINDY, - WeatherCode.LIGHT_WIND: ATTR_CONDITION_WINDY, - WeatherCode.STRONG_WIND: ATTR_CONDITION_WINDY, - WeatherCode.FREEZING_RAIN: ATTR_CONDITION_SNOWY_RAINY, - WeatherCode.HEAVY_FREEZING_RAIN: ATTR_CONDITION_SNOWY_RAINY, - WeatherCode.LIGHT_FREEZING_RAIN: ATTR_CONDITION_SNOWY_RAINY, - WeatherCode.FREEZING_DRIZZLE: ATTR_CONDITION_SNOWY_RAINY, - WeatherCode.ICE_PELLETS: ATTR_CONDITION_HAIL, - WeatherCode.HEAVY_ICE_PELLETS: ATTR_CONDITION_HAIL, - WeatherCode.LIGHT_ICE_PELLETS: ATTR_CONDITION_HAIL, - WeatherCode.SNOW: ATTR_CONDITION_SNOWY, - WeatherCode.HEAVY_SNOW: ATTR_CONDITION_SNOWY, - WeatherCode.LIGHT_SNOW: ATTR_CONDITION_SNOWY, - WeatherCode.FLURRIES: ATTR_CONDITION_SNOWY, - WeatherCode.THUNDERSTORM: ATTR_CONDITION_LIGHTNING, - WeatherCode.RAIN: ATTR_CONDITION_POURING, - WeatherCode.HEAVY_RAIN: ATTR_CONDITION_RAINY, - WeatherCode.LIGHT_RAIN: ATTR_CONDITION_RAINY, - WeatherCode.DRIZZLE: ATTR_CONDITION_RAINY, - WeatherCode.FOG: ATTR_CONDITION_FOG, - WeatherCode.LIGHT_FOG: ATTR_CONDITION_FOG, - WeatherCode.CLOUDY: ATTR_CONDITION_CLOUDY, - WeatherCode.MOSTLY_CLOUDY: ATTR_CONDITION_CLOUDY, - WeatherCode.PARTLY_CLOUDY: ATTR_CONDITION_PARTLYCLOUDY, -} - -# Weather constants -CC_ATTR_TIMESTAMP = "startTime" -CC_ATTR_TEMPERATURE = "temperature" -CC_ATTR_TEMPERATURE_HIGH = "temperatureMax" -CC_ATTR_TEMPERATURE_LOW = "temperatureMin" -CC_ATTR_PRESSURE = "pressureSeaLevel" -CC_ATTR_HUMIDITY = "humidity" -CC_ATTR_WIND_SPEED = "windSpeed" -CC_ATTR_WIND_DIRECTION = "windDirection" -CC_ATTR_OZONE = "pollutantO3" -CC_ATTR_CONDITION = "weatherCode" -CC_ATTR_VISIBILITY = "visibility" -CC_ATTR_PRECIPITATION = "precipitationIntensityAvg" -CC_ATTR_PRECIPITATION_PROBABILITY = "precipitationProbability" -CC_ATTR_WIND_GUST = "windGust" -CC_ATTR_CLOUD_COVER = "cloudCover" -CC_ATTR_PRECIPITATION_TYPE = "precipitationType" - -# Sensor attributes -CC_ATTR_PARTICULATE_MATTER_25 = "particulateMatter25" -CC_ATTR_PARTICULATE_MATTER_10 = "particulateMatter10" -CC_ATTR_NITROGEN_DIOXIDE = "pollutantNO2" -CC_ATTR_CARBON_MONOXIDE = "pollutantCO" -CC_ATTR_SULFUR_DIOXIDE = "pollutantSO2" -CC_ATTR_EPA_AQI = "epaIndex" -CC_ATTR_EPA_PRIMARY_POLLUTANT = "epaPrimaryPollutant" -CC_ATTR_EPA_HEALTH_CONCERN = "epaHealthConcern" -CC_ATTR_CHINA_AQI = "mepIndex" -CC_ATTR_CHINA_PRIMARY_POLLUTANT = "mepPrimaryPollutant" -CC_ATTR_CHINA_HEALTH_CONCERN = "mepHealthConcern" -CC_ATTR_POLLEN_TREE = "treeIndex" -CC_ATTR_POLLEN_WEED = "weedIndex" -CC_ATTR_POLLEN_GRASS = "grassIndex" -CC_ATTR_FIRE_INDEX = "fireIndex" -CC_ATTR_FEELS_LIKE = "temperatureApparent" -CC_ATTR_DEW_POINT = "dewPoint" -CC_ATTR_PRESSURE_SURFACE_LEVEL = "pressureSurfaceLevel" -CC_ATTR_SOLAR_GHI = "solarGHI" -CC_ATTR_CLOUD_BASE = "cloudBase" -CC_ATTR_CLOUD_CEILING = "cloudCeiling" - @dataclass class ClimaCellSensorEntityDescription(SensorEntityDescription): @@ -169,187 +75,6 @@ def __post_init__(self) -> None: ) -CC_SENSOR_TYPES = ( - ClimaCellSensorEntityDescription( - key=CC_ATTR_FEELS_LIKE, - name="Feels Like", - unit_imperial=TEMP_FAHRENHEIT, - unit_metric=TEMP_CELSIUS, - metric_conversion=lambda val: temp_convert(val, TEMP_FAHRENHEIT, TEMP_CELSIUS), - is_metric_check=True, - device_class=SensorDeviceClass.TEMPERATURE, - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_DEW_POINT, - name="Dew Point", - unit_imperial=TEMP_FAHRENHEIT, - unit_metric=TEMP_CELSIUS, - metric_conversion=lambda val: temp_convert(val, TEMP_FAHRENHEIT, TEMP_CELSIUS), - is_metric_check=True, - device_class=SensorDeviceClass.TEMPERATURE, - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_PRESSURE_SURFACE_LEVEL, - name="Pressure (Surface Level)", - unit_imperial=PRESSURE_INHG, - unit_metric=PRESSURE_HPA, - metric_conversion=lambda val: pressure_convert( - val, PRESSURE_INHG, PRESSURE_HPA - ), - is_metric_check=True, - device_class=SensorDeviceClass.PRESSURE, - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_SOLAR_GHI, - name="Global Horizontal Irradiance", - unit_imperial=IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, - unit_metric=IRRADIATION_WATTS_PER_SQUARE_METER, - metric_conversion=3.15459, - is_metric_check=True, - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_CLOUD_BASE, - name="Cloud Base", - unit_imperial=LENGTH_MILES, - unit_metric=LENGTH_KILOMETERS, - metric_conversion=lambda val: distance_convert( - val, LENGTH_MILES, LENGTH_KILOMETERS - ), - is_metric_check=True, - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_CLOUD_CEILING, - name="Cloud Ceiling", - unit_imperial=LENGTH_MILES, - unit_metric=LENGTH_KILOMETERS, - metric_conversion=lambda val: distance_convert( - val, LENGTH_MILES, LENGTH_KILOMETERS - ), - is_metric_check=True, - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_CLOUD_COVER, - name="Cloud Cover", - unit_imperial=PERCENTAGE, - unit_metric=PERCENTAGE, - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_WIND_GUST, - name="Wind Gust", - unit_imperial=SPEED_MILES_PER_HOUR, - unit_metric=SPEED_METERS_PER_SECOND, - metric_conversion=lambda val: distance_convert(val, LENGTH_MILES, LENGTH_METERS) - / 3600, - is_metric_check=True, - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_PRECIPITATION_TYPE, - name="Precipitation Type", - value_map=PrecipitationType, - device_class="climacell__precipitation_type", - icon="mdi:weather-snowy-rainy", - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_OZONE, - name="Ozone", - unit_imperial=CONCENTRATION_PARTS_PER_BILLION, - unit_metric=CONCENTRATION_PARTS_PER_BILLION, - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_PARTICULATE_MATTER_25, - name="Particulate Matter < 2.5 μm", - unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, - unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - metric_conversion=3.2808399**3, - is_metric_check=True, - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_PARTICULATE_MATTER_10, - name="Particulate Matter < 10 μm", - unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, - unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - metric_conversion=3.2808399**3, - is_metric_check=True, - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_NITROGEN_DIOXIDE, - name="Nitrogen Dioxide", - unit_imperial=CONCENTRATION_PARTS_PER_BILLION, - unit_metric=CONCENTRATION_PARTS_PER_BILLION, - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_CARBON_MONOXIDE, - name="Carbon Monoxide", - unit_imperial=CONCENTRATION_PARTS_PER_MILLION, - unit_metric=CONCENTRATION_PARTS_PER_MILLION, - device_class=SensorDeviceClass.CO, - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_SULFUR_DIOXIDE, - name="Sulfur Dioxide", - unit_imperial=CONCENTRATION_PARTS_PER_BILLION, - unit_metric=CONCENTRATION_PARTS_PER_BILLION, - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_EPA_AQI, - name="US EPA Air Quality Index", - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_EPA_PRIMARY_POLLUTANT, - name="US EPA Primary Pollutant", - value_map=PrimaryPollutantType, - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_EPA_HEALTH_CONCERN, - name="US EPA Health Concern", - value_map=HealthConcernType, - device_class="climacell__health_concern", - icon="mdi:hospital", - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_CHINA_AQI, - name="China MEP Air Quality Index", - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_CHINA_PRIMARY_POLLUTANT, - name="China MEP Primary Pollutant", - value_map=PrimaryPollutantType, - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_CHINA_HEALTH_CONCERN, - name="China MEP Health Concern", - value_map=HealthConcernType, - device_class="climacell__health_concern", - icon="mdi:hospital", - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_POLLEN_TREE, - name="Tree Pollen Index", - value_map=PollenIndex, - device_class="climacell__pollen_index", - icon="mdi:flower-pollen", - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_POLLEN_WEED, - name="Weed Pollen Index", - value_map=PollenIndex, - device_class="climacell__pollen_index", - icon="mdi:flower-pollen", - ), - ClimaCellSensorEntityDescription( - key=CC_ATTR_POLLEN_GRASS, - name="Grass Pollen Index", - value_map=PollenIndex, - device_class="climacell__pollen_index", - icon="mdi:flower-pollen", - ), - ClimaCellSensorEntityDescription( - CC_ATTR_FIRE_INDEX, - name="Fire Index", - icon="mdi:fire", - ), -) - # V3 constants CONDITIONS_V3 = { "breezy": ATTR_CONDITION_WINDY, diff --git a/homeassistant/components/climacell/manifest.json b/homeassistant/components/climacell/manifest.json index 4928d92447e15..73594c37dfbba 100644 --- a/homeassistant/components/climacell/manifest.json +++ b/homeassistant/components/climacell/manifest.json @@ -1,9 +1,10 @@ { "domain": "climacell", "name": "ClimaCell", - "config_flow": true, + "config_flow": false, "documentation": "https://www.home-assistant.io/integrations/climacell", "requirements": ["pyclimacell==0.18.2"], + "after_dependencies": ["tomorrowio"], "codeowners": ["@raman325"], "iot_class": "cloud_polling", "loggers": ["pyclimacell"] diff --git a/homeassistant/components/climacell/sensor.py b/homeassistant/components/climacell/sensor.py index 597e1095f89eb..4eb9dddb9c3e5 100644 --- a/homeassistant/components/climacell/sensor.py +++ b/homeassistant/components/climacell/sensor.py @@ -1,8 +1,6 @@ """Sensor component that handles additional ClimaCell data for your location.""" from __future__ import annotations -from abc import abstractmethod - from pyclimacell.const import CURRENT from homeassistant.components.sensor import SensorEntity @@ -13,12 +11,7 @@ from homeassistant.util import slugify from . import ClimaCellDataUpdateCoordinator, ClimaCellEntity -from .const import ( - CC_SENSOR_TYPES, - CC_V3_SENSOR_TYPES, - DOMAIN, - ClimaCellSensorEntityDescription, -) +from .const import CC_V3_SENSOR_TYPES, DOMAIN, ClimaCellSensorEntityDescription async def async_setup_entry( @@ -28,24 +21,18 @@ async def async_setup_entry( ) -> None: """Set up a config entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - api_class: type[BaseClimaCellSensorEntity] - sensor_types: tuple[ClimaCellSensorEntityDescription, ...] - - if (api_version := config_entry.data[CONF_API_VERSION]) == 3: - api_class = ClimaCellV3SensorEntity - sensor_types = CC_V3_SENSOR_TYPES - else: - api_class = ClimaCellSensorEntity - sensor_types = CC_SENSOR_TYPES + api_version = config_entry.data[CONF_API_VERSION] entities = [ - api_class(hass, config_entry, coordinator, api_version, description) - for description in sensor_types + ClimaCellV3SensorEntity( + hass, config_entry, coordinator, api_version, description + ) + for description in CC_V3_SENSOR_TYPES ] async_add_entities(entities) -class BaseClimaCellSensorEntity(ClimaCellEntity, SensorEntity): - """Base ClimaCell sensor entity.""" +class ClimaCellV3SensorEntity(ClimaCellEntity, SensorEntity): + """Sensor entity that talks to ClimaCell v3 API to retrieve non-weather data.""" entity_description: ClimaCellSensorEntityDescription @@ -72,15 +59,12 @@ def __init__( else description.unit_imperial ) - @property - @abstractmethod - def _state(self) -> str | int | float | None: - """Return the raw state.""" - @property def native_value(self) -> str | int | float | None: """Return the state.""" - state = self._state + state = self._get_cc_value( + self.coordinator.data[CURRENT], self.entity_description.key + ) if ( state is not None and not isinstance(state, str) @@ -102,23 +86,3 @@ def native_value(self) -> str | int | float | None: return self.entity_description.value_map(state).name.lower() # type: ignore[misc] return state - - -class ClimaCellSensorEntity(BaseClimaCellSensorEntity): - """Sensor entity that talks to ClimaCell v4 API to retrieve non-weather data.""" - - @property - def _state(self) -> str | int | float | None: - """Return the raw state.""" - return self._get_current_property(self.entity_description.key) - - -class ClimaCellV3SensorEntity(BaseClimaCellSensorEntity): - """Sensor entity that talks to ClimaCell v3 API to retrieve non-weather data.""" - - @property - def _state(self) -> str | int | float | None: - """Return the raw state.""" - return self._get_cc_value( - self.coordinator.data[CURRENT], self.entity_description.key - ) diff --git a/homeassistant/components/climacell/strings.json b/homeassistant/components/climacell/strings.json index 7b6e01b8dd4e7..25ddee09dd0b0 100644 --- a/homeassistant/components/climacell/strings.json +++ b/homeassistant/components/climacell/strings.json @@ -1,24 +1,4 @@ { - "config": { - "step": { - "user": { - "description": "If [%key:common::config_flow::data::latitude%] and [%key:common::config_flow::data::longitude%] are not provided, the default values in the Home Assistant configuration will be used. An entity will be created for each forecast type but only the ones you select will be enabled by default.", - "data": { - "name": "[%key:common::config_flow::data::name%]", - "api_key": "[%key:common::config_flow::data::api_key%]", - "api_version": "API Version", - "latitude": "[%key:common::config_flow::data::latitude%]", - "longitude": "[%key:common::config_flow::data::longitude%]" - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", - "unknown": "[%key:common::config_flow::error::unknown%]", - "rate_limited": "Currently rate limited, please try again later." - } - }, "options": { "step": { "init": { diff --git a/homeassistant/components/climacell/translations/en.json b/homeassistant/components/climacell/translations/en.json index 3e5cd436ba838..a35be85d5b262 100644 --- a/homeassistant/components/climacell/translations/en.json +++ b/homeassistant/components/climacell/translations/en.json @@ -1,24 +1,4 @@ { - "config": { - "error": { - "cannot_connect": "Failed to connect", - "invalid_api_key": "Invalid API key", - "rate_limited": "Currently rate limited, please try again later.", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "api_key": "API Key", - "api_version": "API Version", - "latitude": "Latitude", - "longitude": "Longitude", - "name": "Name" - }, - "description": "If Latitude and Longitude are not provided, the default values in the Home Assistant configuration will be used. An entity will be created for each forecast type but only the ones you select will be enabled by default." - } - } - }, "options": { "step": { "init": { @@ -29,6 +9,5 @@ "title": "Update ClimaCell Options" } } - }, - "title": "ClimaCell" + } } \ No newline at end of file diff --git a/homeassistant/components/climacell/weather.py b/homeassistant/components/climacell/weather.py index e62ed4bab7c16..0167cb7251338 100644 --- a/homeassistant/components/climacell/weather.py +++ b/homeassistant/components/climacell/weather.py @@ -6,15 +6,7 @@ from datetime import datetime from typing import Any, cast -from pyclimacell.const import ( - CURRENT, - DAILY, - FORECASTS, - HOURLY, - NOWCAST, - PrecipitationType, - WeatherCode, -) +from pyclimacell.const import CURRENT, DAILY, FORECASTS, HOURLY, NOWCAST from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, @@ -54,22 +46,6 @@ ATTR_CLOUD_COVER, ATTR_PRECIPITATION_TYPE, ATTR_WIND_GUST, - CC_ATTR_CLOUD_COVER, - CC_ATTR_CONDITION, - CC_ATTR_HUMIDITY, - CC_ATTR_OZONE, - CC_ATTR_PRECIPITATION, - CC_ATTR_PRECIPITATION_PROBABILITY, - CC_ATTR_PRECIPITATION_TYPE, - CC_ATTR_PRESSURE, - CC_ATTR_TEMPERATURE, - CC_ATTR_TEMPERATURE_HIGH, - CC_ATTR_TEMPERATURE_LOW, - CC_ATTR_TIMESTAMP, - CC_ATTR_VISIBILITY, - CC_ATTR_WIND_DIRECTION, - CC_ATTR_WIND_GUST, - CC_ATTR_WIND_SPEED, CC_V3_ATTR_CLOUD_COVER, CC_V3_ATTR_CONDITION, CC_V3_ATTR_HUMIDITY, @@ -88,12 +64,10 @@ CC_V3_ATTR_WIND_GUST, CC_V3_ATTR_WIND_SPEED, CLEAR_CONDITIONS, - CONDITIONS, CONDITIONS_V3, CONF_TIMESTEP, DEFAULT_FORECAST_TYPE, DOMAIN, - MAX_FORECASTS, ) @@ -105,10 +79,8 @@ async def async_setup_entry( """Set up a config entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] api_version = config_entry.data[CONF_API_VERSION] - - api_class = ClimaCellV3WeatherEntity if api_version == 3 else ClimaCellWeatherEntity entities = [ - api_class(config_entry, coordinator, api_version, forecast_type) + ClimaCellV3WeatherEntity(config_entry, coordinator, api_version, forecast_type) for forecast_type in (DAILY, HOURLY, NOWCAST) ] async_add_entities(entities) @@ -267,154 +239,6 @@ def visibility(self): return self._visibility -class ClimaCellWeatherEntity(BaseClimaCellWeatherEntity): - """Entity that talks to ClimaCell v4 API to retrieve weather data.""" - - _attr_temperature_unit = TEMP_FAHRENHEIT - - @staticmethod - def _translate_condition( - condition: int | str | None, sun_is_up: bool = True - ) -> str | None: - """Translate ClimaCell condition into an HA condition.""" - if condition is None: - return None - # We won't guard here, instead we will fail hard - condition = WeatherCode(condition) - if condition in (WeatherCode.CLEAR, WeatherCode.MOSTLY_CLEAR): - if sun_is_up: - return CLEAR_CONDITIONS["day"] - return CLEAR_CONDITIONS["night"] - return CONDITIONS[condition] - - @property - def temperature(self): - """Return the platform temperature.""" - return self._get_current_property(CC_ATTR_TEMPERATURE) - - @property - def _pressure(self): - """Return the raw pressure.""" - return self._get_current_property(CC_ATTR_PRESSURE) - - @property - def humidity(self): - """Return the humidity.""" - return self._get_current_property(CC_ATTR_HUMIDITY) - - @property - def wind_gust(self): - """Return the wind gust speed.""" - return self._get_current_property(CC_ATTR_WIND_GUST) - - @property - def cloud_cover(self): - """Return the cloud cover.""" - return self._get_current_property(CC_ATTR_CLOUD_COVER) - - @property - def precipitation_type(self): - """Return precipitation type.""" - precipitation_type = self._get_current_property(CC_ATTR_PRECIPITATION_TYPE) - if precipitation_type is None: - return None - return PrecipitationType(precipitation_type).name.lower() - - @property - def _wind_speed(self): - """Return the raw wind speed.""" - return self._get_current_property(CC_ATTR_WIND_SPEED) - - @property - def wind_bearing(self): - """Return the wind bearing.""" - return self._get_current_property(CC_ATTR_WIND_DIRECTION) - - @property - def ozone(self): - """Return the O3 (ozone) level.""" - return self._get_current_property(CC_ATTR_OZONE) - - @property - def condition(self): - """Return the condition.""" - return self._translate_condition( - self._get_current_property(CC_ATTR_CONDITION), - is_up(self.hass), - ) - - @property - def _visibility(self): - """Return the raw visibility.""" - return self._get_current_property(CC_ATTR_VISIBILITY) - - @property - def forecast(self): - """Return the forecast.""" - # Check if forecasts are available - raw_forecasts = self.coordinator.data.get(FORECASTS, {}).get(self.forecast_type) - if not raw_forecasts: - return None - - forecasts = [] - max_forecasts = MAX_FORECASTS[self.forecast_type] - forecast_count = 0 - - # Set default values (in cases where keys don't exist), None will be - # returned. Override properties per forecast type as needed - for forecast in raw_forecasts: - forecast_dt = dt_util.parse_datetime(forecast[CC_ATTR_TIMESTAMP]) - - # Throw out past data - if forecast_dt.date() < dt_util.utcnow().date(): - continue - - values = forecast["values"] - use_datetime = True - - condition = values.get(CC_ATTR_CONDITION) - precipitation = values.get(CC_ATTR_PRECIPITATION) - precipitation_probability = values.get(CC_ATTR_PRECIPITATION_PROBABILITY) - - temp = values.get(CC_ATTR_TEMPERATURE_HIGH) - temp_low = None - wind_direction = values.get(CC_ATTR_WIND_DIRECTION) - wind_speed = values.get(CC_ATTR_WIND_SPEED) - - if self.forecast_type == DAILY: - use_datetime = False - temp_low = values.get(CC_ATTR_TEMPERATURE_LOW) - if precipitation: - precipitation = precipitation * 24 - elif self.forecast_type == NOWCAST: - # Precipitation is forecasted in CONF_TIMESTEP increments but in a - # per hour rate, so value needs to be converted to an amount. - if precipitation: - precipitation = ( - precipitation / 60 * self._config_entry.options[CONF_TIMESTEP] - ) - - forecasts.append( - self._forecast_dict( - forecast_dt, - use_datetime, - condition, - precipitation, - precipitation_probability, - temp, - temp_low, - wind_direction, - wind_speed, - ) - ) - - forecast_count += 1 - if forecast_count == max_forecasts: - break - - return forecasts - - class ClimaCellV3WeatherEntity(BaseClimaCellWeatherEntity): """Entity that talks to ClimaCell v3 API to retrieve weather data.""" diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 0c6375064475f..23303474e3b1f 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -9,13 +9,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION, PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers import update_coordinator from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import CO2SignalCoordinator, CO2SignalResponse +from . import CO2SignalCoordinator from .const import ATTRIBUTION, DOMAIN SCAN_INTERVAL = timedelta(minutes=3) @@ -55,7 +55,7 @@ async def async_setup_entry( async_add_entities(CO2Sensor(coordinator, description) for description in SENSORS) -class CO2Sensor(update_coordinator.CoordinatorEntity[CO2SignalResponse], SensorEntity): +class CO2Sensor(CoordinatorEntity[CO2SignalCoordinator], SensorEntity): """Implementation of the CO2Signal sensor.""" _attr_state_class = SensorStateClass.MEASUREMENT diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 07bdc794128b1..f3a239b58227b 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,13 +1,14 @@ """Http views to control the config manager.""" from __future__ import annotations +import asyncio from http import HTTPStatus from aiohttp import web import aiohttp.web_exceptions import voluptuous as vol -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries, data_entry_flow, loader from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES, POLICY_EDIT from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView @@ -48,11 +49,36 @@ class ConfigManagerEntryIndexView(HomeAssistantView): async def get(self, request): """List available config entries.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] - return self.json( - [entry_json(entry) for entry in hass.config_entries.async_entries()] - ) + kwargs = {} + if "domain" in request.query: + kwargs["domain"] = request.query["domain"] + + entries = hass.config_entries.async_entries(**kwargs) + + if "type" not in request.query: + return self.json([entry_json(entry) for entry in entries]) + + integrations = {} + type_filter = request.query["type"] + + # Fetch all the integrations so we can check their type + for integration in await asyncio.gather( + *( + loader.async_get_integration(hass, domain) + for domain in {entry.domain for entry in entries} + ) + ): + integrations[integration.domain] = integration + + entries = [ + entry + for entry in entries + if integrations[entry.domain].integration_type == type_filter + ] + + return self.json([entry_json(entry) for entry in entries]) class ConfigManagerEntryResourceView(HomeAssistantView): @@ -179,7 +205,10 @@ class ConfigManagerAvailableFlowView(HomeAssistantView): async def get(self, request): """List available flow handlers.""" hass = request.app["hass"] - return self.json(await async_get_config_flows(hass)) + kwargs = {} + if "type" in request.query: + kwargs["type_filter"] = request.query["type"] + return self.json(await async_get_config_flows(hass, **kwargs)) class OptionManagerFlowIndexView(FlowManagerIndexView): diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index abee8310e171e..2afb58aff702c 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -40,6 +40,7 @@ "sensor", "siren", "switch", + "update", "vacuum", "water_heater", ] diff --git a/homeassistant/components/demo/update.py b/homeassistant/components/demo/update.py new file mode 100644 index 0000000000000..a48c4a3cab26a --- /dev/null +++ b/homeassistant/components/demo/update.py @@ -0,0 +1,153 @@ +"""Demo platform that offers fake update entities.""" +from __future__ import annotations + +import asyncio +from typing import Any + +from homeassistant.components.update import UpdateDeviceClass, UpdateEntity +from homeassistant.components.update.const import UpdateEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import DOMAIN + +FAKE_INSTALL_SLEEP_TIME = 0.5 + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up demo update entities.""" + async_add_entities( + [ + DemoUpdate( + unique_id="update_no_install", + name="Demo Update No Install", + title="Awesomesoft Inc.", + current_version="1.0.0", + latest_version="1.0.1", + release_summary="Awesome update, fixing everything!", + release_url="https://www.example.com/release/1.0.1", + support_install=False, + ), + DemoUpdate( + unique_id="update_2_date", + name="Demo No Update", + title="AdGuard Home", + current_version="1.0.0", + latest_version="1.0.0", + ), + DemoUpdate( + unique_id="update_addon", + name="Demo add-on", + title="AdGuard Home", + current_version="1.0.0", + latest_version="1.0.1", + release_summary="Awesome update, fixing everything!", + release_url="https://www.example.com/release/1.0.1", + ), + DemoUpdate( + unique_id="update_light_bulb", + name="Demo Living Room Bulb Update", + title="Philips Lamps Firmware", + current_version="1.93.3", + latest_version="1.94.2", + release_summary="Added support for effects", + release_url="https://www.example.com/release/1.93.3", + device_class=UpdateDeviceClass.FIRMWARE, + ), + DemoUpdate( + unique_id="update_support_progress", + name="Demo Update with Progress", + title="Philips Lamps Firmware", + current_version="1.93.3", + latest_version="1.94.2", + support_progress=True, + release_summary="Added support for effects", + release_url="https://www.example.com/release/1.93.3", + device_class=UpdateDeviceClass.FIRMWARE, + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +async def _fake_install() -> None: + """Fake install an update.""" + await asyncio.sleep(FAKE_INSTALL_SLEEP_TIME) + + +class DemoUpdate(UpdateEntity): + """Representation of a demo update entity.""" + + _attr_should_poll = False + + def __init__( + self, + *, + unique_id: str, + name: str, + title: str | None, + current_version: str | None, + latest_version: str | None, + release_summary: str | None = None, + release_url: str | None = None, + support_progress: bool = False, + support_install: bool = True, + device_class: UpdateDeviceClass | None = None, + ) -> None: + """Initialize the Demo select entity.""" + self._attr_current_version = current_version + self._attr_device_class = device_class + self._attr_latest_version = latest_version + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_release_summary = release_summary + self._attr_release_url = release_url + self._attr_title = title + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=name, + ) + if support_install: + self._attr_supported_features |= ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.BACKUP + | UpdateEntityFeature.SPECIFIC_VERSION + ) + if support_progress: + self._attr_supported_features |= UpdateEntityFeature.PROGRESS + + async def async_install( + self, + version: str | None = None, + backup: bool | None = None, + **kwargs: Any, + ) -> None: + """Install an update.""" + if self.supported_features & UpdateEntityFeature.PROGRESS: + for progress in range(0, 100, 10): + self._attr_in_progress = progress + self.async_write_ha_state() + await _fake_install() + + self._attr_in_progress = False + self._attr_current_version = ( + version if version is not None else self.latest_version + ) + self.async_write_ha_state() diff --git a/homeassistant/components/derivative/manifest.json b/homeassistant/components/derivative/manifest.json index bed23d33e1561..665c4cb019292 100644 --- a/homeassistant/components/derivative/manifest.json +++ b/homeassistant/components/derivative/manifest.json @@ -1,5 +1,6 @@ { "domain": "derivative", + "integration_type": "helper", "name": "Derivative", "documentation": "https://www.home-assistant.io/integrations/derivative", "codeowners": [ diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index 3c08a75448fc5..6d99ba59735bd 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -3,6 +3,7 @@ from datetime import timedelta import logging +from typing import Union from pyeight.eight import EightSleep from pyeight.user import EightUser @@ -227,7 +228,11 @@ async def _async_update_data(self) -> None: await self.api.update_user_data() -class EightSleepBaseEntity(CoordinatorEntity): +class EightSleepBaseEntity( + CoordinatorEntity[ + Union[EightSleepUserDataCoordinator, EightSleepHeatDataCoordinator] + ] +): """The base Eight Sleep entity class.""" def __init__( diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index cc3dd93d9c414..d9f7396387067 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -61,14 +61,14 @@ async def async_setup_entry( ) -class ElgatoLight(ElgatoEntity, CoordinatorEntity, LightEntity): +class ElgatoLight( + ElgatoEntity, CoordinatorEntity[DataUpdateCoordinator[State]], LightEntity +): """Defines an Elgato Light.""" - coordinator: DataUpdateCoordinator[State] - def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: DataUpdateCoordinator[State], client: Elgato, info: Info, mac: str | None, diff --git a/homeassistant/components/elmax/common.py b/homeassistant/components/elmax/common.py index e9854cc5d7ae7..2d66ca9f72e7a 100644 --- a/homeassistant/components/elmax/common.py +++ b/homeassistant/components/elmax/common.py @@ -116,11 +116,9 @@ async def _async_update_data(self): ) from err -class ElmaxEntity(CoordinatorEntity): +class ElmaxEntity(CoordinatorEntity[ElmaxCoordinator]): """Wrapper for Elmax entities.""" - coordinator: ElmaxCoordinator - def __init__( self, panel: PanelEntry, diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index fa43cb61ffe98..88310579e7203 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -16,6 +16,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.util.network import is_ipv4_address from .const import DOMAIN @@ -86,6 +87,8 @@ async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle a flow initialized by zeroconf discovery.""" + if not is_ipv4_address(discovery_info.host): + return self.async_abort(reason="not_ipv4_address") serial = discovery_info.properties["serialnum"] await self.async_set_unique_id(serial) self.ip_address = discovery_info.host diff --git a/homeassistant/components/enphase_envoy/translations/en.json b/homeassistant/components/enphase_envoy/translations/en.json index 5d4617ed9fac7..ff600fea45447 100644 --- a/homeassistant/components/enphase_envoy/translations/en.json +++ b/homeassistant/components/enphase_envoy/translations/en.json @@ -2,7 +2,8 @@ "config": { "abort": { "already_configured": "Device is already configured", - "reauth_successful": "Re-authentication was successful" + "reauth_successful": "Re-authentication was successful", + "not_ipv4_address": "Only IPv4 addresess are supported" }, "error": { "cannot_connect": "Failed to connect", diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 0154e2eba28c2..fd9b5dfd6d2d6 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -31,6 +31,7 @@ from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_DEVICE_ID, CONF_HOST, CONF_MODE, CONF_PASSWORD, @@ -192,7 +193,13 @@ def async_on_service_call(service: HomeassistantServiceCall) -> None: hass.async_create_task(tag.async_scan_tag(tag_id, device_id)) return - hass.bus.async_fire(service.service, service_data) + hass.bus.async_fire( + service.service, + { + ATTR_DEVICE_ID: device_id, + **service_data, + }, + ) else: hass.async_create_task( hass.services.async_call( diff --git a/homeassistant/components/evil_genius_labs/__init__.py b/homeassistant/components/evil_genius_labs/__init__.py index 2fbd85938f0f8..ff858db566a8c 100644 --- a/homeassistant/components/evil_genius_labs/__init__.py +++ b/homeassistant/components/evil_genius_labs/__init__.py @@ -11,12 +11,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - aiohttp_client, - device_registry as dr, - update_coordinator, -) +from homeassistant.helpers import aiohttp_client, device_registry as dr from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from .const import DOMAIN @@ -49,7 +49,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class EvilGeniusUpdateCoordinator(update_coordinator.DataUpdateCoordinator[dict]): +class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]): """Update coordinator for Evil Genius data.""" info: dict @@ -81,11 +81,9 @@ async def _async_update_data(self) -> dict: return cast(dict, await self.client.get_data()) -class EvilGeniusEntity(update_coordinator.CoordinatorEntity): +class EvilGeniusEntity(CoordinatorEntity[EvilGeniusUpdateCoordinator]): """Base entity for Evil Genius.""" - coordinator: EvilGeniusUpdateCoordinator - @property def device_info(self) -> DeviceInfo: """Return device info.""" diff --git a/homeassistant/components/ezviz/binary_sensor.py b/homeassistant/components/ezviz/binary_sensor.py index 942ceeecdb241..43ac914a50c26 100644 --- a/homeassistant/components/ezviz/binary_sensor.py +++ b/homeassistant/components/ezviz/binary_sensor.py @@ -54,8 +54,6 @@ async def async_setup_entry( class EzvizBinarySensor(EzvizEntity, BinarySensorEntity): """Representation of a Ezviz sensor.""" - coordinator: EzvizDataUpdateCoordinator - def __init__( self, coordinator: EzvizDataUpdateCoordinator, diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 6680466ecf083..58b45eeafd33f 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -228,8 +228,6 @@ async def async_setup_entry( class EzvizCamera(EzvizEntity, Camera): """An implementation of a Ezviz security camera.""" - coordinator: EzvizDataUpdateCoordinator - def __init__( self, hass: HomeAssistant, diff --git a/homeassistant/components/ezviz/entity.py b/homeassistant/components/ezviz/entity.py index 288c4a5d9ebdd..2ab42a93286c0 100644 --- a/homeassistant/components/ezviz/entity.py +++ b/homeassistant/components/ezviz/entity.py @@ -10,7 +10,7 @@ from .coordinator import EzvizDataUpdateCoordinator -class EzvizEntity(CoordinatorEntity, Entity): +class EzvizEntity(CoordinatorEntity[EzvizDataUpdateCoordinator], Entity): """Generic entity encapsulating common features of Ezviz device.""" def __init__( diff --git a/homeassistant/components/ezviz/switch.py b/homeassistant/components/ezviz/switch.py index ea8f1e83f70db..c2e562f62da2c 100644 --- a/homeassistant/components/ezviz/switch.py +++ b/homeassistant/components/ezviz/switch.py @@ -39,7 +39,6 @@ async def async_setup_entry( class EzvizSwitch(EzvizEntity, SwitchEntity): """Representation of a Ezviz sensor.""" - coordinator: EzvizDataUpdateCoordinator _attr_device_class = SwitchDeviceClass.SWITCH def __init__( diff --git a/homeassistant/components/fivem/__init__.py b/homeassistant/components/fivem/__init__.py index 2004aacd165b2..1fe5ccf0b8f8a 100644 --- a/homeassistant/components/fivem/__init__.py +++ b/homeassistant/components/fivem/__init__.py @@ -128,10 +128,9 @@ class FiveMEntityDescription(EntityDescription): extra_attrs: list[str] | None = None -class FiveMEntity(CoordinatorEntity): +class FiveMEntity(CoordinatorEntity[FiveMDataUpdateCoordinator]): """Representation of a FiveM base entity.""" - coordinator: FiveMDataUpdateCoordinator entity_description: FiveMEntityDescription def __init__( diff --git a/homeassistant/components/fjaraskupan/binary_sensor.py b/homeassistant/components/fjaraskupan/binary_sensor.py index 9f24a3d39d2cd..f1672530c453e 100644 --- a/homeassistant/components/fjaraskupan/binary_sensor.py +++ b/homeassistant/components/fjaraskupan/binary_sensor.py @@ -67,7 +67,7 @@ def _constructor(device_state: DeviceState) -> list[Entity]: async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) -class BinarySensor(CoordinatorEntity[State], BinarySensorEntity): +class BinarySensor(CoordinatorEntity[DataUpdateCoordinator[State]], BinarySensorEntity): """Grease filter sensor.""" entity_description: EntityDescription diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py index ea4327fc4b61e..4b04910a167d8 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -70,7 +70,7 @@ def _constructor(device_state: DeviceState): async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) -class Fan(CoordinatorEntity[State], FanEntity): +class Fan(CoordinatorEntity[DataUpdateCoordinator[State]], FanEntity): """Fan entity.""" def __init__( diff --git a/homeassistant/components/fjaraskupan/light.py b/homeassistant/components/fjaraskupan/light.py index 8c44460a0997a..7c1e5d3413885 100644 --- a/homeassistant/components/fjaraskupan/light.py +++ b/homeassistant/components/fjaraskupan/light.py @@ -37,7 +37,7 @@ def _constructor(device_state: DeviceState) -> list[Entity]: async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) -class Light(CoordinatorEntity[State], LightEntity): +class Light(CoordinatorEntity[DataUpdateCoordinator[State]], LightEntity): """Light device.""" def __init__( diff --git a/homeassistant/components/fjaraskupan/number.py b/homeassistant/components/fjaraskupan/number.py index eecb0b3b8e1bb..bbde9bd889892 100644 --- a/homeassistant/components/fjaraskupan/number.py +++ b/homeassistant/components/fjaraskupan/number.py @@ -34,7 +34,9 @@ def _constructor(device_state: DeviceState) -> list[Entity]: async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) -class PeriodicVentingTime(CoordinatorEntity[State], NumberEntity): +class PeriodicVentingTime( + CoordinatorEntity[DataUpdateCoordinator[State]], NumberEntity +): """Periodic Venting.""" _attr_max_value: float = 59 diff --git a/homeassistant/components/fjaraskupan/sensor.py b/homeassistant/components/fjaraskupan/sensor.py index 8c19b3e3cec0a..fbd9d5f6d08fd 100644 --- a/homeassistant/components/fjaraskupan/sensor.py +++ b/homeassistant/components/fjaraskupan/sensor.py @@ -39,7 +39,7 @@ def _constructor(device_state: DeviceState) -> list[Entity]: async_setup_entry_platform(hass, config_entry, async_add_entities, _constructor) -class RssiSensor(CoordinatorEntity[State], SensorEntity): +class RssiSensor(CoordinatorEntity[DataUpdateCoordinator[State]], SensorEntity): """Sensor device.""" def __init__( diff --git a/homeassistant/components/flux_led/entity.py b/homeassistant/components/flux_led/entity.py index da92931d1e685..6a77dba948c70 100644 --- a/homeassistant/components/flux_led/entity.py +++ b/homeassistant/components/flux_led/entity.py @@ -66,11 +66,9 @@ def __init__( self._attr_device_info = _async_device_info(self._device, entry) -class FluxEntity(CoordinatorEntity): +class FluxEntity(CoordinatorEntity[FluxLedUpdateCoordinator]): """Representation of a Flux entity with a coordinator.""" - coordinator: FluxLedUpdateCoordinator - def __init__( self, coordinator: FluxLedUpdateCoordinator, diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index 0d179cd2b7720..2942a13c73413 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -187,7 +187,9 @@ async def async_setup_entry( ) -class FluxLight(FluxOnOffEntity, CoordinatorEntity, LightEntity): +class FluxLight( + FluxOnOffEntity, CoordinatorEntity[FluxLedUpdateCoordinator], LightEntity +): """Representation of a Flux light.""" _attr_supported_features = SUPPORT_TRANSITION | SUPPORT_EFFECT diff --git a/homeassistant/components/flux_led/number.py b/homeassistant/components/flux_led/number.py index b4e6e87a829f9..06f706aee218b 100644 --- a/homeassistant/components/flux_led/number.py +++ b/homeassistant/components/flux_led/number.py @@ -92,7 +92,9 @@ async def async_setup_entry( async_add_entities(entities) -class FluxSpeedNumber(FluxEntity, CoordinatorEntity, NumberEntity): +class FluxSpeedNumber( + FluxEntity, CoordinatorEntity[FluxLedUpdateCoordinator], NumberEntity +): """Defines a flux_led speed number.""" _attr_min_value = 1 @@ -122,7 +124,9 @@ async def async_set_value(self, value: float) -> None: await self.coordinator.async_request_refresh() -class FluxConfigNumber(FluxEntity, CoordinatorEntity, NumberEntity): +class FluxConfigNumber( + FluxEntity, CoordinatorEntity[FluxLedUpdateCoordinator], NumberEntity +): """Base class for flux config numbers.""" _attr_entity_category = EntityCategory.CONFIG diff --git a/homeassistant/components/flux_led/switch.py b/homeassistant/components/flux_led/switch.py index e8c34f12b118c..18b079beff9b2 100644 --- a/homeassistant/components/flux_led/switch.py +++ b/homeassistant/components/flux_led/switch.py @@ -52,7 +52,9 @@ async def async_setup_entry( async_add_entities(entities) -class FluxSwitch(FluxOnOffEntity, CoordinatorEntity, SwitchEntity): +class FluxSwitch( + FluxOnOffEntity, CoordinatorEntity[FluxLedUpdateCoordinator], SwitchEntity +): """Representation of a Flux switch.""" async def _async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 21039d45afa29..514697a32f9df 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -817,7 +817,7 @@ class FritzData: profile_switches: dict = field(default_factory=dict) -class FritzDeviceBase(update_coordinator.CoordinatorEntity): +class FritzDeviceBase(update_coordinator.CoordinatorEntity[AvmWrapper]): """Entity base class for a device connected to a FRITZ!Box device.""" def __init__(self, avm_wrapper: AvmWrapper, device: FritzDevice) -> None: diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index e5ef4536bc468..7bb71e52560ed 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -93,11 +93,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class FritzBoxEntity(CoordinatorEntity): +class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator]): """Basis FritzBox entity.""" - coordinator: FritzboxDataUpdateCoordinator - def __init__( self, coordinator: FritzboxDataUpdateCoordinator, diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index a266998a9b5f0..0e00bbd135ebf 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -691,10 +691,9 @@ async def async_setup_entry( ] -class _FroniusSensorEntity(CoordinatorEntity, SensorEntity): +class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEntity): """Defines a Fronius coordinator entity.""" - coordinator: FroniusCoordinatorBase entity_descriptions: list[SensorEntityDescription] _entity_id_prefix: str diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index ff589d34791ce..c14a99051e4a6 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -69,10 +69,9 @@ async def async_setup_entry( async_add_entities(sensors) -class GiosSensor(CoordinatorEntity, SensorEntity): +class GiosSensor(CoordinatorEntity[GiosDataUpdateCoordinator], SensorEntity): """Define an GIOS sensor.""" - coordinator: GiosDataUpdateCoordinator entity_description: GiosSensorEntityDescription def __init__( diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index a09e440e2ce63..8dff4b04b0100 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -172,12 +172,11 @@ async def async_setup_entry( ) -class GitHubSensorEntity(CoordinatorEntity[dict[str, Any]], SensorEntity): +class GitHubSensorEntity(CoordinatorEntity[GitHubDataUpdateCoordinator], SensorEntity): """Defines a GitHub sensor entity.""" _attr_attribution = "Data provided by the GitHub API" - coordinator: GitHubDataUpdateCoordinator entity_description: GitHubSensorEntityDescription def __init__( diff --git a/homeassistant/components/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index 5d0392e6db207..bfbadf86ee20b 100644 --- a/homeassistant/components/gogogate2/common.py +++ b/homeassistant/components/gogogate2/common.py @@ -66,7 +66,7 @@ def __init__( self.api = api -class GoGoGate2Entity(CoordinatorEntity): +class GoGoGate2Entity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """Base class for gogogate2 entities.""" def __init__( diff --git a/homeassistant/components/gree/entity.py b/homeassistant/components/gree/entity.py index 7407a90b4d009..66be66f9dc9f5 100644 --- a/homeassistant/components/gree/entity.py +++ b/homeassistant/components/gree/entity.py @@ -7,7 +7,7 @@ from .const import DOMAIN -class GreeEntity(CoordinatorEntity): +class GreeEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """Generic Gree entity (base class).""" def __init__(self, coordinator: DeviceDataUpdateCoordinator, desc: str) -> None: diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index 5dd41166c32ff..9f727269fed22 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -11,7 +11,7 @@ from .const import ATTR_SLUG, DATA_KEY_ADDONS, DATA_KEY_OS -class HassioAddonEntity(CoordinatorEntity): +class HassioAddonEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): """Base entity for a Hass.io add-on.""" def __init__( @@ -38,7 +38,7 @@ def available(self) -> bool: ) -class HassioOSEntity(CoordinatorEntity): +class HassioOSEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): """Base Entity for Hass.io OS.""" def __init__( diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 5d870ffa8ee31..2bf285d25e6ae 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -171,6 +171,7 @@ async def ws_get_list_statistic_ids( statistic_ids = await get_instance(hass).async_add_executor_job( list_statistic_ids, hass, + None, msg.get("statistic_type"), ) connection.send_result(msg["id"], statistic_ids) @@ -224,6 +225,7 @@ async def get( ) minimal_response = "minimal_response" in request.query + no_attributes = "no_attributes" in request.query hass = request.app["hass"] @@ -245,6 +247,7 @@ async def get( include_start_time_state, significant_changes_only, minimal_response, + no_attributes, ), ) @@ -257,6 +260,7 @@ def _sorted_significant_states_json( include_start_time_state, significant_changes_only, minimal_response, + no_attributes, ): """Fetch significant stats from the database as json.""" timer_start = time.perf_counter() @@ -272,6 +276,7 @@ def _sorted_significant_states_json( include_start_time_state, significant_changes_only, minimal_response, + no_attributes, ) result = list(result.values()) @@ -359,7 +364,14 @@ def entity_filter(self): """Generate the entity filter query.""" includes = [] if self.included_domains: - includes.append(history_models.States.domain.in_(self.included_domains)) + includes.append( + or_( + *[ + history_models.States.entity_id.like(f"{domain}.%") + for domain in self.included_domains + ] + ).self_group() + ) if self.included_entities: includes.append(history_models.States.entity_id.in_(self.included_entities)) for glob in self.included_entity_globs: @@ -367,7 +379,14 @@ def entity_filter(self): excludes = [] if self.excluded_domains: - excludes.append(history_models.States.domain.in_(self.excluded_domains)) + excludes.append( + or_( + *[ + history_models.States.entity_id.like(f"{domain}.%") + for domain in self.excluded_domains + ] + ).self_group() + ) if self.excluded_entities: excludes.append(history_models.States.entity_id.in_(self.excluded_entities)) for glob in self.excluded_entity_globs: diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index e9a09f9db8609..8863d819db210 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -142,7 +142,7 @@ async def async_setup_entry( async_add_entities(entities) -class HWEnergySensor(CoordinatorEntity[DeviceResponseEntry], SensorEntity): +class HWEnergySensor(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator], SensorEntity): """Representation of a HomeWizard Sensor.""" def __init__( diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index 7860370baa7a2..3c6b1a1c5dc89 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -31,11 +31,11 @@ async def async_setup_entry( ) -class HWEnergySwitchEntity(CoordinatorEntity, SwitchEntity): +class HWEnergySwitchEntity( + CoordinatorEntity[HWEnergyDeviceUpdateCoordinator], SwitchEntity +): """Representation switchable entity.""" - coordinator: HWEnergyDeviceUpdateCoordinator - def __init__( self, coordinator: HWEnergyDeviceUpdateCoordinator, diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 346cc67d23518..dd6182b244ae2 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -6,6 +6,7 @@ import logging from typing import Any +import aiohttp from aiohttp import client_exceptions from aiohue import HueBridgeV1, HueBridgeV2, LinkButtonNotPressed, Unauthorized from aiohue.errors import AiohueException, BridgeBusy @@ -14,7 +15,7 @@ from homeassistant import core from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST, Platform -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import aiohttp_client from .const import CONF_API_VERSION, DOMAIN @@ -131,7 +132,11 @@ async def async_request_call( # log only self.logger.debug("Ignored error/warning from Hue API: %s", str(err)) return None - raise err + raise HomeAssistantError(f"Request failed: {err}") from err + except aiohttp.ClientError as err: + raise HomeAssistantError( + f"Request failed due connection error: {err}" + ) from err async def async_reset(self) -> bool: """Reset this bridge to default state. diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 0901d9a1e2c28..265777814a879 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -6,6 +6,7 @@ from typing import Any from urllib.parse import urlparse +import aiohttp from aiohue import LinkButtonNotPressed, create_app_key from aiohue.discovery import DiscoveredHueBridge, discover_bridge, discover_nupnp from aiohue.util import normalize_bridge_id @@ -70,9 +71,12 @@ async def _get_bridge( self, host: str, bridge_id: str | None = None ) -> DiscoveredHueBridge: """Return a DiscoveredHueBridge object.""" - bridge = await discover_bridge( - host, websession=aiohttp_client.async_get_clientsession(self.hass) - ) + try: + bridge = await discover_bridge( + host, websession=aiohttp_client.async_get_clientsession(self.hass) + ) + except aiohttp.ClientError: + return None if bridge_id is not None: bridge_id = normalize_bridge_id(bridge_id) assert bridge_id == bridge.id diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 31c5a50285302..948609f4c1375 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -42,6 +42,14 @@ 'device (groupedLight) is "soft off", command (on) may not have effect', "device (light) has communication issues, command (on) may not have effect", 'device (light) is "soft off", command (on) may not have effect', + "device (grouped_light) has communication issues, command (.on) may not have effect", + 'device (grouped_light) is "soft off", command (.on) may not have effect' + "device (grouped_light) has communication issues, command (.on.on) may not have effect", + 'device (grouped_light) is "soft off", command (.on.on) may not have effect' + "device (light) has communication issues, command (.on) may not have effect", + 'device (light) is "soft off", command (.on) may not have effect', + "device (light) has communication issues, command (.on.on) may not have effect", + 'device (light) is "soft off", command (.on.on) may not have effect', ] diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index ee40222b0836d..5b4574c717c81 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -39,6 +39,10 @@ ALLOWED_ERRORS = [ "device (light) has communication issues, command (on) may not have effect", 'device (light) is "soft off", command (on) may not have effect', + "device (light) has communication issues, command (.on) may not have effect", + 'device (light) is "soft off", command (.on) may not have effect', + "device (light) has communication issues, command (.on.on) may not have effect", + 'device (light) is "soft off", command (.on.on) may not have effect', ] diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 837886bbf56eb..c0bac1b274549 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -18,6 +18,7 @@ ATTR_UNIT_OF_MEASUREMENT, CONF_METHOD, CONF_NAME, + CONF_UNIQUE_ID, STATE_UNAVAILABLE, STATE_UNKNOWN, TIME_DAYS, @@ -70,6 +71,7 @@ PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int), vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES), @@ -125,7 +127,7 @@ async def async_setup_platform( name=config.get(CONF_NAME), round_digits=config[CONF_ROUND_DIGITS], source_entity=config[CONF_SOURCE_SENSOR], - unique_id=None, + unique_id=config.get(CONF_UNIQUE_ID), unit_of_measurement=config.get(CONF_UNIT_OF_MEASUREMENT), unit_prefix=config[CONF_UNIT_PREFIX], unit_time=config[CONF_UNIT_TIME], diff --git a/homeassistant/components/intellifire/config_flow.py b/homeassistant/components/intellifire/config_flow.py index 869504d22a2fe..a8d9fa135d222 100644 --- a/homeassistant/components/intellifire/config_flow.py +++ b/homeassistant/components/intellifire/config_flow.py @@ -1,6 +1,7 @@ """Config flow for IntelliFire integration.""" from __future__ import annotations +from dataclasses import dataclass from typing import Any from aiohttp import ClientConnectionError @@ -8,6 +9,7 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.const import CONF_HOST from homeassistant.data_entry_flow import FlowResult @@ -18,6 +20,14 @@ MANUAL_ENTRY_STRING = "IP Address" # Simplified so it does not have to be translated +@dataclass +class DiscoveredHostInfo: + """Host info for discovery.""" + + ip: str + serial: str | None + + async def validate_host_input(host: str) -> str: """Validate the user input allows us to connect. @@ -39,7 +49,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize the Config Flow Handler.""" self._config_context = {} - self._not_configured_hosts: list[str] = [] + self._not_configured_hosts: list[DiscoveredHostInfo] = [] + self._discovered_host: DiscoveredHostInfo async def _find_fireplaces(self): """Perform UDP discovery.""" @@ -52,7 +63,9 @@ async def _find_fireplaces(self): } self._not_configured_hosts = [ - ip for ip in discovered_hosts if ip not in configured_hosts + DiscoveredHostInfo(ip, None) + for ip in discovered_hosts + if ip not in configured_hosts ] LOGGER.debug("Discovered Hosts: %s", discovered_hosts) LOGGER.debug("Configured Hosts: %s", configured_hosts) @@ -62,7 +75,7 @@ async def _async_validate_and_create_entry(self, host: str) -> FlowResult: """Validate and create the entry.""" self._async_abort_entries_match({CONF_HOST: host}) serial = await validate_host_input(host) - await self.async_set_unique_id(serial) + await self.async_set_unique_id(serial, raise_on_progress=False) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) return self.async_create_entry( title=f"Fireplace {serial}", @@ -108,7 +121,8 @@ async def async_step_pick_device( data_schema=vol.Schema( { vol.Required(CONF_HOST): vol.In( - self._not_configured_hosts + [MANUAL_ENTRY_STRING] + [host.ip for host in self._not_configured_hosts] + + [MANUAL_ENTRY_STRING] ) } ), @@ -127,3 +141,44 @@ async def async_step_user( return await self.async_step_pick_device() LOGGER.debug("Running Step: manual_device_entry") return await self.async_step_manual_device_entry() + + async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: + """Handle DHCP Discovery.""" + + # Run validation logic on ip + host = discovery_info.ip + + self._async_abort_entries_match({CONF_HOST: host}) + try: + serial = await validate_host_input(host) + except (ConnectionError, ClientConnectionError): + return self.async_abort(reason="not_intellifire_device") + + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + self._discovered_host = DiscoveredHostInfo(ip=host, serial=serial) + + placeholders = {CONF_HOST: host, "serial": serial} + self.context["title_placeholders"] = placeholders + self._set_confirm_only() + + return await self.async_step_dhcp_confirm() + + async def async_step_dhcp_confirm(self, user_input=None): + """Attempt to confirm.""" + + # Add the hosts one by one + host = self._discovered_host.ip + serial = self._discovered_host.serial + + if user_input is None: + # Show the confirmation dialog + return self.async_show_form( + step_id="dhcp_confirm", + description_placeholders={CONF_HOST: host, "serial": serial}, + ) + + return self.async_create_entry( + title=f"Fireplace {serial}", + data={CONF_HOST: host}, + ) diff --git a/homeassistant/components/intellifire/entity.py b/homeassistant/components/intellifire/entity.py index eeb5e7b51bd9a..6d20c015ab987 100644 --- a/homeassistant/components/intellifire/entity.py +++ b/homeassistant/components/intellifire/entity.py @@ -7,10 +7,9 @@ from . import IntellifireDataUpdateCoordinator -class IntellifireEntity(CoordinatorEntity): +class IntellifireEntity(CoordinatorEntity[IntellifireDataUpdateCoordinator]): """Define a generic class for Intellifire entities.""" - coordinator: IntellifireDataUpdateCoordinator _attr_attribution = "Data provided by unpublished Intellifire API" def __init__( diff --git a/homeassistant/components/intellifire/manifest.json b/homeassistant/components/intellifire/manifest.json index 75d4ee2e75ff4..5809748787c55 100644 --- a/homeassistant/components/intellifire/manifest.json +++ b/homeassistant/components/intellifire/manifest.json @@ -6,5 +6,7 @@ "requirements": ["intellifire4py==1.0.1"], "codeowners": ["@jeeftor"], "iot_class": "local_polling", - "loggers": ["intellifire4py"] + "loggers": ["intellifire4py"], + "dhcp": [{"hostname": "zentrios-*"}] + } diff --git a/homeassistant/components/intellifire/strings.json b/homeassistant/components/intellifire/strings.json index d5d3f344c8e6a..a85b807c4c00c 100644 --- a/homeassistant/components/intellifire/strings.json +++ b/homeassistant/components/intellifire/strings.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{serial} ({host})", "step": { "manual_device_entry": { "description": "Local Configuration", @@ -7,6 +8,9 @@ "host": "[%key:common::config_flow::data::host%]" } }, + "dhcp_confirm": { + "description": "Do you want to setup {host}\nSerial: {serial}?" + }, "pick_device": { "data": { "host": "[%key:common::config_flow::data::host%]" @@ -17,7 +21,8 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "not_intellifire_device":"Not an IntelliFire Device." } } } diff --git a/homeassistant/components/intellifire/translations/en.json b/homeassistant/components/intellifire/translations/en.json index 0f7538e74131e..844d77427ca75 100644 --- a/homeassistant/components/intellifire/translations/en.json +++ b/homeassistant/components/intellifire/translations/en.json @@ -1,27 +1,28 @@ { "config": { - "abort": { - "already_configured": "Device is already configured" - }, - "error": { - "cannot_connect": "Could not connect to a fireplace endpoint at url: http://{host}/poll\nVerify IP address and try again" - }, - "step": { - "manual_device_entry": { - "title": "IntelliFire - Local Config", - "description": "Enter the IP address of the IntelliFire unit on your local network.", - "data": { - "host": "Host (IP Address)" - } + "abort": { + "already_configured": "Device is already configured", + "not_intellifire_device": "Not an IntelliFire Device." }, - "user": { - "description": "Username and password are the same information used in your IntelliFire Android/iOS application.", - "title": "IntelliFire Config" + "error": { + "cannot_connect": "Failed to connect" }, - "pick_device": { - "title": "Device Selection", - "description": "The following IntelliFire devices were discovered. Please select which you wish to configure." + "flow_title": "{serial} ({host})", + "step": { + "dhcp_confirm": { + "description": "Do you want to setup {host}\nSerial: {serial}?" + }, + "manual_device_entry": { + "data": { + "host": "Host" + }, + "description": "Local Configuration" + }, + "pick_device": { + "data": { + "host": "Host" + } + } } - } } - } \ No newline at end of file +} \ No newline at end of file diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index c3c173f778ec1..8bcf5bfae9aca 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -24,11 +24,12 @@ POWER_WATT, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity, entity_registry, update_coordinator +from homeassistant.helpers import entity, entity_registry from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt from .const import ( @@ -159,11 +160,10 @@ def new_data_received(): coordinator.async_add_listener(new_data_received) -class IotaWattSensor(update_coordinator.CoordinatorEntity, SensorEntity): +class IotaWattSensor(CoordinatorEntity[IotawattUpdater], SensorEntity): """Defines a IoTaWatt Energy Sensor.""" entity_description: IotaWattSensorEntityDescription - coordinator: IotawattUpdater def __init__( self, diff --git a/homeassistant/components/ipp/entity.py b/homeassistant/components/ipp/entity.py index 7bd01b4cd1279..b2f3a4a1469f6 100644 --- a/homeassistant/components/ipp/entity.py +++ b/homeassistant/components/ipp/entity.py @@ -8,7 +8,7 @@ from .coordinator import IPPDataUpdateCoordinator -class IPPEntity(CoordinatorEntity): +class IPPEntity(CoordinatorEntity[IPPDataUpdateCoordinator]): """Defines a base IPP entity.""" def __init__( diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index d82f85e9ba9d9..f98a48d8dc364 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -11,7 +11,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from . import KrakenData from .const import ( @@ -86,7 +89,9 @@ def async_update_sensors(hass: HomeAssistant, config_entry: ConfigEntry) -> None ) -class KrakenSensor(CoordinatorEntity[Optional[KrakenResponse]], SensorEntity): +class KrakenSensor( + CoordinatorEntity[DataUpdateCoordinator[Optional[KrakenResponse]]], SensorEntity +): """Define a Kraken sensor.""" entity_description: KrakenSensorEntityDescription diff --git a/homeassistant/components/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py index d468c3a653f06..fac62c9bb8758 100644 --- a/homeassistant/components/launch_library/sensor.py +++ b/homeassistant/components/launch_library/sensor.py @@ -179,13 +179,14 @@ async def async_setup_entry( ) -class LaunchLibrarySensor(CoordinatorEntity, SensorEntity): +class LaunchLibrarySensor( + CoordinatorEntity[DataUpdateCoordinator[LaunchLibraryData]], SensorEntity +): """Representation of the next launch sensors.""" _attr_attribution = "Data provided by Launch Library." _next_event: Launch | Event | None = None entity_description: LaunchLibrarySensorEntityDescription - coordinator: DataUpdateCoordinator[LaunchLibraryData] def __init__( self, diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 8860daaec3c54..8b00311a9e743 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -1,6 +1,9 @@ """Event parser and human readable log generator.""" +from __future__ import annotations + +from collections.abc import Iterable from contextlib import suppress -from datetime import timedelta +from datetime import datetime as dt, timedelta from http import HTTPStatus from itertools import groupby import json @@ -9,6 +12,8 @@ import sqlalchemy from sqlalchemy.orm import aliased +from sqlalchemy.orm.query import Query +from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import literal import voluptuous as vol @@ -59,13 +64,14 @@ from homeassistant.loader import bind_hass import homeassistant.util.dt as dt_util -ENTITY_ID_JSON_TEMPLATE = '"entity_id":"{}"' +ENTITY_ID_JSON_TEMPLATE = '%"entity_id":"{}"%' ENTITY_ID_JSON_EXTRACT = re.compile('"entity_id": ?"([^"]+)"') DOMAIN_JSON_EXTRACT = re.compile('"domain": ?"([^"]+)"') ICON_JSON_EXTRACT = re.compile('"icon": ?"([^"]+)"') ATTR_MESSAGE = "message" -CONTINUOUS_DOMAINS = ["proximity", "sensor"] +CONTINUOUS_DOMAINS = {"proximity", "sensor"} +CONTINUOUS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in CONTINUOUS_DOMAINS] DOMAIN = "logbook" @@ -73,7 +79,7 @@ EMPTY_JSON_OBJECT = "{}" UNIT_OF_MEASUREMENT_JSON = '"unit_of_measurement":' - +UNIT_OF_MEASUREMENT_JSON_LIKE = f"%{UNIT_OF_MEASUREMENT_JSON}%" HA_DOMAIN_ENTITY_ID = f"{HA_DOMAIN}._" CONFIG_SCHEMA = vol.Schema( @@ -489,35 +495,39 @@ def yield_events(query): ) -def _generate_events_query(session): +def _generate_events_query(session: Session) -> Query: return session.query( *EVENT_COLUMNS, States.state, States.entity_id, - States.domain, States.attributes, StateAttributes.shared_attrs, ) -def _generate_events_query_without_states(session): +def _generate_events_query_without_states(session: Session) -> Query: return session.query( *EVENT_COLUMNS, literal(value=None, type_=sqlalchemy.String).label("state"), literal(value=None, type_=sqlalchemy.String).label("entity_id"), - literal(value=None, type_=sqlalchemy.String).label("domain"), literal(value=None, type_=sqlalchemy.Text).label("attributes"), literal(value=None, type_=sqlalchemy.Text).label("shared_attrs"), ) -def _generate_states_query(session, start_day, end_day, old_state, entity_ids): +def _generate_states_query( + session: Session, + start_day: dt, + end_day: dt, + old_state: States, + entity_ids: Iterable[str], +) -> Query: return ( _generate_events_query(session) .outerjoin(Events, (States.event_id == Events.event_id)) .outerjoin(old_state, (States.old_state_id == old_state.state_id)) .filter(_missing_state_matcher(old_state)) - .filter(_continuous_entity_matcher()) + .filter(_not_continuous_entity_matcher()) .filter((States.last_updated > start_day) & (States.last_updated < end_day)) .filter( (States.last_updated == States.last_changed) @@ -529,7 +539,9 @@ def _generate_states_query(session, start_day, end_day, old_state, entity_ids): ) -def _apply_events_types_and_states_filter(hass, query, old_state): +def _apply_events_types_and_states_filter( + hass: HomeAssistant, query: Query, old_state: States +) -> Query: events_query = ( query.outerjoin(States, (Events.event_id == States.event_id)) .outerjoin(old_state, (States.old_state_id == old_state.state_id)) @@ -538,7 +550,8 @@ def _apply_events_types_and_states_filter(hass, query, old_state): | _missing_state_matcher(old_state) ) .filter( - (Events.event_type != EVENT_STATE_CHANGED) | _continuous_entity_matcher() + (Events.event_type != EVENT_STATE_CHANGED) + | _not_continuous_entity_matcher() ) ) return _apply_event_types_filter(hass, events_query, ALL_EVENT_TYPES).outerjoin( @@ -546,7 +559,7 @@ def _apply_events_types_and_states_filter(hass, query, old_state): ) -def _missing_state_matcher(old_state): +def _missing_state_matcher(old_state: States) -> Any: # The below removes state change events that do not have # and old_state or the old_state is missing (newly added entities) # or the new_state is missing (removed entities) @@ -557,37 +570,64 @@ def _missing_state_matcher(old_state): ) -def _continuous_entity_matcher(): - # - # Prefilter out continuous domains that have - # ATTR_UNIT_OF_MEASUREMENT as its much faster in sql. - # +def _not_continuous_entity_matcher() -> Any: + """Match non continuous entities.""" return sqlalchemy.or_( - sqlalchemy.not_(States.domain.in_(CONTINUOUS_DOMAINS)), - sqlalchemy.not_(States.attributes.contains(UNIT_OF_MEASUREMENT_JSON)), - sqlalchemy.not_( - StateAttributes.shared_attrs.contains(UNIT_OF_MEASUREMENT_JSON) - ), + _not_continuous_domain_matcher(), + sqlalchemy.and_( + _continuous_domain_matcher, _not_uom_attributes_matcher() + ).self_group(), ) -def _apply_event_time_filter(events_query, start_day, end_day): +def _not_continuous_domain_matcher() -> Any: + """Match not continuous domains.""" + return sqlalchemy.and_( + *[ + ~States.entity_id.like(entity_domain) + for entity_domain in CONTINUOUS_ENTITY_ID_LIKE + ], + ).self_group() + + +def _continuous_domain_matcher() -> Any: + """Match continuous domains.""" + return sqlalchemy.or_( + *[ + States.entity_id.like(entity_domain) + for entity_domain in CONTINUOUS_ENTITY_ID_LIKE + ], + ).self_group() + + +def _not_uom_attributes_matcher() -> Any: + """Prefilter ATTR_UNIT_OF_MEASUREMENT as its much faster in sql.""" + return ~StateAttributes.shared_attrs.like( + UNIT_OF_MEASUREMENT_JSON_LIKE + ) | ~States.attributes.like(UNIT_OF_MEASUREMENT_JSON_LIKE) + + +def _apply_event_time_filter(events_query: Query, start_day: dt, end_day: dt) -> Query: return events_query.filter( (Events.time_fired > start_day) & (Events.time_fired < end_day) ) -def _apply_event_types_filter(hass, query, event_types): +def _apply_event_types_filter( + hass: HomeAssistant, query: Query, event_types: list[str] +) -> Query: return query.filter( Events.event_type.in_(event_types + list(hass.data.get(DOMAIN, {}))) ) -def _apply_event_entity_id_matchers(events_query, entity_ids): +def _apply_event_entity_id_matchers( + events_query: Query, entity_ids: Iterable[str] +) -> Query: return events_query.filter( sqlalchemy.or_( *( - Events.event_data.contains(ENTITY_ID_JSON_TEMPLATE.format(entity_id)) + Events.event_data.like(ENTITY_ID_JSON_TEMPLATE.format(entity_id)) for entity_id in entity_ids ) ) @@ -694,7 +734,7 @@ class LazyEventPartialState: "event_type", "entity_id", "state", - "domain", + "_domain", "context_id", "context_user_id", "context_parent_id", @@ -707,15 +747,22 @@ def __init__(self, row): self._event_data = None self._time_fired_isoformat = None self._attributes = None + self._domain = None self.event_type = self._row.event_type self.entity_id = self._row.entity_id self.state = self._row.state - self.domain = self._row.domain self.context_id = self._row.context_id self.context_user_id = self._row.context_user_id self.context_parent_id = self._row.context_parent_id self.time_fired_minute = self._row.time_fired.minute + @property + def domain(self): + """Return the domain for the state.""" + if self._domain is None: + self._domain = split_entity_id(self.entity_id)[0] + return self._domain + @property def attributes_icon(self): """Extract the icon from the decoded attributes or json.""" diff --git a/homeassistant/components/lookin/entity.py b/homeassistant/components/lookin/entity.py index 6ff167d86fe4c..1c641b76f32d2 100644 --- a/homeassistant/components/lookin/entity.py +++ b/homeassistant/components/lookin/entity.py @@ -52,11 +52,11 @@ def _set_lookin_device_attrs(self, lookin_data: LookinData) -> None: self._lookin_udp_subs = lookin_data.lookin_udp_subs -class LookinDeviceCoordinatorEntity(LookinDeviceMixIn, CoordinatorEntity): +class LookinDeviceCoordinatorEntity( + LookinDeviceMixIn, CoordinatorEntity[LookinDataUpdateCoordinator] +): """A lookin device entity on the device itself that uses the coordinator.""" - coordinator: LookinDataUpdateCoordinator - _attr_should_poll = False def __init__(self, lookin_data: LookinData) -> None: @@ -84,11 +84,11 @@ def _set_lookin_entity_attrs( self._function_names = {function.name for function in self._device.functions} -class LookinCoordinatorEntity(LookinDeviceMixIn, LookinEntityMixIn, CoordinatorEntity): +class LookinCoordinatorEntity( + LookinDeviceMixIn, LookinEntityMixIn, CoordinatorEntity[LookinDataUpdateCoordinator] +): """A lookin device entity for an external device that uses the coordinator.""" - coordinator: LookinDataUpdateCoordinator - _attr_should_poll = False _attr_assumed_state = True diff --git a/homeassistant/components/lovelace/cast.py b/homeassistant/components/lovelace/cast.py index 02280ebd18254..bd06d142bd386 100644 --- a/homeassistant/components/lovelace/cast.py +++ b/homeassistant/components/lovelace/cast.py @@ -33,7 +33,7 @@ async def async_get_media_browser_root_object( return [] return [ BrowseMedia( - title="Lovelace", + title="Dashboards", media_class=MEDIA_CLASS_APP, media_content_id="", media_content_type=DOMAIN, diff --git a/homeassistant/components/lovelace/manifest.json b/homeassistant/components/lovelace/manifest.json index cc8f6ddab0859..6f91a61b08ccb 100644 --- a/homeassistant/components/lovelace/manifest.json +++ b/homeassistant/components/lovelace/manifest.json @@ -1,6 +1,6 @@ { "domain": "lovelace", - "name": "Lovelace", + "name": "Dashboards", "documentation": "https://www.home-assistant.io/integrations/lovelace", "codeowners": ["@home-assistant/frontend"] } diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py index d85dcc0a3e385..e4cad5c32016d 100644 --- a/homeassistant/components/media_player/browse_media.py +++ b/homeassistant/components/media_player/browse_media.py @@ -10,7 +10,9 @@ from homeassistant.components.http.auth import async_sign_path from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.network import ( + NoURLAvailableError, get_supervisor_network_url, get_url, is_hass_url, @@ -28,11 +30,15 @@ def async_process_play_media_url( for_supervisor_network: bool = False, ) -> str: """Update a media URL with authentication if it points at Home Assistant.""" - if media_content_id[0] != "/" and not is_hass_url(hass, media_content_id): - return media_content_id - parsed = yarl.URL(media_content_id) + if parsed.is_absolute(): + if not is_hass_url(hass, media_content_id): + return media_content_id + else: + if media_content_id[0] != "/": + raise ValueError("URL is relative, but does not start with a /") + if parsed.query: logging.getLogger(__name__).debug( "Not signing path for content with query param" @@ -46,13 +52,23 @@ def async_process_play_media_url( media_content_id = str(parsed.join(yarl.URL(signed_path))) # convert relative URL to absolute URL - if media_content_id[0] == "/" and not allow_relative_url: + if not parsed.is_absolute() and not allow_relative_url: base_url = None if for_supervisor_network: base_url = get_supervisor_network_url(hass) if not base_url: - base_url = get_url(hass) + try: + base_url = get_url(hass) + except NoURLAvailableError as err: + msg = "Unable to determine Home Assistant URL to send to device" + if ( + hass.config.api + and hass.config.api.use_ssl + and (not hass.config.external_url or not hass.config.internal_url) + ): + msg += ". Configure internal and external URL in general settings." + raise HomeAssistantError(msg) from err media_content_id = f"{base_url}{media_content_id}" diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 5791620d5ac0c..d17bfda03f1c5 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -45,7 +45,7 @@ from homeassistant.util.pressure import convert as convert_pressure from homeassistant.util.speed import convert as convert_speed -from . import MetDataUpdateCoordinator, MetWeatherData +from . import MetDataUpdateCoordinator from .const import ( ATTR_FORECAST_PRECIPITATION, ATTR_MAP, @@ -127,11 +127,9 @@ def format_condition(condition: str) -> str: return condition -class MetWeather(CoordinatorEntity[MetWeatherData], WeatherEntity): +class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): """Implementation of a Met.no weather condition.""" - coordinator: MetDataUpdateCoordinator - def __init__( self, coordinator: MetDataUpdateCoordinator, diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index dfd6a9a880749..af4f05a1536b7 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -126,8 +126,6 @@ async def _async_update_data(self) -> ModernFormsDevice: class ModernFormsDeviceEntity(CoordinatorEntity[ModernFormsDataUpdateCoordinator]): """Defines a Modern Forms device entity.""" - coordinator: ModernFormsDataUpdateCoordinator - def __init__( self, *, diff --git a/homeassistant/components/moehlenhoff_alpha2/climate.py b/homeassistant/components/moehlenhoff_alpha2/climate.py index d99eb0e4c8c6a..783e2df5967ee 100644 --- a/homeassistant/components/moehlenhoff_alpha2/climate.py +++ b/homeassistant/components/moehlenhoff_alpha2/climate.py @@ -38,10 +38,9 @@ async def async_setup_entry( # https://developers.home-assistant.io/docs/core/entity/climate/ -class Alpha2Climate(CoordinatorEntity, ClimateEntity): +class Alpha2Climate(CoordinatorEntity[Alpha2BaseCoordinator], ClimateEntity): """Alpha2 ClimateEntity.""" - coordinator: Alpha2BaseCoordinator target_temperature_step = 0.2 _attr_supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE diff --git a/homeassistant/components/nam/button.py b/homeassistant/components/nam/button.py index f73307d09ce24..db5474ec925f7 100644 --- a/homeassistant/components/nam/button.py +++ b/homeassistant/components/nam/button.py @@ -41,11 +41,9 @@ async def async_setup_entry( async_add_entities(buttons, False) -class NAMButton(CoordinatorEntity, ButtonEntity): +class NAMButton(CoordinatorEntity[NAMDataUpdateCoordinator], ButtonEntity): """Define an Nettigo Air Monitor button.""" - coordinator: NAMDataUpdateCoordinator - def __init__( self, coordinator: NAMDataUpdateCoordinator, diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 88f6008b45f84..af729cf906678 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -58,11 +58,9 @@ async def async_setup_entry( async_add_entities(sensors, False) -class NAMSensor(CoordinatorEntity, SensorEntity): +class NAMSensor(CoordinatorEntity[NAMDataUpdateCoordinator], SensorEntity): """Define an Nettigo Air Monitor sensor.""" - coordinator: NAMDataUpdateCoordinator - def __init__( self, coordinator: NAMDataUpdateCoordinator, diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index eca6668d0f5a5..a4277b2908d1c 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -46,7 +46,7 @@ async def async_setup_entry( async_add_entities(entities) -class NINAMessage(CoordinatorEntity, BinarySensorEntity): +class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEntity): """Representation of an NINA warning.""" def __init__( diff --git a/homeassistant/components/nsw_fuel_station/sensor.py b/homeassistant/components/nsw_fuel_station/sensor.py index 2e022a7ec1326..82a5e3a59a8bc 100644 --- a/homeassistant/components/nsw_fuel_station/sensor.py +++ b/homeassistant/components/nsw_fuel_station/sensor.py @@ -83,7 +83,9 @@ def setup_platform( add_entities(entities) -class StationPriceSensor(CoordinatorEntity, SensorEntity): +class StationPriceSensor( + CoordinatorEntity[DataUpdateCoordinator[StationPriceData]], SensorEntity +): """Implementation of a sensor that reports the fuel price for a station.""" def __init__( diff --git a/homeassistant/components/nzbget/__init__.py b/homeassistant/components/nzbget/__init__.py index d9a41c905358c..cb906495d5822 100644 --- a/homeassistant/components/nzbget/__init__.py +++ b/homeassistant/components/nzbget/__init__.py @@ -154,7 +154,7 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non await hass.config_entries.async_reload(entry.entry_id) -class NZBGetEntity(CoordinatorEntity): +class NZBGetEntity(CoordinatorEntity[NZBGetDataUpdateCoordinator]): """Defines a base NZBGet entity.""" def __init__( diff --git a/homeassistant/components/octoprint/binary_sensor.py b/homeassistant/components/octoprint/binary_sensor.py index e1db7a9513638..b0e43bd74e0fe 100644 --- a/homeassistant/components/octoprint/binary_sensor.py +++ b/homeassistant/components/octoprint/binary_sensor.py @@ -36,11 +36,11 @@ async def async_setup_entry( async_add_entities(entities) -class OctoPrintBinarySensorBase(CoordinatorEntity, BinarySensorEntity): +class OctoPrintBinarySensorBase( + CoordinatorEntity[OctoprintDataUpdateCoordinator], BinarySensorEntity +): """Representation an OctoPrint binary sensor.""" - coordinator: OctoprintDataUpdateCoordinator - def __init__( self, coordinator: OctoprintDataUpdateCoordinator, diff --git a/homeassistant/components/octoprint/button.py b/homeassistant/components/octoprint/button.py index 97676592f4753..e16f123a73a32 100644 --- a/homeassistant/components/octoprint/button.py +++ b/homeassistant/components/octoprint/button.py @@ -34,10 +34,9 @@ async def async_setup_entry( ) -class OctoprintButton(CoordinatorEntity, ButtonEntity): +class OctoprintButton(CoordinatorEntity[OctoprintDataUpdateCoordinator], ButtonEntity): """Represent an OctoPrint binary sensor.""" - coordinator: OctoprintDataUpdateCoordinator client: OctoprintClient def __init__( diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index 5a094c10987c5..4efc094c29747 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -89,11 +89,11 @@ def async_add_tool_sensors() -> None: async_add_entities(entities) -class OctoPrintSensorBase(CoordinatorEntity, SensorEntity): +class OctoPrintSensorBase( + CoordinatorEntity[OctoprintDataUpdateCoordinator], SensorEntity +): """Representation of an OctoPrint sensor.""" - coordinator: OctoprintDataUpdateCoordinator - def __init__( self, coordinator: OctoprintDataUpdateCoordinator, diff --git a/homeassistant/components/omnilogic/common.py b/homeassistant/components/omnilogic/common.py index 78685036e06da..4c92420972b6e 100644 --- a/homeassistant/components/omnilogic/common.py +++ b/homeassistant/components/omnilogic/common.py @@ -73,7 +73,7 @@ def get_item_data(item, item_kind, current_id, data): return parsed_data -class OmniLogicEntity(CoordinatorEntity): +class OmniLogicEntity(CoordinatorEntity[OmniLogicUpdateCoordinator]): """Defines the base OmniLogic entity.""" def __init__( diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py index bb7170bb5da1c..40b52248a5210 100644 --- a/homeassistant/components/open_meteo/weather.py +++ b/homeassistant/components/open_meteo/weather.py @@ -28,14 +28,18 @@ async def async_setup_entry( async_add_entities([OpenMeteoWeatherEntity(entry=entry, coordinator=coordinator)]) -class OpenMeteoWeatherEntity(CoordinatorEntity, WeatherEntity): +class OpenMeteoWeatherEntity( + CoordinatorEntity[DataUpdateCoordinator[OpenMeteoForecast]], WeatherEntity +): """Defines an Open-Meteo weather entity.""" _attr_temperature_unit = TEMP_CELSIUS - coordinator: DataUpdateCoordinator[OpenMeteoForecast] def __init__( - self, *, entry: ConfigEntry, coordinator: DataUpdateCoordinator + self, + *, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator[OpenMeteoForecast], ) -> None: """Initialize Open-Meteo weather entity.""" super().__init__(coordinator=coordinator) diff --git a/homeassistant/components/overkiz/entity.py b/homeassistant/components/overkiz/entity.py index 72ef793c2b4cc..7c42f415a65dd 100644 --- a/homeassistant/components/overkiz/entity.py +++ b/homeassistant/components/overkiz/entity.py @@ -16,11 +16,9 @@ from .executor import OverkizExecutor -class OverkizEntity(CoordinatorEntity): +class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]): """Representation of an Overkiz device entity.""" - coordinator: OverkizDataUpdateCoordinator - def __init__( self, device_url: str, coordinator: OverkizDataUpdateCoordinator ) -> None: diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py index edc076382ec40..57f6b0ad99cb5 100644 --- a/homeassistant/components/p1_monitor/sensor.py +++ b/homeassistant/components/p1_monitor/sensor.py @@ -235,11 +235,11 @@ async def async_setup_entry( ) -class P1MonitorSensorEntity(CoordinatorEntity, SensorEntity): +class P1MonitorSensorEntity( + CoordinatorEntity[P1MonitorDataUpdateCoordinator], SensorEntity +): """Defines an P1 Monitor sensor.""" - coordinator: P1MonitorDataUpdateCoordinator - def __init__( self, *, diff --git a/homeassistant/components/philips_js/light.py b/homeassistant/components/philips_js/light.py index fc26fc3ed6a32..21bb71992697e 100644 --- a/homeassistant/components/philips_js/light.py +++ b/homeassistant/components/philips_js/light.py @@ -123,7 +123,9 @@ def _average_pixels(data): return 0.0, 0.0, 0.0 -class PhilipsTVLightEntity(CoordinatorEntity, LightEntity): +class PhilipsTVLightEntity( + CoordinatorEntity[PhilipsTVDataUpdateCoordinator], LightEntity +): """Representation of a Philips TV exposing the JointSpace API.""" def __init__( diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index 20fa2ced825fe..77a1d9dccd96d 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -79,10 +79,11 @@ async def async_setup_entry( ) -class PhilipsTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity): +class PhilipsTVMediaPlayer( + CoordinatorEntity[PhilipsTVDataUpdateCoordinator], MediaPlayerEntity +): """Representation of a Philips TV exposing the JointSpace API.""" - coordinator: PhilipsTVDataUpdateCoordinator _attr_device_class = MediaPlayerDeviceClass.TV def __init__( diff --git a/homeassistant/components/philips_js/remote.py b/homeassistant/components/philips_js/remote.py index 09fe16215b6ca..388519644276b 100644 --- a/homeassistant/components/philips_js/remote.py +++ b/homeassistant/components/philips_js/remote.py @@ -27,11 +27,9 @@ async def async_setup_entry( async_add_entities([PhilipsTVRemote(coordinator)]) -class PhilipsTVRemote(CoordinatorEntity, RemoteEntity): +class PhilipsTVRemote(CoordinatorEntity[PhilipsTVDataUpdateCoordinator], RemoteEntity): """Device that sends commands.""" - coordinator: PhilipsTVDataUpdateCoordinator - def __init__( self, coordinator: PhilipsTVDataUpdateCoordinator, diff --git a/homeassistant/components/philips_js/switch.py b/homeassistant/components/philips_js/switch.py index 15f72a8aafff4..a89c22f1850da 100644 --- a/homeassistant/components/philips_js/switch.py +++ b/homeassistant/components/philips_js/switch.py @@ -27,11 +27,11 @@ async def async_setup_entry( async_add_entities([PhilipsTVScreenSwitch(coordinator)]) -class PhilipsTVScreenSwitch(CoordinatorEntity, SwitchEntity): +class PhilipsTVScreenSwitch( + CoordinatorEntity[PhilipsTVDataUpdateCoordinator], SwitchEntity +): """A Philips TV screen state switch.""" - coordinator: PhilipsTVDataUpdateCoordinator - def __init__( self, coordinator: PhilipsTVDataUpdateCoordinator, diff --git a/homeassistant/components/plugwise/entity.py b/homeassistant/components/plugwise/entity.py index b172b5468b05a..b0896c3cd6dea 100644 --- a/homeassistant/components/plugwise/entity.py +++ b/homeassistant/components/plugwise/entity.py @@ -12,14 +12,12 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import PlugwiseData, PlugwiseDataUpdateCoordinator +from .coordinator import PlugwiseDataUpdateCoordinator -class PlugwiseEntity(CoordinatorEntity[PlugwiseData]): +class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): """Represent a PlugWise Entity.""" - coordinator: PlugwiseDataUpdateCoordinator - def __init__( self, coordinator: PlugwiseDataUpdateCoordinator, diff --git a/homeassistant/components/powerwall/entity.py b/homeassistant/components/powerwall/entity.py index 20871944663c2..5d55b8b8bf18d 100644 --- a/homeassistant/components/powerwall/entity.py +++ b/homeassistant/components/powerwall/entity.py @@ -1,7 +1,10 @@ """The Tesla Powerwall integration base entity.""" from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from .const import ( DOMAIN, @@ -13,7 +16,7 @@ from .models import PowerwallData, PowerwallRuntimeData -class PowerWallEntity(CoordinatorEntity[PowerwallData]): +class PowerWallEntity(CoordinatorEntity[DataUpdateCoordinator[PowerwallData]]): """Base class for powerwall entities.""" def __init__(self, powerwall_data: PowerwallRuntimeData) -> None: diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index b028298362975..9e66c61a2bb7c 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -1,28 +1,30 @@ """Support for powerwall sensors.""" from __future__ import annotations -from typing import Any +from collections.abc import Callable +from dataclasses import dataclass from tesla_powerwall import Meter, MeterType from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENERGY_KILO_WATT_HOUR, PERCENTAGE, POWER_KILO_WATT +from homeassistant.const import ( + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + FREQUENCY_HERTZ, + PERCENTAGE, + POWER_KILO_WATT, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ATTR_FREQUENCY, - ATTR_INSTANT_AVERAGE_VOLTAGE, - ATTR_INSTANT_TOTAL_CURRENT, - ATTR_IS_ACTIVE, - DOMAIN, - POWERWALL_COORDINATOR, -) +from .const import DOMAIN, POWERWALL_COORDINATOR from .entity import PowerWallEntity from .models import PowerwallData, PowerwallRuntimeData @@ -30,6 +32,79 @@ _METER_DIRECTION_IMPORT = "import" +@dataclass +class PowerwallRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Meter], float] + + +@dataclass +class PowerwallSensorEntityDescription( + SensorEntityDescription, PowerwallRequiredKeysMixin +): + """Describes Powerwall entity.""" + + +def _get_meter_power(meter: Meter) -> float: + """Get the current value in kW.""" + return meter.get_power(precision=3) + + +def _get_meter_frequency(meter: Meter) -> float: + """Get the current value in Hz.""" + return round(meter.frequency, 1) + + +def _get_meter_total_current(meter: Meter) -> float: + """Get the current value in A.""" + return meter.get_instant_total_current() + + +def _get_meter_average_voltage(meter: Meter) -> float: + """Get the current value in V.""" + return round(meter.average_voltage, 1) + + +POWERWALL_INSTANT_SENSORS = ( + PowerwallSensorEntityDescription( + key="instant_power", + name="Now", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_KILO_WATT, + value_fn=_get_meter_power, + ), + PowerwallSensorEntityDescription( + key="instant_frequency", + name="Frequency Now", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + native_unit_of_measurement=FREQUENCY_HERTZ, + entity_registry_enabled_default=False, + value_fn=_get_meter_frequency, + ), + PowerwallSensorEntityDescription( + key="instant_current", + name="Average Current Now", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + entity_registry_enabled_default=False, + value_fn=_get_meter_total_current, + ), + PowerwallSensorEntityDescription( + key="instant_voltage", + name="Average Voltage Now", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + entity_registry_enabled_default=False, + value_fn=_get_meter_average_voltage, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -40,24 +115,17 @@ async def async_setup_entry( coordinator = powerwall_data[POWERWALL_COORDINATOR] assert coordinator is not None data: PowerwallData = coordinator.data - entities: list[ - PowerWallEnergySensor - | PowerWallImportSensor - | PowerWallExportSensor - | PowerWallChargeSensor - | PowerWallBackupReserveSensor - ] = [ + entities: list[PowerWallEntity] = [ PowerWallChargeSensor(powerwall_data), PowerWallBackupReserveSensor(powerwall_data), ] for meter in data.meters.meters: + entities.append(PowerWallExportSensor(powerwall_data, meter)) + entities.append(PowerWallImportSensor(powerwall_data, meter)) entities.extend( - [ - PowerWallEnergySensor(powerwall_data, meter), - PowerWallExportSensor(powerwall_data, meter), - PowerWallImportSensor(powerwall_data, meter), - ] + PowerWallEnergySensor(powerwall_data, meter, description) + for description in POWERWALL_INSTANT_SENSORS ) async_add_entities(entities) @@ -85,34 +153,27 @@ def native_value(self) -> int: class PowerWallEnergySensor(PowerWallEntity, SensorEntity): """Representation of an Powerwall Energy sensor.""" - _attr_state_class = SensorStateClass.MEASUREMENT - _attr_native_unit_of_measurement = POWER_KILO_WATT - _attr_device_class = SensorDeviceClass.POWER + entity_description: PowerwallSensorEntityDescription - def __init__(self, powerwall_data: PowerwallRuntimeData, meter: MeterType) -> None: + def __init__( + self, + powerwall_data: PowerwallRuntimeData, + meter: MeterType, + description: PowerwallSensorEntityDescription, + ) -> None: """Initialize the sensor.""" + self.entity_description = description super().__init__(powerwall_data) self._meter = meter - self._attr_name = f"Powerwall {self._meter.value.title()} Now" + self._attr_name = f"Powerwall {self._meter.value.title()} {description.name}" self._attr_unique_id = ( - f"{self.base_unique_id}_{self._meter.value}_instant_power" + f"{self.base_unique_id}_{self._meter.value}_{description.key}" ) @property def native_value(self) -> float: - """Get the current value in kW.""" - return self.data.meters.get_meter(self._meter).get_power(precision=3) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the device specific state attributes.""" - meter = self.data.meters.get_meter(self._meter) - return { - ATTR_FREQUENCY: round(meter.frequency, 1), - ATTR_INSTANT_AVERAGE_VOLTAGE: round(meter.average_voltage, 1), - ATTR_INSTANT_TOTAL_CURRENT: meter.get_instant_total_current(), - ATTR_IS_ACTIVE: meter.is_active(), - } + """Get the current value.""" + return self.entity_description.value_fn(self.data.meters.get_meter(self._meter)) class PowerWallBackupReserveSensor(PowerWallEntity, SensorEntity): diff --git a/homeassistant/components/pure_energie/sensor.py b/homeassistant/components/pure_energie/sensor.py index fffbfd7c7bb24..8b07a2818f885 100644 --- a/homeassistant/components/pure_energie/sensor.py +++ b/homeassistant/components/pure_energie/sensor.py @@ -78,10 +78,11 @@ async def async_setup_entry( ) -class PureEnergieSensorEntity(CoordinatorEntity[PureEnergieData], SensorEntity): +class PureEnergieSensorEntity( + CoordinatorEntity[PureEnergieDataUpdateCoordinator], SensorEntity +): """Defines an Pure Energie sensor.""" - coordinator: PureEnergieDataUpdateCoordinator entity_description: PureEnergieSensorEntityDescription def __init__( diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index 471f4483a4781..de108329c45fa 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -133,10 +133,11 @@ async def async_setup_entry( ) -class PVOutputSensorEntity(CoordinatorEntity, SensorEntity): +class PVOutputSensorEntity( + CoordinatorEntity[PVOutputDataUpdateCoordinator], SensorEntity +): """Representation of a PVOutput sensor.""" - coordinator: PVOutputDataUpdateCoordinator entity_description: PVOutputSensorEntityDescription def __init__( diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py index 6d9ac9402e6a1..8cfae034bffd2 100644 --- a/homeassistant/components/pvpc_hourly_pricing/sensor.py +++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py @@ -125,11 +125,9 @@ async def async_setup_entry( ) -class ElecPriceSensor(CoordinatorEntity, SensorEntity): +class ElecPriceSensor(CoordinatorEntity[ElecPricesDataUpdateCoordinator], SensorEntity): """Class to hold the prices of electricity as a sensor.""" - coordinator: ElecPricesDataUpdateCoordinator - def __init__( self, coordinator: ElecPricesDataUpdateCoordinator, diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 2ee6896d03200..c188dd6c4b622 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -985,7 +985,7 @@ def _process_one_event(self, event): if event.event_type == EVENT_STATE_CHANGED: try: dbstate = States.from_event(event) - dbstate_attributes = StateAttributes.from_event(event) + shared_attrs = StateAttributes.shared_attrs_from_event(event) except (TypeError, ValueError) as ex: _LOGGER.warning( "State is not JSON serializable: %s: %s", @@ -995,27 +995,33 @@ def _process_one_event(self, event): return dbstate.attributes = None - shared_attrs = dbstate_attributes.shared_attrs # Matching attributes found in the pending commit if pending_attributes := self._pending_state_attributes.get(shared_attrs): dbstate.state_attributes = pending_attributes # Matching attributes id found in the cache elif attributes_id := self._state_attributes_ids.get(shared_attrs): dbstate.attributes_id = attributes_id - # Matching attributes found in the database - elif ( - attributes := self.event_session.query(StateAttributes.attributes_id) - .filter(StateAttributes.hash == dbstate_attributes.hash) - .filter(StateAttributes.shared_attrs == shared_attrs) - .first() - ): - dbstate.attributes_id = attributes[0] - self._state_attributes_ids[shared_attrs] = attributes[0] - # No matching attributes found, save them in the DB else: - dbstate.state_attributes = dbstate_attributes - self._pending_state_attributes[shared_attrs] = dbstate_attributes - self.event_session.add(dbstate_attributes) + attr_hash = StateAttributes.hash_shared_attrs(shared_attrs) + # Matching attributes found in the database + if ( + attributes := self.event_session.query( + StateAttributes.attributes_id + ) + .filter(StateAttributes.hash == attr_hash) + .filter(StateAttributes.shared_attrs == shared_attrs) + .first() + ): + dbstate.attributes_id = attributes[0] + self._state_attributes_ids[shared_attrs] = attributes[0] + # No matching attributes found, save them in the DB + else: + dbstate_attributes = StateAttributes( + shared_attrs=shared_attrs, hash=attr_hash + ) + dbstate.state_attributes = dbstate_attributes + self._pending_state_attributes[shared_attrs] = dbstate_attributes + self.event_session.add(dbstate_attributes) if old_state := self._old_states.pop(dbstate.entity_id, None): if old_state.state_id: diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index ae0b37e211a12..13f833627a425 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -1,5 +1,11 @@ """Recorder constants.""" +from functools import partial +import json +from typing import Final + +from homeassistant.helpers.json import JSONEncoder + DATA_INSTANCE = "recorder_instance" SQLITE_URL_PREFIX = "sqlite://" DOMAIN = "recorder" @@ -17,3 +23,5 @@ MAX_ROWS_TO_PURGE = 998 DB_WORKER_PREFIX = "DbWorker" + +JSON_DUMP: Final = partial(json.dumps, cls=JSONEncoder, separators=(",", ":")) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index c4d15863a5bb7..6f5597ac26841 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -2,15 +2,17 @@ from __future__ import annotations from collections import defaultdict +from datetime import datetime from itertools import groupby import logging import time -from sqlalchemy import and_, bindparam, func +from sqlalchemy import Text, and_, bindparam, func, or_ from sqlalchemy.ext import baked +from sqlalchemy.sql.expression import literal from homeassistant.components import recorder -from homeassistant.core import split_entity_id +from homeassistant.core import HomeAssistant, State, split_entity_id import homeassistant.util.dt as dt_util from .models import ( @@ -28,14 +30,16 @@ STATE_KEY = "state" LAST_CHANGED_KEY = "last_changed" -SIGNIFICANT_DOMAINS = ( +SIGNIFICANT_DOMAINS = { "climate", "device_tracker", "humidifier", "thermostat", "water_heater", -) -IGNORE_DOMAINS = ("zone", "scene") +} +SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in SIGNIFICANT_DOMAINS] +IGNORE_DOMAINS = {"zone", "scene"} +IGNORE_DOMAINS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in IGNORE_DOMAINS] NEED_ATTRIBUTE_DOMAINS = { "climate", "humidifier", @@ -44,13 +48,20 @@ "water_heater", } -QUERY_STATES = [ - States.domain, +BASE_STATES = [ States.entity_id, States.state, - States.attributes, States.last_changed, States.last_updated, +] +QUERY_STATE_NO_ATTR = [ + *BASE_STATES, + literal(value=None, type_=Text).label("attributes"), + literal(value=None, type_=Text).label("shared_attrs"), +] +QUERY_STATES = [ + *BASE_STATES, + States.attributes, StateAttributes.shared_attrs, ] @@ -78,6 +89,7 @@ def get_significant_states_with_session( include_start_time_state=True, significant_changes_only=True, minimal_response=False, + no_attributes=False, ): """ Return states changes during UTC period start_time - end_time. @@ -92,37 +104,52 @@ def get_significant_states_with_session( thermostat so that we get current temperature in our graphs). """ timer_start = time.perf_counter() + query_keys = QUERY_STATE_NO_ATTR if no_attributes else QUERY_STATES + baked_query = hass.data[HISTORY_BAKERY](lambda session: session.query(*query_keys)) - baked_query = hass.data[HISTORY_BAKERY]( - lambda session: session.query(*QUERY_STATES) - ) - - if significant_changes_only: + if entity_ids is not None and len(entity_ids) == 1: + if ( + significant_changes_only + and split_entity_id(entity_ids[0])[0] not in SIGNIFICANT_DOMAINS + ): + baked_query += lambda q: q.filter( + States.last_changed == States.last_updated + ) + elif significant_changes_only: baked_query += lambda q: q.filter( - ( - States.domain.in_(SIGNIFICANT_DOMAINS) - | (States.last_changed == States.last_updated) + or_( + *[ + States.entity_id.like(entity_domain) + for entity_domain in SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE + ], + (States.last_changed == States.last_updated), ) - & (States.last_updated > bindparam("start_time")) ) - else: - baked_query += lambda q: q.filter(States.last_updated > bindparam("start_time")) if entity_ids is not None: baked_query += lambda q: q.filter( States.entity_id.in_(bindparam("entity_ids", expanding=True)) ) else: - baked_query += lambda q: q.filter(~States.domain.in_(IGNORE_DOMAINS)) + baked_query += lambda q: q.filter( + and_( + *[ + ~States.entity_id.like(entity_domain) + for entity_domain in IGNORE_DOMAINS_ENTITY_ID_LIKE + ] + ) + ) if filters: filters.bake(baked_query) + baked_query += lambda q: q.filter(States.last_updated > bindparam("start_time")) if end_time is not None: baked_query += lambda q: q.filter(States.last_updated < bindparam("end_time")) - baked_query += lambda q: q.outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ) + if not no_attributes: + baked_query += lambda q: q.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) baked_query += lambda q: q.order_by(States.entity_id, States.last_updated) states = execute( @@ -144,14 +171,25 @@ def get_significant_states_with_session( filters, include_start_time_state, minimal_response, + no_attributes, ) -def state_changes_during_period(hass, start_time, end_time=None, entity_id=None): +def state_changes_during_period( + hass: HomeAssistant, + start_time: datetime, + end_time: datetime | None = None, + entity_id: str | None = None, + no_attributes: bool = False, + descending: bool = False, + limit: int | None = None, + include_start_time_state: bool = True, +) -> dict[str, list[State]]: """Return states changes during UTC period start_time - end_time.""" with session_scope(hass=hass) as session: + query_keys = QUERY_STATE_NO_ATTR if no_attributes else QUERY_STATES baked_query = hass.data[HISTORY_BAKERY]( - lambda session: session.query(*QUERY_STATES) + lambda session: session.query(*query_keys) ) baked_query += lambda q: q.filter( @@ -168,10 +206,16 @@ def state_changes_during_period(hass, start_time, end_time=None, entity_id=None) baked_query += lambda q: q.filter_by(entity_id=bindparam("entity_id")) entity_id = entity_id.lower() - baked_query += lambda q: q.outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ) - baked_query += lambda q: q.order_by(States.entity_id, States.last_updated) + if not no_attributes: + baked_query += lambda q: q.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) + + last_updated = States.last_updated.desc() if descending else States.last_updated + baked_query += lambda q: q.order_by(States.entity_id, last_updated) + + if limit: + baked_query += lambda q: q.limit(limit) states = execute( baked_query(session).params( @@ -181,7 +225,14 @@ def state_changes_during_period(hass, start_time, end_time=None, entity_id=None) entity_ids = [entity_id] if entity_id is not None else None - return _sorted_states_to_dict(hass, session, states, start_time, entity_ids) + return _sorted_states_to_dict( + hass, + session, + states, + start_time, + entity_ids, + include_start_time_state=include_start_time_state, + ) def get_last_state_changes(hass, number_of_states, entity_id): @@ -225,7 +276,14 @@ def get_last_state_changes(hass, number_of_states, entity_id): ) -def get_states(hass, utc_point_in_time, entity_ids=None, run=None, filters=None): +def get_states( + hass, + utc_point_in_time, + entity_ids=None, + run=None, + filters=None, + no_attributes=False, +): """Return the states at a specific point in time.""" if run is None: run = recorder.run_information_from_instance(hass, utc_point_in_time) @@ -236,17 +294,23 @@ def get_states(hass, utc_point_in_time, entity_ids=None, run=None, filters=None) with session_scope(hass=hass) as session: return _get_states_with_session( - hass, session, utc_point_in_time, entity_ids, run, filters + hass, session, utc_point_in_time, entity_ids, run, filters, no_attributes ) def _get_states_with_session( - hass, session, utc_point_in_time, entity_ids=None, run=None, filters=None + hass, + session, + utc_point_in_time, + entity_ids=None, + run=None, + filters=None, + no_attributes=False, ): """Return the states at a specific point in time.""" if entity_ids and len(entity_ids) == 1: return _get_single_entity_states_with_session( - hass, session, utc_point_in_time, entity_ids[0] + hass, session, utc_point_in_time, entity_ids[0], no_attributes ) if run is None: @@ -258,7 +322,8 @@ def _get_states_with_session( # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. - query = session.query(*QUERY_STATES) + query_keys = QUERY_STATE_NO_ATTR if no_attributes else QUERY_STATES + query = session.query(*query_keys) if entity_ids: # We got an include-list of entities, accelerate the query by filtering already @@ -278,9 +343,11 @@ def _get_states_with_session( query = query.join( most_recent_state_ids, States.state_id == most_recent_state_ids.c.max_state_id, - ).outerjoin( - StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) + if not no_attributes: + query = query.outerjoin( + StateAttributes, (States.attributes_id == StateAttributes.attributes_id) + ) else: # We did not get an include-list of entities, query all states in the inner # query, then filter out unwanted domains as well as applying the custom filter. @@ -315,30 +382,34 @@ def _get_states_with_session( most_recent_state_ids, States.state_id == most_recent_state_ids.c.max_state_id, ) - query = query.filter(~States.domain.in_(IGNORE_DOMAINS)) + for entity_domain in IGNORE_DOMAINS_ENTITY_ID_LIKE: + query = query.filter(~States.entity_id.like(entity_domain)) if filters: query = filters.apply(query) - query = query.outerjoin( - StateAttributes, (States.attributes_id == StateAttributes.attributes_id) - ) + if not no_attributes: + query = query.outerjoin( + StateAttributes, (States.attributes_id == StateAttributes.attributes_id) + ) attr_cache = {} return [LazyState(row, attr_cache) for row in execute(query)] -def _get_single_entity_states_with_session(hass, session, utc_point_in_time, entity_id): +def _get_single_entity_states_with_session( + hass, session, utc_point_in_time, entity_id, no_attributes=False +): # Use an entirely different (and extremely fast) query if we only # have a single entity id - baked_query = hass.data[HISTORY_BAKERY]( - lambda session: session.query(*QUERY_STATES) - ) + query_keys = QUERY_STATE_NO_ATTR if no_attributes else QUERY_STATES + baked_query = hass.data[HISTORY_BAKERY](lambda session: session.query(*query_keys)) baked_query += lambda q: q.filter( States.last_updated < bindparam("utc_point_in_time"), States.entity_id == bindparam("entity_id"), ) - baked_query += lambda q: q.outerjoin( - StateAttributes, States.attributes_id == StateAttributes.attributes_id - ) + if not no_attributes: + baked_query += lambda q: q.outerjoin( + StateAttributes, States.attributes_id == StateAttributes.attributes_id + ) baked_query += lambda q: q.order_by(States.last_updated.desc()) baked_query += lambda q: q.limit(1) @@ -358,6 +429,7 @@ def _sorted_states_to_dict( filters=None, include_start_time_state=True, minimal_response=False, + no_attributes=False, ): """Convert SQL results into JSON friendly data structure. @@ -381,7 +453,13 @@ def _sorted_states_to_dict( if include_start_time_state: run = recorder.run_information_from_instance(hass, start_time) for state in _get_states_with_session( - hass, session, start_time, entity_ids, run=run, filters=filters + hass, + session, + start_time, + entity_ids, + run=run, + filters=filters, + no_attributes=no_attributes, ): state.last_changed = start_time state.last_updated = start_time @@ -440,7 +518,7 @@ def _sorted_states_to_dict( return {key: val for key, val in result.items() if val} -def get_state(hass, utc_point_in_time, entity_id, run=None): +def get_state(hass, utc_point_in_time, entity_id, run=None, no_attributes=False): """Return a state at a specific point in time.""" - states = get_states(hass, utc_point_in_time, (entity_id,), run) + states = get_states(hass, utc_point_in_time, (entity_id,), run, None, no_attributes) return states[0] if states else None diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 096ec380cf65e..5db43aa760ff9 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -376,6 +376,7 @@ def _drop_foreign_key_constraints(instance, engine, table, columns): def _apply_update(instance, new_version, old_version): # noqa: C901 """Perform operations to bring schema up to date.""" engine = instance.engine + dialect = engine.dialect.name if new_version == 1: _create_index(instance, "events", "ix_events_time_fired") elif new_version == 2: @@ -639,7 +640,8 @@ def _apply_update(instance, new_version, old_version): # noqa: C901 "ix_statistics_short_term_statistic_id_start", ) elif new_version == 25: - _add_columns(instance, "states", ["attributes_id INTEGER(20)"]) + big_int = "INTEGER(20)" if dialect == "mysql" else "INTEGER" + _add_columns(instance, "states", [f"attributes_id {big_int}"]) _create_index(instance, "states", "ix_states_attributes_id") else: diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index ab87408105561..292abf87fd73d 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta import json import logging -from typing import TypedDict, overload +from typing import Any, TypedDict, overload from fnvhash import fnv1a_32 from sqlalchemy import ( @@ -30,14 +30,14 @@ MAX_LENGTH_EVENT_CONTEXT_ID, MAX_LENGTH_EVENT_EVENT_TYPE, MAX_LENGTH_EVENT_ORIGIN, - MAX_LENGTH_STATE_DOMAIN, MAX_LENGTH_STATE_ENTITY_ID, MAX_LENGTH_STATE_STATE, ) -from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id -from homeassistant.helpers.json import JSONEncoder +from homeassistant.core import Context, Event, EventOrigin, State import homeassistant.util.dt as dt_util +from .const import JSON_DUMP + # SQLAlchemy Schema # pylint: disable=invalid-name Base = declarative_base() @@ -116,8 +116,7 @@ def from_event(event, event_data=None): """Create an event database object from a native event.""" return Events( event_type=event.event_type, - event_data=event_data - or json.dumps(event.data, cls=JSONEncoder, separators=(",", ":")), + event_data=event_data or JSON_DUMP(event.data), origin=str(event.origin.value), time_fired=event.time_fired, context_id=event.context.id, @@ -157,7 +156,6 @@ class States(Base): # type: ignore[misc,valid-type] ) __tablename__ = TABLE_STATES state_id = Column(Integer, Identity(), primary_key=True) - domain = Column(String(MAX_LENGTH_STATE_DOMAIN)) entity_id = Column(String(MAX_LENGTH_STATE_ENTITY_ID)) state = Column(String(MAX_LENGTH_STATE_STATE)) attributes = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) @@ -178,7 +176,7 @@ def __repr__(self) -> str: """Return string representation of instance for debugging.""" return ( f" str: ) @staticmethod - def from_event(event): + def from_event(event) -> States: """Create object from a state_changed event.""" entity_id = event.data["entity_id"] - state = event.data.get("new_state") - - dbstate = States(entity_id=entity_id) - dbstate.attributes = None + state: State | None = event.data.get("new_state") + dbstate = States(entity_id=entity_id, attributes=None) - # State got deleted + # None state means the state was removed from the state machine if state is None: dbstate.state = "" - dbstate.domain = split_entity_id(entity_id)[0] dbstate.last_changed = event.time_fired dbstate.last_updated = event.time_fired else: - dbstate.domain = state.domain dbstate.state = state.state dbstate.last_changed = state.last_changed dbstate.last_updated = state.last_updated return dbstate - def to_native(self, validate_entity_id=True): + def to_native(self, validate_entity_id: bool = True) -> State | None: """Convert to an HA state object.""" try: return State( @@ -221,7 +215,7 @@ def to_native(self, validate_entity_id=True): process_timestamp(self.last_updated), # Join the events table on event_id to get the context instead # as it will always be there for state_changed events - context=Context(id=None), + context=Context(id=None), # type: ignore[arg-type] validate_entity_id=validate_entity_id, ) except ValueError: @@ -251,23 +245,29 @@ def __repr__(self) -> str: ) @staticmethod - def from_event(event): + def from_event(event: Event) -> StateAttributes: """Create object from a state_changed event.""" - state = event.data.get("new_state") - dbstate = StateAttributes() - # State got deleted - if state is None: - dbstate.shared_attrs = "{}" - else: - dbstate.shared_attrs = json.dumps( - dict(state.attributes), - cls=JSONEncoder, - separators=(",", ":"), - ) - dbstate.hash = fnv1a_32(dbstate.shared_attrs.encode("utf-8")) + state: State | None = event.data.get("new_state") + # None state means the state was removed from the state machine + dbstate = StateAttributes( + shared_attrs="{}" if state is None else JSON_DUMP(state.attributes) + ) + dbstate.hash = StateAttributes.hash_shared_attrs(dbstate.shared_attrs) return dbstate - def to_native(self): + @staticmethod + def shared_attrs_from_event(event: Event) -> str: + """Create shared_attrs from a state_changed event.""" + state: State | None = event.data.get("new_state") + # None state means the state was removed from the state machine + return "{}" if state is None else JSON_DUMP(state.attributes) + + @staticmethod + def hash_shared_attrs(shared_attrs: str) -> int: + """Return the hash of json encoded shared attributes.""" + return fnv1a_32(shared_attrs.encode("utf-8")) + + def to_native(self) -> dict[str, Any]: """Convert to an HA state object.""" try: return json.loads(self.shared_attrs) diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index 76b8aceb30f3f..02985f9d0c349 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -37,7 +37,7 @@ def _do_return_conn(self, conn): def shutdown(self): """Close the connection.""" - if self.recorder_or_dbworker and (conn := self._conn.current()): + if self.recorder_or_dbworker and self._conn and (conn := self._conn.current()): conn.close() def dispose(self): diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 0109d68f0f5b3..a15d22810f4e4 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -49,9 +49,6 @@ def purge_old_data( state_ids, attributes_ids = _select_state_and_attributes_ids_to_purge( session, purge_before, event_ids ) - attributes_ids = _remove_attributes_ids_used_by_newer_states( - session, purge_before, attributes_ids - ) statistics_runs = _select_statistics_runs_to_purge(session, purge_before) short_term_statistics = _select_short_term_statistics_to_purge( session, purge_before @@ -60,8 +57,10 @@ def purge_old_data( if state_ids: _purge_state_ids(instance, session, state_ids) - if attributes_ids: - _purge_attributes_ids(instance, session, attributes_ids) + if unused_attribute_ids_set := _select_unused_attributes_ids( + session, attributes_ids + ): + _purge_attributes_ids(instance, session, unused_attribute_ids_set) if event_ids: _purge_event_ids(session, event_ids) @@ -121,20 +120,18 @@ def _select_state_and_attributes_ids_to_purge( return state_ids, attributes_ids -def _remove_attributes_ids_used_by_newer_states( - session: Session, purge_before: datetime, attributes_ids: set[int] +def _select_unused_attributes_ids( + session: Session, attributes_ids: set[int] ) -> set[int]: - """Remove attributes ids that are still in use for states we are not purging yet.""" + """Return a set of attributes ids that are not used by any states in the database.""" if not attributes_ids: return set() - keep_attributes_ids = { - state.attributes_id - for state in session.query(States.attributes_id) - .filter(States.last_updated >= purge_before) + to_remove = attributes_ids - { + state[0] + for state in session.query(distinct(States.attributes_id)) .filter(States.attributes_id.in_(attributes_ids)) - .group_by(States.attributes_id) + .all() } - to_remove = attributes_ids - keep_attributes_ids _LOGGER.debug( "Selected %s shared attributes to remove", len(to_remove), @@ -187,9 +184,7 @@ def _purge_state_ids(instance: Recorder, session: Session, state_ids: set[int]) disconnected_rows = ( session.query(States) .filter(States.old_state_id.in_(state_ids)) - .update( - {"old_state_id": None, "attributes_id": None}, synchronize_session=False - ) + .update({"old_state_id": None}, synchronize_session=False) ) _LOGGER.debug("Updated %s states to remove old_state_id", disconnected_rows) @@ -332,27 +327,6 @@ def _purge_filtered_data(instance: Recorder, session: Session) -> bool: return True -def _remove_attributes_ids_used_by_other_entities( - session: Session, entities: list[str], attributes_ids: set[int] -) -> set[int]: - """Remove attributes ids that are still in use for entitiy_ids we are not purging yet.""" - if not attributes_ids: - return set() - keep_attributes_ids = { - state.attributes_id - for state in session.query(States.attributes_id) - .filter(States.entity_id.not_in(entities)) - .filter(States.attributes_id.in_(attributes_ids)) - .group_by(States.attributes_id) - } - to_remove = attributes_ids - keep_attributes_ids - _LOGGER.debug( - "Selected %s shared attributes to remove", - len(to_remove), - ) - return to_remove - - def _purge_filtered_states( instance: Recorder, session: Session, excluded_entity_ids: list[str] ) -> None: @@ -369,15 +343,15 @@ def _purge_filtered_states( ) ) event_ids = [id_ for id_ in event_ids if id_ is not None] - attributes_ids_set = _remove_attributes_ids_used_by_other_entities( - session, excluded_entity_ids, {id_ for id_ in attributes_ids if id_ is not None} - ) _LOGGER.debug( "Selected %s state_ids to remove that should be filtered", len(state_ids) ) _purge_state_ids(instance, session, set(state_ids)) _purge_event_ids(session, event_ids) # type: ignore[arg-type] # type of event_ids already narrowed to 'list[int]' - _purge_attributes_ids(instance, session, attributes_ids_set) + unused_attribute_ids_set = _select_unused_attributes_ids( + session, {id_ for id_ in attributes_ids if id_ is not None} + ) + _purge_attributes_ids(instance, session, unused_attribute_ids_set) def _purge_filtered_events( @@ -390,7 +364,9 @@ def _purge_filtered_events( .limit(MAX_ROWS_TO_PURGE) .all() ) - event_ids: list[int] = [event.event_id for event in events] + event_ids: list[int] = [ + event.event_id for event in events if event.event_id is not None + ] _LOGGER.debug( "Selected %s event_ids to remove that should be filtered", len(event_ids) ) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 4154ae830555f..df53bd5530717 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -718,21 +718,22 @@ def update_statistics_metadata( def list_statistic_ids( hass: HomeAssistant, + statistic_ids: list[str] | tuple[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, ) -> list[dict | None]: - """Return all statistic_ids and unit of measurement. + """Return all statistic_ids (or filtered one) and unit of measurement. Queries the database for existing statistic_ids, as well as integrations with a recorder platform for statistic_ids which will be added in the next statistics period. """ units = hass.config.units - statistic_ids = {} + result = {} # Query the database with session_scope(hass=hass) as session: metadata = get_metadata_with_session( - hass, session, statistic_type=statistic_type + hass, session, statistic_type=statistic_type, statistic_ids=statistic_ids ) for _, meta in metadata.values(): @@ -741,7 +742,7 @@ def list_statistic_ids( unit = _configured_unit(unit, units) meta["unit_of_measurement"] = unit - statistic_ids = { + result = { meta["statistic_id"]: { "name": meta["name"], "source": meta["source"], @@ -754,7 +755,9 @@ def list_statistic_ids( for platform in hass.data[DOMAIN].values(): if not hasattr(platform, "list_statistic_ids"): continue - platform_statistic_ids = platform.list_statistic_ids(hass, statistic_type) + platform_statistic_ids = platform.list_statistic_ids( + hass, statistic_ids=statistic_ids, statistic_type=statistic_type + ) for statistic_id, info in platform_statistic_ids.items(): if (unit := info["unit_of_measurement"]) is not None: @@ -763,7 +766,7 @@ def list_statistic_ids( platform_statistic_ids[statistic_id]["unit_of_measurement"] = unit for key, value in platform_statistic_ids.items(): - statistic_ids.setdefault(key, value) + result.setdefault(key, value) # Return a list of statistic_id + metadata return [ @@ -773,7 +776,7 @@ def list_statistic_ids( "source": info["source"], "unit_of_measurement": info["unit_of_measurement"], } - for _id, info in statistic_ids.items() + for _id, info in result.items() ] diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index ee5081b3c1c07..5c8a53f2ddd4a 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant, callback from .const import DATA_INSTANCE, MAX_QUEUE_BACKLOG -from .statistics import validate_statistics +from .statistics import list_statistic_ids, validate_statistics from .util import async_migration_in_progress if TYPE_CHECKING: @@ -24,6 +24,7 @@ def async_setup(hass: HomeAssistant) -> None: """Set up the recorder websocket API.""" websocket_api.async_register_command(hass, ws_validate_statistics) websocket_api.async_register_command(hass, ws_clear_statistics) + websocket_api.async_register_command(hass, ws_get_statistics_metadata) websocket_api.async_register_command(hass, ws_update_statistics_metadata) websocket_api.async_register_command(hass, ws_info) websocket_api.async_register_command(hass, ws_backup_start) @@ -68,6 +69,23 @@ def ws_clear_statistics( connection.send_result(msg["id"]) +@websocket_api.websocket_command( + { + vol.Required("type"): "recorder/get_statistics_metadata", + vol.Optional("statistic_ids"): [str], + } +) +@websocket_api.async_response +async def ws_get_statistics_metadata( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Get statistics metadata for a list of statistic_ids.""" + statistic_ids = await hass.async_add_executor_job( + list_statistic_ids, hass, msg.get("statistic_ids") + ) + connection.send_result(msg["id"], statistic_ids) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/homeassistant/components/renault/renault_coordinator.py b/homeassistant/components/renault/renault_coordinator.py index 4487d9db9ab73..7db5ed0c4e168 100644 --- a/homeassistant/components/renault/renault_coordinator.py +++ b/homeassistant/components/renault/renault_coordinator.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable from datetime import timedelta import logging -from typing import TypeVar +from typing import Optional, TypeVar from renault_api.kamereon.exceptions import ( AccessDeniedException, @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -T = TypeVar("T", bound=KamereonVehicleDataAttributes) +T = TypeVar("T", bound=Optional[KamereonVehicleDataAttributes]) class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): diff --git a/homeassistant/components/renault/renault_entities.py b/homeassistant/components/renault/renault_entities.py index 14ebcf2c2e444..82f071decae78 100644 --- a/homeassistant/components/renault/renault_entities.py +++ b/homeassistant/components/renault/renault_entities.py @@ -2,14 +2,14 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Optional, cast +from typing import cast from homeassistant.const import ATTR_NAME from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .renault_coordinator import T +from .renault_coordinator import RenaultDataUpdateCoordinator, T from .renault_vehicle import RenaultVehicleProxy @@ -50,7 +50,9 @@ def name(self) -> str: return f"{self.vehicle.device_info[ATTR_NAME]} {self.entity_description.name}" -class RenaultDataEntity(CoordinatorEntity[Optional[T]], RenaultEntity): +class RenaultDataEntity( + CoordinatorEntity[RenaultDataUpdateCoordinator[T]], RenaultEntity +): """Implementation of a Renault entity with a data coordinator.""" def __init__( @@ -65,5 +67,5 @@ def __init__( def _get_data_attr(self, key: str) -> StateType: """Return the attribute value from the coordinator data.""" if self.coordinator.data is None: - return None + return None # type: ignore[unreachable] return cast(StateType, getattr(self.coordinator.data, key)) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index c6621b16bbc4d..3b486af175b1e 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, Generic, cast from renault_api.kamereon.enums import ChargeState, PlugState from renault_api.kamereon.models import ( @@ -45,18 +45,18 @@ @dataclass -class RenaultSensorRequiredKeysMixin: +class RenaultSensorRequiredKeysMixin(Generic[T]): """Mixin for required keys.""" data_key: str - entity_class: type[RenaultSensor] + entity_class: type[RenaultSensor[T]] @dataclass class RenaultSensorEntityDescription( SensorEntityDescription, RenaultDataEntityDescription, - RenaultSensorRequiredKeysMixin, + RenaultSensorRequiredKeysMixin[T], ): """Class describing Renault sensor entities.""" @@ -73,7 +73,7 @@ async def async_setup_entry( ) -> None: """Set up the Renault entities from config entry.""" proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] - entities: list[RenaultSensor] = [ + entities: list[RenaultSensor[Any]] = [ description.entity_class(vehicle, description) for vehicle in proxy.vehicles.values() for description in SENSOR_TYPES @@ -87,7 +87,7 @@ async def async_setup_entry( class RenaultSensor(RenaultDataEntity[T], SensorEntity): """Mixin for sensor specific attributes.""" - entity_description: RenaultSensorEntityDescription + entity_description: RenaultSensorEntityDescription[T] @property def data(self) -> StateType: @@ -157,7 +157,7 @@ def _get_utc_value(entity: RenaultSensor[T]) -> datetime: return as_utc(original_dt) -SENSOR_TYPES: tuple[RenaultSensorEntityDescription, ...] = ( +SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( RenaultSensorEntityDescription( key="battery_level", coordinator="battery", diff --git a/homeassistant/components/ridwell/manifest.json b/homeassistant/components/ridwell/manifest.json index e02a0ba65265d..63dab1ffff3bd 100644 --- a/homeassistant/components/ridwell/manifest.json +++ b/homeassistant/components/ridwell/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ridwell", "requirements": [ - "aioridwell==2021.12.2" + "aioridwell==2022.03.0" ], "codeowners": [ "@bachya" diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 8dd76f0b9cba9..f7e88ad1ed18a 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -402,9 +402,6 @@ async def async_play_media( stream_name = original_media_id stream_format = guess_stream_format(media_id, mime_type) - # If media ID is a relative URL, we serve it from HA. - media_id = async_process_play_media_url(self.hass, media_id) - if media_type == FORMAT_CONTENT_TYPE[HLS_PROVIDER]: media_type = MEDIA_TYPE_VIDEO mime_type = FORMAT_CONTENT_TYPE[HLS_PROVIDER] @@ -412,6 +409,9 @@ async def async_play_media( stream_format = "hls" if media_type in (MEDIA_TYPE_MUSIC, MEDIA_TYPE_URL, MEDIA_TYPE_VIDEO): + # If media ID is a relative URL, we serve it from HA. + media_id = async_process_play_media_url(self.hass, media_id) + parsed = yarl.URL(media_id) if mime_type is None: diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 635c5af62422f..75e117d983479 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -596,11 +596,15 @@ def _compile_statistics( # noqa: C901 return result -def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) -> dict: - """Return statistic_ids and meta data.""" +def list_statistic_ids( + hass: HomeAssistant, + statistic_ids: list[str] | tuple[str] | None = None, + statistic_type: str | None = None, +) -> dict: + """Return all or filtered statistic_ids and meta data.""" entities = _get_sensor_states(hass) - statistic_ids = {} + result = {} for state in entities: state_class = state.attributes[ATTR_STATE_CLASS] @@ -611,6 +615,9 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) - if statistic_type is not None and statistic_type not in provided_statistics: continue + if statistic_ids is not None and state.entity_id not in statistic_ids: + continue + if ( "sum" in provided_statistics and ATTR_LAST_RESET not in state.attributes @@ -619,7 +626,7 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) - continue if device_class not in UNIT_CONVERSIONS: - statistic_ids[state.entity_id] = { + result[state.entity_id] = { "source": RECORDER_DOMAIN, "unit_of_measurement": native_unit, } @@ -629,12 +636,12 @@ def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) - continue statistics_unit = DEVICE_CLASS_UNITS[device_class] - statistic_ids[state.entity_id] = { + result[state.entity_id] = { "source": RECORDER_DOMAIN, "unit_of_measurement": statistics_unit, } - return statistic_ids + return result def validate_statistics( diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index ff8f2bb7ec867..a8af46270aee5 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -3,7 +3,7 @@ "name": "Sentry", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sentry", - "requirements": ["sentry-sdk==1.5.7"], + "requirements": ["sentry-sdk==1.5.8"], "codeowners": ["@dcramer", "@frenck"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index deb9577d57674..7c8a967385174 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==2022.02.1"], + "requirements": ["simplisafe-python==2022.03.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", "dhcp": [ diff --git a/homeassistant/components/smarthab/__init__.py b/homeassistant/components/smarthab/__init__.py deleted file mode 100644 index a4dc1a43f31c2..0000000000000 --- a/homeassistant/components/smarthab/__init__.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Support for SmartHab device integration.""" -import logging - -import pysmarthab -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType - -DOMAIN = "smarthab" -DATA_HUB = "hub" -PLATFORMS = [Platform.LIGHT, Platform.COVER] - -_LOGGER = logging.getLogger(__name__) - -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_EMAIL): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the SmartHab platform.""" - - hass.data.setdefault(DOMAIN, {}) - - if DOMAIN not in config: - return True - - if not hass.config_entries.async_entries(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up config entry for SmartHab integration.""" - - # Assign configuration variables - username = entry.data[CONF_EMAIL] - password = entry.data[CONF_PASSWORD] - - # Setup connection with SmartHab API - hub = pysmarthab.SmartHab() - - try: - await hub.async_login(username, password) - except pysmarthab.RequestFailedException as err: - _LOGGER.exception("Error while trying to reach SmartHab API") - raise ConfigEntryNotReady from err - - # Pass hub object to child platforms - hass.data[DOMAIN][entry.entry_id] = {DATA_HUB: hub} - - hass.config_entries.async_setup_platforms(entry, PLATFORMS) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload config entry from SmartHab integration.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok diff --git a/homeassistant/components/smarthab/config_flow.py b/homeassistant/components/smarthab/config_flow.py deleted file mode 100644 index 826454ab4d81b..0000000000000 --- a/homeassistant/components/smarthab/config_flow.py +++ /dev/null @@ -1,78 +0,0 @@ -"""SmartHab configuration flow.""" -import logging - -import pysmarthab -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD - -from . import DOMAIN - -_LOGGER = logging.getLogger(__name__) - - -class SmartHabConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """SmartHab config flow.""" - - VERSION = 1 - - def _show_setup_form(self, user_input=None, errors=None): - """Show the setup form to the user.""" - - if user_input is None: - user_input = {} - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required( - CONF_EMAIL, default=user_input.get(CONF_EMAIL, "") - ): str, - vol.Required(CONF_PASSWORD): str, - } - ), - errors=errors or {}, - ) - - async def async_step_user(self, user_input=None): - """Handle a flow initiated by the user.""" - errors = {} - - if user_input is None: - return self._show_setup_form(user_input, None) - - username = user_input[CONF_EMAIL] - password = user_input[CONF_PASSWORD] - - # Check if already configured - if self.unique_id is None: - await self.async_set_unique_id(username) - self._abort_if_unique_id_configured() - - # Setup connection with SmartHab API - hub = pysmarthab.SmartHab() - - try: - await hub.async_login(username, password) - - # Verify that passed in configuration works - if hub.is_logged_in(): - return self.async_create_entry( - title=username, data={CONF_EMAIL: username, CONF_PASSWORD: password} - ) - - errors["base"] = "invalid_auth" - except pysmarthab.RequestFailedException: - _LOGGER.exception("Error while trying to reach SmartHab API") - errors["base"] = "service" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected error during login") - errors["base"] = "unknown" - - return self._show_setup_form(user_input, errors) - - async def async_step_import(self, import_info): - """Handle import from legacy config.""" - return await self.async_step_user(import_info) diff --git a/homeassistant/components/smarthab/cover.py b/homeassistant/components/smarthab/cover.py deleted file mode 100644 index 3c581774b03b8..0000000000000 --- a/homeassistant/components/smarthab/cover.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Support for SmartHab device integration.""" -from datetime import timedelta -import logging - -import pysmarthab -from requests.exceptions import Timeout - -from homeassistant.components.cover import ( - ATTR_POSITION, - SUPPORT_CLOSE, - SUPPORT_OPEN, - SUPPORT_SET_POSITION, - CoverDeviceClass, - CoverEntity, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import DATA_HUB, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(seconds=60) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up SmartHab covers from a config entry.""" - hub = hass.data[DOMAIN][config_entry.entry_id][DATA_HUB] - - entities = ( - SmartHabCover(cover) - for cover in await hub.async_get_device_list() - if isinstance(cover, pysmarthab.Shutter) - ) - - async_add_entities(entities, True) - - -class SmartHabCover(CoverEntity): - """Representation a cover.""" - - _attr_device_class = CoverDeviceClass.WINDOW - - def __init__(self, cover): - """Initialize a SmartHabCover.""" - self._cover = cover - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._cover.device_id - - @property - def name(self) -> str: - """Return the display name of this cover.""" - return self._cover.label - - @property - def current_cover_position(self) -> int: - """Return current position of cover. - - None is unknown, 0 is closed, 100 is fully open. - """ - return self._cover.state - - @property - def supported_features(self) -> int: - """Flag supported features.""" - return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION - - @property - def is_closed(self) -> bool: - """Return if the cover is closed or not.""" - return self._cover.state == 0 - - async def async_open_cover(self, **kwargs): - """Open the cover.""" - await self._cover.async_open() - - async def async_close_cover(self, **kwargs): - """Close cover.""" - await self._cover.async_close() - - async def async_set_cover_position(self, **kwargs): - """Move the cover to a specific position.""" - await self._cover.async_set_state(kwargs[ATTR_POSITION]) - - async def async_update(self): - """Fetch new state data for this cover.""" - try: - await self._cover.async_update() - except Timeout: - _LOGGER.error( - "Reached timeout while updating cover %s from API", self.entity_id - ) diff --git a/homeassistant/components/smarthab/light.py b/homeassistant/components/smarthab/light.py deleted file mode 100644 index 9fcc952b24ac4..0000000000000 --- a/homeassistant/components/smarthab/light.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Support for SmartHab device integration.""" -from datetime import timedelta -import logging - -import pysmarthab -from requests.exceptions import Timeout - -from homeassistant.components.light import LightEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import DATA_HUB, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(seconds=60) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up SmartHab lights from a config entry.""" - hub = hass.data[DOMAIN][config_entry.entry_id][DATA_HUB] - - entities = ( - SmartHabLight(light) - for light in await hub.async_get_device_list() - if isinstance(light, pysmarthab.Light) - ) - - async_add_entities(entities, True) - - -class SmartHabLight(LightEntity): - """Representation of a SmartHab Light.""" - - def __init__(self, light): - """Initialize a SmartHabLight.""" - self._light = light - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._light.device_id - - @property - def name(self) -> str: - """Return the display name of this light.""" - return self._light.label - - @property - def is_on(self) -> bool: - """Return true if light is on.""" - return self._light.state - - async def async_turn_on(self, **kwargs): - """Instruct the light to turn on.""" - await self._light.async_turn_on() - - async def async_turn_off(self, **kwargs): - """Instruct the light to turn off.""" - await self._light.async_turn_off() - - async def async_update(self): - """Fetch new state data for this light.""" - try: - await self._light.async_update() - except Timeout: - _LOGGER.error( - "Reached timeout while updating light %s from API", self.entity_id - ) diff --git a/homeassistant/components/smarthab/manifest.json b/homeassistant/components/smarthab/manifest.json deleted file mode 100644 index 7974215de64aa..0000000000000 --- a/homeassistant/components/smarthab/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "smarthab", - "name": "SmartHab", - "documentation": "https://www.home-assistant.io/integrations/smarthab", - "config_flow": true, - "requirements": ["smarthab==0.21"], - "codeowners": ["@outadoc"], - "iot_class": "cloud_polling", - "loggers": ["pysmarthab"] -} diff --git a/homeassistant/components/smarthab/strings.json b/homeassistant/components/smarthab/strings.json deleted file mode 100644 index e1cb6eb441165..0000000000000 --- a/homeassistant/components/smarthab/strings.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "service": "Error while trying to reach SmartHab. Service might be down. Check your connection.", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "step": { - "user": { - "data": { - "password": "[%key:common::config_flow::data::password%]", - "email": "[%key:common::config_flow::data::email%]" - }, - "description": "For technical reasons, be sure to use a secondary account specific to your Home Assistant setup. You can create one from the SmartHab application.", - "title": "Setup SmartHab" - } - } - } -} diff --git a/homeassistant/components/smarthab/translations/bg.json b/homeassistant/components/smarthab/translations/bg.json deleted file mode 100644 index 75022ed3005de..0000000000000 --- a/homeassistant/components/smarthab/translations/bg.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "email": "Email", - "password": "\u041f\u0430\u0440\u043e\u043b\u0430" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/ca.json b/homeassistant/components/smarthab/translations/ca.json deleted file mode 100644 index cac81a0aceaa7..0000000000000 --- a/homeassistant/components/smarthab/translations/ca.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", - "service": "Error en l'intent de connexi\u00f3 a SmartHab. Pot ser que el servei no estigui disponible. Comprova la connexi\u00f3.", - "unknown": "Error inesperat" - }, - "step": { - "user": { - "data": { - "email": "Correu electr\u00f2nic", - "password": "Contrasenya" - }, - "description": "Per motius t\u00e8cnics, assegura't utilitzar un compte secundari espec\u00edfic per a la configuraci\u00f3 de Home Assistant. Pots crear-ne un des de l'aplicaci\u00f3 SmartHab.", - "title": "Configuraci\u00f3 de SmartHab" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/cs.json b/homeassistant/components/smarthab/translations/cs.json deleted file mode 100644 index 1e862ff00691b..0000000000000 --- a/homeassistant/components/smarthab/translations/cs.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", - "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" - }, - "step": { - "user": { - "data": { - "email": "E-mail", - "password": "Heslo" - }, - "title": "Nastaven\u00ed SmartHab" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/de.json b/homeassistant/components/smarthab/translations/de.json deleted file mode 100644 index ca2bf3373f2d5..0000000000000 --- a/homeassistant/components/smarthab/translations/de.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "Ung\u00fcltige Authentifizierung", - "service": "Fehler beim Versuch, SmartHab zu erreichen. Der Dienst ist m\u00f6glicherweise nicht erreichbar. Pr\u00fcfe deine Verbindung.", - "unknown": "Unerwarteter Fehler" - }, - "step": { - "user": { - "data": { - "email": "E-Mail", - "password": "Passwort" - }, - "description": "Stelle aus technischen Gr\u00fcnden sicher, dass du ein sekund\u00e4res Konto speziell f\u00fcr deine Home Assistant-Einrichtung verwendest. Du kannst ein solches Konto \u00fcber die SmartHab-Anwendung erstellen.", - "title": "SmartHab einrichten" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/el.json b/homeassistant/components/smarthab/translations/el.json deleted file mode 100644 index 43de6c1ca8902..0000000000000 --- a/homeassistant/components/smarthab/translations/el.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "\u039c\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf\u03c2 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2", - "service": "\u03a3\u03c6\u03ac\u03bb\u03bc\u03b1 \u03ba\u03b1\u03c4\u03ac \u03c4\u03b7\u03bd \u03c0\u03c1\u03bf\u03c3\u03c0\u03ac\u03b8\u03b5\u03b9\u03b1 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03c3\u03c4\u03bf SmartHab. \u0397 \u03c5\u03c0\u03b7\u03c1\u03b5\u03c3\u03af\u03b1 \u03bc\u03c0\u03bf\u03c1\u03b5\u03af \u03bd\u03b1 \u03b5\u03af\u03bd\u03b1\u03b9 \u03b5\u03ba\u03c4\u03cc\u03c2 \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1\u03c2. \u0395\u03bb\u03ad\u03b3\u03be\u03c4\u03b5 \u03c4\u03b7 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03ae \u03c3\u03b1\u03c2.", - "unknown": "\u0391\u03c0\u03c1\u03cc\u03c3\u03bc\u03b5\u03bd\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" - }, - "step": { - "user": { - "data": { - "email": "Email", - "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2" - }, - "description": "\u0393\u03b9\u03b1 \u03c4\u03b5\u03c7\u03bd\u03b9\u03ba\u03bf\u03cd\u03c2 \u03bb\u03cc\u03b3\u03bf\u03c5\u03c2, \u03b2\u03b5\u03b2\u03b1\u03b9\u03c9\u03b8\u03b5\u03af\u03c4\u03b5 \u03cc\u03c4\u03b9 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b5 \u03ad\u03bd\u03b1\u03bd \u03b4\u03b5\u03c5\u03c4\u03b5\u03c1\u03b5\u03cd\u03bf\u03bd\u03c4\u03b1 \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc \u03b5\u03b9\u03b4\u03b9\u03ba\u03ac \u03b3\u03b9\u03b1 \u03c4\u03b7 \u03c1\u03cd\u03b8\u03bc\u03b9\u03c3\u03b7 \u03c4\u03bf\u03c5 Home Assistant. \u039c\u03c0\u03bf\u03c1\u03b5\u03af\u03c4\u03b5 \u03bd\u03b1 \u03b4\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03ae\u03c3\u03b5\u03c4\u03b5 \u03ad\u03bd\u03b1\u03bd \u03b1\u03c0\u03cc \u03c4\u03b7\u03bd \u03b5\u03c6\u03b1\u03c1\u03bc\u03bf\u03b3\u03ae SmartHab.", - "title": "\u03a1\u03cd\u03b8\u03bc\u03b9\u03c3\u03b7 SmartHab" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/en.json b/homeassistant/components/smarthab/translations/en.json deleted file mode 100644 index 854f7a7ddb5d3..0000000000000 --- a/homeassistant/components/smarthab/translations/en.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "Invalid authentication", - "service": "Error while trying to reach SmartHab. Service might be down. Check your connection.", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "email": "Email", - "password": "Password" - }, - "description": "For technical reasons, be sure to use a secondary account specific to your Home Assistant setup. You can create one from the SmartHab application.", - "title": "Setup SmartHab" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/es.json b/homeassistant/components/smarthab/translations/es.json deleted file mode 100644 index b111ddbccd19a..0000000000000 --- a/homeassistant/components/smarthab/translations/es.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", - "service": "Error al intentar contactar con SmartHab. El servicio podr\u00eda estar ca\u00eddo. Verifica tu conexi\u00f3n.", - "unknown": "Error inesperado" - }, - "step": { - "user": { - "data": { - "email": "Correo electr\u00f3nico", - "password": "Contrase\u00f1a" - }, - "description": "Por razones t\u00e9cnicas, aseg\u00farate de usar una cuenta secundaria espec\u00edfica para su configuraci\u00f3n de Home Assistant. Puedes crear una desde la aplicaci\u00f3n SmartHab.", - "title": "Configurar SmartHab" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/et.json b/homeassistant/components/smarthab/translations/et.json deleted file mode 100644 index f7c91f8dce6a8..0000000000000 --- a/homeassistant/components/smarthab/translations/et.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "Tuvastamise viga", - "service": "Viga SmartHabiga \u00fchendumisel. Teenus v\u00f5ib olla h\u00e4iritud. Kontrolli oma \u00fchendust.", - "unknown": "Tundmatu viga" - }, - "step": { - "user": { - "data": { - "email": "E-post", - "password": "Salas\u00f5na" - }, - "description": "Tehnilistel p\u00f5hjustel kasuta kindlasti oma Home Assistanti seadistustele vastavat sekundaarset kontot. Selle saad luua rakendusest SmartHab.", - "title": "Seadista SmartHab" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/fr.json b/homeassistant/components/smarthab/translations/fr.json deleted file mode 100644 index 5e3381a50e639..0000000000000 --- a/homeassistant/components/smarthab/translations/fr.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "Authentification non valide", - "service": "Erreur de connexion \u00e0 SmartHab. V\u00e9rifiez votre connexion. Le service peut \u00eatre indisponible.", - "unknown": "Erreur inattendue" - }, - "step": { - "user": { - "data": { - "email": "Courriel", - "password": "Mot de passe" - }, - "description": "Pour des raisons techniques, utilisez un compte sp\u00e9cifique \u00e0 Home Assistant. Vous pouvez cr\u00e9er un compte secondaire depuis l'application SmartHab.", - "title": "Configurer SmartHab" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/he.json b/homeassistant/components/smarthab/translations/he.json deleted file mode 100644 index c00515506ac65..0000000000000 --- a/homeassistant/components/smarthab/translations/he.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", - "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" - }, - "step": { - "user": { - "data": { - "email": "\u05d3\u05d5\u05d0\"\u05dc", - "password": "\u05e1\u05d9\u05e1\u05de\u05d4" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/hu.json b/homeassistant/components/smarthab/translations/hu.json deleted file mode 100644 index 2e3cf430a9fe9..0000000000000 --- a/homeassistant/components/smarthab/translations/hu.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "service": "Hiba t\u00f6rt\u00e9nt a SmartHab el\u00e9r\u00e9se k\u00f6zben. A szolg\u00e1ltat\u00e1s le\u00e1llhat. Ellen\u0151rizze a kapcsolatot.", - "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" - }, - "step": { - "user": { - "data": { - "email": "E-mail", - "password": "Jelsz\u00f3" - }, - "description": "Technikai okokb\u00f3l ne felejtsen el m\u00e1sodlagos fi\u00f3kot haszn\u00e1lni a Home Assistant be\u00e1ll\u00edt\u00e1s\u00e1hoz. A SmartHab alkalmaz\u00e1sb\u00f3l l\u00e9trehozhat egyet.", - "title": "A SmartHab be\u00e1ll\u00edt\u00e1sa" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/id.json b/homeassistant/components/smarthab/translations/id.json deleted file mode 100644 index 7a776eac304f5..0000000000000 --- a/homeassistant/components/smarthab/translations/id.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "Autentikasi tidak valid", - "service": "Terjadi kesalahan saat mencoba menjangkau SmartHab. Layanan mungkin sedang mengalami gangguan. Periksa koneksi Anda.", - "unknown": "Kesalahan yang tidak diharapkan" - }, - "step": { - "user": { - "data": { - "email": "Email", - "password": "Kata Sandi" - }, - "description": "Untuk alasan teknis, pastikan untuk menggunakan akun sekunder khusus untuk penyiapan Home Assistant Anda. Anda dapat membuatnya dari aplikasi SmartHab.", - "title": "Siapkan SmartHab" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/it.json b/homeassistant/components/smarthab/translations/it.json deleted file mode 100644 index b74da607f7751..0000000000000 --- a/homeassistant/components/smarthab/translations/it.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "Autenticazione non valida", - "service": "Errore durante il tentativo di raggiungere SmartHab. Il servizio potrebbe non essere attivo. Controlla la connessione.", - "unknown": "Errore imprevisto" - }, - "step": { - "user": { - "data": { - "email": "Email", - "password": "Password" - }, - "description": "Per motivi tecnici, assicurati di utilizzare un account secondario specifico per la tua configurazione di Home Assistant. \u00c8 possibile crearne uno dall'applicazione SmartHab.", - "title": "Configurazione SmartHab" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/ja.json b/homeassistant/components/smarthab/translations/ja.json deleted file mode 100644 index a463f259c2ac5..0000000000000 --- a/homeassistant/components/smarthab/translations/ja.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", - "service": "SmartHab\u306b\u30a2\u30af\u30bb\u30b9\u3057\u3088\u3046\u3068\u3057\u3066\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002\u30b5\u30fc\u30d3\u30b9\u304c\u30c0\u30a6\u30f3\u3057\u3066\u3044\u308b\u53ef\u80fd\u6027\u304c\u3042\u308a\u307e\u3059\u3002\u63a5\u7d9a\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", - "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" - }, - "step": { - "user": { - "data": { - "email": "E\u30e1\u30fc\u30eb", - "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" - }, - "description": "\u6280\u8853\u7684\u306a\u7406\u7531\u304b\u3089\u3001Home Assistant\u306e\u8a2d\u5b9a\u306b\u56fa\u6709\u306e\u30bb\u30ab\u30f3\u30c0\u30ea\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u4f7f\u7528\u3057\u3066\u304f\u3060\u3055\u3044\u3002SmartHab\u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u304b\u3089\u4f5c\u6210\u3067\u304d\u307e\u3059\u3002", - "title": "SmartHab\u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/ko.json b/homeassistant/components/smarthab/translations/ko.json deleted file mode 100644 index 1641555b412f4..0000000000000 --- a/homeassistant/components/smarthab/translations/ko.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "service": "SmartHab \uc5d0 \uc811\uc18d\ud558\ub294 \uc911 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4. \uc11c\ube44\uc2a4\uac00 \ub2e4\uc6b4\ub418\uc5c8\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4. \uc5f0\uacb0\uc744 \ud655\uc778\ud574\uc8fc\uc138\uc694.", - "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" - }, - "step": { - "user": { - "data": { - "email": "\uc774\uba54\uc77c", - "password": "\ube44\ubc00\ubc88\ud638" - }, - "description": "\uae30\uc220\uc801\uc778 \uc774\uc720\ub85c Home Assistant \uc124\uc815\uacfc \uad00\ub828\ub41c \ubcf4\uc870 \uacc4\uc815\uc744 \uc0ac\uc6a9\ud574\uc57c \ud569\ub2c8\ub2e4. SmartHab \uc560\ud50c\ub9ac\ucf00\uc774\uc158\uc5d0\uc11c \uc0dd\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", - "title": "SmartHab \uc124\uce58\ud558\uae30" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/lb.json b/homeassistant/components/smarthab/translations/lb.json deleted file mode 100644 index e651190a1e287..0000000000000 --- a/homeassistant/components/smarthab/translations/lb.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "Ong\u00eblteg Authentifikatioun", - "service": "Feeler beim verbanne mat SmartHab. De Service ass viellaicht net ereechbar. Iwwerpr\u00e9if deng Verbindung.", - "unknown": "Onerwaarte Feeler" - }, - "step": { - "user": { - "data": { - "email": "E-Mail", - "password": "Passwuert" - }, - "description": "W\u00e9inst technesche Gr\u00ebnn soll een zweeten Kont benotz gin fir d\u00e4in Home Assistant. Du kanns een zous\u00e4tzleche Kont an der SmartHab Applikatioun erstellen.", - "title": "SmartHab ariichten" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/nl.json b/homeassistant/components/smarthab/translations/nl.json deleted file mode 100644 index 31a02ae2b9771..0000000000000 --- a/homeassistant/components/smarthab/translations/nl.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "Ongeldige authenticatie", - "service": "Fout bij het bereiken van SmartHab. De service is mogelijk uitgevallen. Controleer uw verbinding.", - "unknown": "Onverwachte fout" - }, - "step": { - "user": { - "data": { - "email": "E-mail", - "password": "Wachtwoord" - }, - "description": "Om technische redenen moet u een tweede account gebruiken dat specifiek is voor uw Home Assistant-installatie. U kunt er een aanmaken vanuit de SmartHab-toepassing.", - "title": "Stel SmartHab in" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/no.json b/homeassistant/components/smarthab/translations/no.json deleted file mode 100644 index ed2beaf7836b4..0000000000000 --- a/homeassistant/components/smarthab/translations/no.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "Ugyldig godkjenning", - "service": "Feil under fors\u00f8k p\u00e5 \u00e5 n\u00e5 SmartHab. Tjenesten kan v\u00e6re nede. Sjekk tilkoblingen din.", - "unknown": "Uventet feil" - }, - "step": { - "user": { - "data": { - "email": "E-post", - "password": "Passord" - }, - "description": "Av tekniske \u00e5rsaker m\u00e5 du s\u00f8rge for \u00e5 bruke en sekund\u00e6r konto som er spesifikk for oppsettet i Home Assistant. Du kan opprette en fra SmartHab-programmet.", - "title": "Oppsett av SmartHab" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/pl.json b/homeassistant/components/smarthab/translations/pl.json deleted file mode 100644 index 14ca88f1c001a..0000000000000 --- a/homeassistant/components/smarthab/translations/pl.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "Niepoprawne uwierzytelnienie", - "service": "B\u0142\u0105d podczas pr\u00f3by osi\u0105gni\u0119cia SmartHab. Us\u0142uga mo\u017ce by\u0107 wy\u0142\u0105czna. Sprawd\u017a po\u0142\u0105czenie.", - "unknown": "Nieoczekiwany b\u0142\u0105d" - }, - "step": { - "user": { - "data": { - "email": "Adres e-mail", - "password": "Has\u0142o" - }, - "description": "Ze wzgl\u0119d\u00f3w technicznych, nale\u017cy u\u017cy\u0107 dodatkowego konta, specjalnie na u\u017cytek dla Home Assistanta. Mo\u017cesz je utworzy\u0107 z poziomu aplikacji SmartHab.", - "title": "Konfiguracja SmartHab" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/pt-BR.json b/homeassistant/components/smarthab/translations/pt-BR.json deleted file mode 100644 index ef205c53827d2..0000000000000 --- a/homeassistant/components/smarthab/translations/pt-BR.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", - "service": "Erro ao tentar acessar o SmartHab. O servi\u00e7o pode estar inoperante. Verifique sua conex\u00e3o.", - "unknown": "Erro inesperado" - }, - "step": { - "user": { - "data": { - "email": "Email", - "password": "Senha" - }, - "description": "Por motivos t\u00e9cnicos, certifique-se de usar uma conta secund\u00e1ria espec\u00edfica para a configura\u00e7\u00e3o do Home Assistant. Voc\u00ea pode criar um a partir do aplicativo SmartHab.", - "title": "Configurar SmartHab" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/pt.json b/homeassistant/components/smarthab/translations/pt.json deleted file mode 100644 index 7430480cc099d..0000000000000 --- a/homeassistant/components/smarthab/translations/pt.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", - "unknown": "Erro inesperado" - }, - "step": { - "user": { - "data": { - "email": "Email", - "password": "Palavra-passe" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/ru.json b/homeassistant/components/smarthab/translations/ru.json deleted file mode 100644 index 45e3698034f68..0000000000000 --- a/homeassistant/components/smarthab/translations/ru.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", - "service": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u043f\u043e\u043f\u044b\u0442\u043a\u0435 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u043a SmartHab. \u0421\u0435\u0440\u0432\u0438\u0441 \u043c\u043e\u0436\u0435\u0442 \u0431\u044b\u0442\u044c \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435.", - "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." - }, - "step": { - "user": { - "data": { - "email": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b", - "password": "\u041f\u0430\u0440\u043e\u043b\u044c" - }, - "description": "\u041f\u043e \u0442\u0435\u0445\u043d\u0438\u0447\u0435\u0441\u043a\u0438\u043c \u043f\u0440\u0438\u0447\u0438\u043d\u0430\u043c \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u0443\u044e \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c \u0434\u043b\u044f Home Assistant. \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u0441\u043e\u0437\u0434\u0430\u0442\u044c \u0435\u0451 \u0432 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0438 SmartHab.", - "title": "SmartHab" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/sk.json b/homeassistant/components/smarthab/translations/sk.json deleted file mode 100644 index 72b0304f1c3bd..0000000000000 --- a/homeassistant/components/smarthab/translations/sk.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "Neplatn\u00e9 overenie" - }, - "step": { - "user": { - "data": { - "email": "Email" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/tr.json b/homeassistant/components/smarthab/translations/tr.json deleted file mode 100644 index 699967ce8eebf..0000000000000 --- a/homeassistant/components/smarthab/translations/tr.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", - "service": "SmartHab'a ula\u015fmaya \u00e7al\u0131\u015f\u0131rken hata olu\u015ftu. Servis kapal\u0131 olabilir. Ba\u011flant\u0131n\u0131z\u0131 kontrol edin.", - "unknown": "Beklenmeyen hata" - }, - "step": { - "user": { - "data": { - "email": "E-posta", - "password": "Parola" - }, - "description": "Teknik nedenlerle, Ev Asistan\u0131 kurulumunuza \u00f6zel ikincil bir hesap kulland\u0131\u011f\u0131n\u0131zdan emin olun. SmartHab uygulamas\u0131ndan bir tane olu\u015fturabilirsiniz.", - "title": "SmartHab'\u0131 kurun" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/uk.json b/homeassistant/components/smarthab/translations/uk.json deleted file mode 100644 index 036ec0a78d4d8..0000000000000 --- a/homeassistant/components/smarthab/translations/uk.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "\u041d\u0435\u0432\u0456\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044f.", - "service": "\u041f\u043e\u043c\u0438\u043b\u043a\u0430 \u043f\u0440\u0438 \u0441\u043f\u0440\u043e\u0431\u0456 \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u044f \u0434\u043e SmartHab. \u0421\u0435\u0440\u0432\u0456\u0441 \u043c\u043e\u0436\u0435 \u0431\u0443\u0442\u0438 \u0432\u0456\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0439. \u041f\u0435\u0440\u0435\u0432\u0456\u0440\u0442\u0435 \u0437'\u0454\u0434\u043d\u0430\u043d\u043d\u044f.", - "unknown": "\u041d\u0435\u043e\u0447\u0456\u043a\u0443\u0432\u0430\u043d\u0430 \u043f\u043e\u043c\u0438\u043b\u043a\u0430" - }, - "step": { - "user": { - "data": { - "email": "\u0410\u0434\u0440\u0435\u0441\u0430 \u0435\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0457 \u043f\u043e\u0448\u0442\u0438", - "password": "\u041f\u0430\u0440\u043e\u043b\u044c" - }, - "description": "\u0417 \u0442\u0435\u0445\u043d\u0456\u0447\u043d\u0438\u0445 \u043f\u0440\u0438\u0447\u0438\u043d \u043d\u0435\u043e\u0431\u0445\u0456\u0434\u043d\u043e \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0432\u0430\u0442\u0438 \u0434\u043e\u0434\u0430\u0442\u043a\u043e\u0432\u0438\u0439 \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u0438\u0439 \u0437\u0430\u043f\u0438\u0441 \u0434\u043b\u044f Home Assistant. \u0412\u0438 \u043c\u043e\u0436\u0435\u0442\u0435 \u0441\u0442\u0432\u043e\u0440\u0438\u0442\u0438 \u0457\u0457 \u0432 \u0434\u043e\u0434\u0430\u0442\u043a\u0443 SmartHab.", - "title": "SmartHab" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/zh-Hans.json b/homeassistant/components/smarthab/translations/zh-Hans.json deleted file mode 100644 index f339adebd8682..0000000000000 --- a/homeassistant/components/smarthab/translations/zh-Hans.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "\u9a8c\u8bc1\u7801\u65e0\u6548" - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarthab/translations/zh-Hant.json b/homeassistant/components/smarthab/translations/zh-Hant.json deleted file mode 100644 index 9f1d903a9b1e4..0000000000000 --- a/homeassistant/components/smarthab/translations/zh-Hant.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "config": { - "error": { - "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", - "service": "\u5617\u8a66\u8a2a\u554f Smarthab \u6642\u767c\u751f\u932f\u8aa4\uff0c\u670d\u52d9\u53ef\u4ee5\u5df2\u7d93\u5931\u6548\uff0c\u8acb\u6aa2\u67e5\u9023\u7dda\u3002", - "unknown": "\u672a\u9810\u671f\u932f\u8aa4" - }, - "step": { - "user": { - "data": { - "email": "\u96fb\u5b50\u90f5\u4ef6", - "password": "\u5bc6\u78bc" - }, - "description": "\u7531\u65bc\u6280\u8853\u539f\u56e0\u3001\u8acb\u78ba\u5b9a\u6307\u5b9a Home Assistant \u8a2d\u5b9a\u5099\u7528\u5e33\u6236\u3002\u53ef\u4ee5\u900f\u904e Smarthab \u61c9\u7528\u7a0b\u5f0f\u5275\u5efa\u3002", - "title": "\u8a2d\u5b9a SmartHab" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index 9bec5d4a72e7b..90f85b6c8393b 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/smarttub", "dependencies": [], "codeowners": ["@mdz"], - "requirements": ["python-smarttub==0.0.29"], + "requirements": ["python-smarttub==0.0.30"], "quality_scale": "platinum", "iot_class": "cloud_polling", "loggers": ["smarttub"] diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index c9b8ec4758339..b0a10690ce61f 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -19,6 +19,7 @@ "audio_delay": (0, 5), "bass": (-10, 10), "treble": (-10, 10), + "sub_gain": (-15, 15), } _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 3a2bac516841c..98c6d3bba26ad 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -150,6 +150,7 @@ def __init__( self.dialog_level: bool | None = None self.night_mode: bool | None = None self.sub_enabled: bool | None = None + self.sub_gain: int | None = None self.surround_enabled: bool | None = None # Misc features @@ -490,7 +491,7 @@ def async_update_volume(self, event: SonosEvent) -> None: if bool_var in variables: setattr(self, bool_var, variables[bool_var] == "1") - for int_var in ("audio_delay", "bass", "treble"): + for int_var in ("audio_delay", "bass", "treble", "sub_gain"): if int_var in variables: setattr(self, int_var, variables[int_var]) diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 9ec389fb4a8e8..abaf367486d29 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -371,17 +371,18 @@ def _run_worker(self) -> None: wait_timeout, redact_credentials(str(self.source)), ) - self._worker_finished() - - def _worker_finished(self) -> None: - """Schedule cleanup of all outputs.""" @callback - def remove_outputs() -> None: + def worker_finished() -> None: + # The worker is no checking availability of the stream and can no longer track + # availability so mark it as available, otherwise the frontend may not be able to + # interact with the stream. + if not self.available: + self._async_update_state(True) for provider in self.outputs().values(): self.remove_provider(provider) - self.hass.loop.call_soon_threadsafe(remove_outputs) + self.hass.loop.call_soon_threadsafe(worker_finished) def stop(self) -> None: """Remove outputs and access token.""" diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py index a252e61b690b2..6e05586706fe8 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -10,6 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -21,6 +22,7 @@ ENTRY_COORDINATOR, ENTRY_VEHICLES, FETCH_INTERVAL, + MANUFACTURER, PLATFORMS, UPDATE_INTERVAL, VEHICLE_API_GEN, @@ -154,3 +156,12 @@ def get_vehicle_info(controller, vin): VEHICLE_LAST_UPDATE: 0, } return info + + +def get_device_info(vehicle_info): + """Return DeviceInfo object based on vehicle info.""" + return DeviceInfo( + identifiers={(DOMAIN, vehicle_info[VEHICLE_VIN])}, + manufacturer=MANUFACTURER, + name=vehicle_info[VEHICLE_NAME], + ) diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py index 596923cbc0623..3ad7dd58af55e 100644 --- a/homeassistant/components/subaru/const.py +++ b/homeassistant/components/subaru/const.py @@ -1,4 +1,6 @@ """Constants for the Subaru integration.""" +from subarulink.const import ALL_DOORS, DRIVERS_DOOR, TAILGATE_DOOR + from homeassistant.const import Platform DOMAIN = "subaru" @@ -32,9 +34,25 @@ MANUFACTURER = "Subaru Corp." PLATFORMS = [ + Platform.LOCK, Platform.SENSOR, ] +SERVICE_LOCK = "lock" +SERVICE_UNLOCK = "unlock" +SERVICE_UNLOCK_SPECIFIC_DOOR = "unlock_specific_door" + +ATTR_DOOR = "door" + +UNLOCK_DOOR_ALL = "all" +UNLOCK_DOOR_DRIVERS = "driver" +UNLOCK_DOOR_TAILGATE = "tailgate" +UNLOCK_VALID_DOORS = { + UNLOCK_DOOR_ALL: ALL_DOORS, + UNLOCK_DOOR_DRIVERS: DRIVERS_DOOR, + UNLOCK_DOOR_TAILGATE: TAILGATE_DOOR, +} + ICONS = { "Avg Fuel Consumption": "mdi:leaf", "EV Range": "mdi:ev-station", diff --git a/homeassistant/components/subaru/lock.py b/homeassistant/components/subaru/lock.py new file mode 100644 index 0000000000000..fb460c6279aba --- /dev/null +++ b/homeassistant/components/subaru/lock.py @@ -0,0 +1,91 @@ +"""Support for Subaru door locks.""" +import logging + +import voluptuous as vol + +from homeassistant.components.lock import LockEntity +from homeassistant.const import SERVICE_LOCK, SERVICE_UNLOCK +from homeassistant.helpers import entity_platform + +from . import DOMAIN, get_device_info +from .const import ( + ATTR_DOOR, + ENTRY_CONTROLLER, + ENTRY_VEHICLES, + SERVICE_UNLOCK_SPECIFIC_DOOR, + UNLOCK_DOOR_ALL, + UNLOCK_VALID_DOORS, + VEHICLE_HAS_REMOTE_SERVICE, + VEHICLE_NAME, + VEHICLE_VIN, +) +from .remote_service import async_call_remote_service + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Subaru locks by config_entry.""" + entry = hass.data[DOMAIN][config_entry.entry_id] + controller = entry[ENTRY_CONTROLLER] + vehicle_info = entry[ENTRY_VEHICLES] + async_add_entities( + SubaruLock(vehicle, controller) + for vehicle in vehicle_info.values() + if vehicle[VEHICLE_HAS_REMOTE_SERVICE] + ) + + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service( + SERVICE_UNLOCK_SPECIFIC_DOOR, + {vol.Required(ATTR_DOOR): vol.In(UNLOCK_VALID_DOORS)}, + "async_unlock_specific_door", + ) + + +class SubaruLock(LockEntity): + """ + Representation of a Subaru door lock. + + Note that the Subaru API currently does not support returning the status of the locks. Lock status is always unknown. + """ + + def __init__(self, vehicle_info, controller): + """Initialize the locks for the vehicle.""" + self.controller = controller + self.vehicle_info = vehicle_info + vin = vehicle_info[VEHICLE_VIN] + self.car_name = vehicle_info[VEHICLE_NAME] + self._attr_name = f"{self.car_name} Door Locks" + self._attr_unique_id = f"{vin}_door_locks" + self._attr_device_info = get_device_info(vehicle_info) + + async def async_lock(self, **kwargs): + """Send the lock command.""" + _LOGGER.debug("Locking doors for: %s", self.car_name) + await async_call_remote_service( + self.controller, + SERVICE_LOCK, + self.vehicle_info, + ) + + async def async_unlock(self, **kwargs): + """Send the unlock command.""" + _LOGGER.debug("Unlocking doors for: %s", self.car_name) + await async_call_remote_service( + self.controller, + SERVICE_UNLOCK, + self.vehicle_info, + UNLOCK_VALID_DOORS[UNLOCK_DOOR_ALL], + ) + + async def async_unlock_specific_door(self, door): + """Send the unlock command for a specified door.""" + _LOGGER.debug("Unlocking %s door for: %s", door, self.car_name) + await async_call_remote_service( + self.controller, + SERVICE_UNLOCK, + self.vehicle_info, + UNLOCK_VALID_DOORS[door], + ) diff --git a/homeassistant/components/subaru/remote_service.py b/homeassistant/components/subaru/remote_service.py new file mode 100644 index 0000000000000..04c87b6b8d2cb --- /dev/null +++ b/homeassistant/components/subaru/remote_service.py @@ -0,0 +1,33 @@ +"""Remote vehicle services for Subaru integration.""" +import logging + +from subarulink.exceptions import SubaruException + +from homeassistant.exceptions import HomeAssistantError + +from .const import SERVICE_UNLOCK, VEHICLE_NAME, VEHICLE_VIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_call_remote_service(controller, cmd, vehicle_info, arg=None): + """Execute subarulink remote command.""" + car_name = vehicle_info[VEHICLE_NAME] + vin = vehicle_info[VEHICLE_VIN] + + _LOGGER.debug("Sending %s command command to %s", cmd, car_name) + success = False + err_msg = "" + try: + if cmd == SERVICE_UNLOCK: + success = await getattr(controller, cmd)(vin, arg) + else: + success = await getattr(controller, cmd)(vin) + except SubaruException as err: + err_msg = err.message + + if success: + _LOGGER.debug("%s command successfully completed for %s", cmd, car_name) + return + + raise HomeAssistantError(f"Service {cmd} failed for {car_name}: {err_msg}") diff --git a/homeassistant/components/subaru/services.yaml b/homeassistant/components/subaru/services.yaml new file mode 100644 index 0000000000000..58be48f9d18f2 --- /dev/null +++ b/homeassistant/components/subaru/services.yaml @@ -0,0 +1,19 @@ +unlock_specific_door: + name: Unlock Specific Door + description: Unlocks specific door(s) + target: + entity: + domain: lock + integration: subaru + fields: + door: + name: Door + description: "One of the following: 'all', 'driver', 'tailgate'" + example: driver + required: true + selector: + select: + options: + - "all" + - "driver" + - "tailgate" diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index 814f6c9da506e..f28e334e1c00f 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -94,11 +94,9 @@ async def async_setup_platform( ) -class TautulliSensor(CoordinatorEntity, SensorEntity): +class TautulliSensor(CoordinatorEntity[TautulliDataUpdateCoordinator], SensorEntity): """Representation of a Tautulli sensor.""" - coordinator: TautulliDataUpdateCoordinator - def __init__( self, coordinator: TautulliDataUpdateCoordinator, diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index ca037f23bc400..b1f1af4a6e05f 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -220,7 +220,7 @@ async def async_added_to_hass(self): ) await super().async_added_to_hass() - async def _async_alarm_arm(self, state, script=None, code=None): + async def _async_alarm_arm(self, state, script, code): """Arm the panel to specified state with supplied script.""" optimistic_set = False @@ -228,10 +228,7 @@ async def _async_alarm_arm(self, state, script=None, code=None): self._state = state optimistic_set = True - if script is not None: - await script.async_run({ATTR_CODE: code}, context=self._context) - else: - _LOGGER.error("No script action defined for %s", state) + await script.async_run({ATTR_CODE: code}, context=self._context) if optimistic_set: self.async_write_ha_state() diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 2768609e65da5..123b365d697be 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -13,13 +13,14 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import template, update_coordinator +from homeassistant.helpers import template +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import TriggerUpdateCoordinator from .const import CONF_ATTRIBUTES, CONF_AVAILABILITY, CONF_PICTURE -class TriggerEntity(update_coordinator.CoordinatorEntity): +class TriggerEntity(CoordinatorEntity[TriggerUpdateCoordinator]): """Template entity based on trigger data.""" domain: str diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 12bcec295d06e..cd5148ba58a22 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -32,11 +32,14 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import update_coordinator from homeassistant.helpers.device_registry import async_get as async_get_dev_reg from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_get as async_get_entity_reg +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from homeassistant.util import Throttle, dt as dt_util from .const import DOMAIN as TIBBER_DOMAIN, MANUFACTURER @@ -239,7 +242,7 @@ async def async_setup_entry( entity_registry = async_get_entity_reg(hass) device_registry = async_get_dev_reg(hass) - coordinator: update_coordinator.DataUpdateCoordinator | None = None + coordinator: TibberDataCoordinator | None = None entities: list[TibberSensor] = [] for home in tibber_connection.get_homes(only_active=False): try: @@ -392,13 +395,13 @@ async def _fetch_data(self): ]["estimatedAnnualConsumption"] -class TibberDataSensor(TibberSensor, update_coordinator.CoordinatorEntity): +class TibberDataSensor(TibberSensor, CoordinatorEntity["TibberDataCoordinator"]): """Representation of a Tibber sensor.""" def __init__( self, tibber_home, - coordinator: update_coordinator.DataUpdateCoordinator, + coordinator: TibberDataCoordinator, entity_description: SensorEntityDescription, ): """Initialize the sensor.""" @@ -420,7 +423,7 @@ def native_value(self): return getattr(self._tibber_home, self.entity_description.key) -class TibberSensorRT(TibberSensor, update_coordinator.CoordinatorEntity): +class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"]): """Representation of a Tibber sensor for real time consumption.""" def __init__( @@ -450,7 +453,7 @@ def available(self): @callback def _handle_coordinator_update(self) -> None: - if not (live_measurement := self.coordinator.get_live_measurement()): # type: ignore[attr-defined] + if not (live_measurement := self.coordinator.get_live_measurement()): return state = live_measurement.get(self.entity_description.key) if state is None: @@ -479,7 +482,7 @@ def _handle_coordinator_update(self) -> None: self.async_write_ha_state() -class TibberRtDataCoordinator(update_coordinator.DataUpdateCoordinator): +class TibberRtDataCoordinator(DataUpdateCoordinator): """Handle Tibber realtime data.""" def __init__(self, async_add_entities, tibber_home, hass): @@ -538,7 +541,7 @@ def get_live_measurement(self): return self.data.get("data", {}).get("liveMeasurement") -class TibberDataCoordinator(update_coordinator.DataUpdateCoordinator): +class TibberDataCoordinator(DataUpdateCoordinator): """Handle Tibber data and insert statistics.""" def __init__(self, hass, tibber_connection): diff --git a/homeassistant/components/tolo/__init__.py b/homeassistant/components/tolo/__init__.py index 6f17379dfbf94..3b78adacbacb5 100644 --- a/homeassistant/components/tolo/__init__.py +++ b/homeassistant/components/tolo/__init__.py @@ -92,11 +92,9 @@ def _get_tolo_sauna_data(self) -> ToloSaunaData: return ToloSaunaData(status, settings) -class ToloSaunaCoordinatorEntity(CoordinatorEntity): +class ToloSaunaCoordinatorEntity(CoordinatorEntity[ToloSaunaUpdateCoordinator]): """CoordinatorEntity for TOLO Sauna.""" - coordinator: ToloSaunaUpdateCoordinator - def __init__( self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry ) -> None: diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py new file mode 100644 index 0000000000000..68070164312bd --- /dev/null +++ b/homeassistant/components/tomorrowio/__init__.py @@ -0,0 +1,351 @@ +"""The Tomorrow.io integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from math import ceil +from typing import Any + +from pytomorrowio import TomorrowioV4 +from pytomorrowio.const import CURRENT, FORECASTS +from pytomorrowio.exceptions import ( + CantConnectException, + InvalidAPIKeyException, + RateLimitedException, + UnknownException, +) + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import ( + ATTRIBUTION, + CONF_TIMESTEP, + DOMAIN, + INTEGRATION_NAME, + MAX_REQUESTS_PER_DAY, + TMRW_ATTR_CARBON_MONOXIDE, + TMRW_ATTR_CHINA_AQI, + TMRW_ATTR_CHINA_HEALTH_CONCERN, + TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, + TMRW_ATTR_CLOUD_BASE, + TMRW_ATTR_CLOUD_CEILING, + TMRW_ATTR_CLOUD_COVER, + TMRW_ATTR_CONDITION, + TMRW_ATTR_DEW_POINT, + TMRW_ATTR_EPA_AQI, + TMRW_ATTR_EPA_HEALTH_CONCERN, + TMRW_ATTR_EPA_PRIMARY_POLLUTANT, + TMRW_ATTR_FEELS_LIKE, + TMRW_ATTR_FIRE_INDEX, + TMRW_ATTR_HUMIDITY, + TMRW_ATTR_NITROGEN_DIOXIDE, + TMRW_ATTR_OZONE, + TMRW_ATTR_PARTICULATE_MATTER_10, + TMRW_ATTR_PARTICULATE_MATTER_25, + TMRW_ATTR_POLLEN_GRASS, + TMRW_ATTR_POLLEN_TREE, + TMRW_ATTR_POLLEN_WEED, + TMRW_ATTR_PRECIPITATION, + TMRW_ATTR_PRECIPITATION_PROBABILITY, + TMRW_ATTR_PRECIPITATION_TYPE, + TMRW_ATTR_PRESSURE, + TMRW_ATTR_PRESSURE_SURFACE_LEVEL, + TMRW_ATTR_SOLAR_GHI, + TMRW_ATTR_SULPHUR_DIOXIDE, + TMRW_ATTR_TEMPERATURE, + TMRW_ATTR_TEMPERATURE_HIGH, + TMRW_ATTR_TEMPERATURE_LOW, + TMRW_ATTR_VISIBILITY, + TMRW_ATTR_WIND_DIRECTION, + TMRW_ATTR_WIND_GUST, + TMRW_ATTR_WIND_SPEED, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [SENSOR_DOMAIN, WEATHER_DOMAIN] + + +def _set_update_interval(hass: HomeAssistant, current_entry: ConfigEntry) -> timedelta: + """Recalculate update_interval based on existing Tomorrow.io instances and update them.""" + api_calls = 2 + # We check how many Tomorrow.io configured instances are using the same API key and + # calculate interval to not exceed allowed numbers of requests. Divide 90% of + # MAX_REQUESTS_PER_DAY by the number of API calls because we want a buffer in the + # number of API calls left at the end of the day. + other_instance_entry_ids = [ + entry.entry_id + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.entry_id != current_entry.entry_id + and entry.data[CONF_API_KEY] == current_entry.data[CONF_API_KEY] + ] + + interval = timedelta( + minutes=( + ceil( + (24 * 60 * (len(other_instance_entry_ids) + 1) * api_calls) + / (MAX_REQUESTS_PER_DAY * 0.9) + ) + ) + ) + + for entry_id in other_instance_entry_ids: + if entry_id in hass.data[DOMAIN]: + hass.data[DOMAIN][entry_id].update_interval = interval + + return interval + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Tomorrow.io API from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + # Let's precreate the device so that if this is a first time setup for a config + # entry imported from a ClimaCell entry, we can apply customizations from the old + # device. + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.data[CONF_API_KEY])}, + name=INTEGRATION_NAME, + manufacturer=INTEGRATION_NAME, + sw_version="v4", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + # If this is an import and we still have the old config entry ID in the entry data, + # it means we are setting this entry up for the first time after a migration from + # ClimaCell to Tomorrow.io. In order to preserve any customizations on the ClimaCell + # entities, we need to remove each old entity, creating a new entity in its place + # but attached to this entry. + if entry.source == SOURCE_IMPORT and "old_config_entry_id" in entry.data: + # Remove the old config entry ID from the entry data so we don't try this again + # on the next setup + data = entry.data.copy() + old_config_entry_id = data.pop("old_config_entry_id") + hass.config_entries.async_update_entry(entry, data=data) + _LOGGER.debug( + ( + "Setting up imported climacell entry %s for the first time as " + "tomorrowio entry %s" + ), + old_config_entry_id, + entry.entry_id, + ) + + ent_reg = er.async_get(hass) + for entity_entry in er.async_entries_for_config_entry( + ent_reg, old_config_entry_id + ): + _LOGGER.debug("Removing %s", entity_entry.entity_id) + ent_reg.async_remove(entity_entry.entity_id) + # In case the API key has changed due to a V3 -> V4 change, we need to + # generate the new entity's unique ID + new_unique_id = ( + f"{entry.data[CONF_API_KEY]}_" + f"{'_'.join(entity_entry.unique_id.split('_')[1:])}" + ) + _LOGGER.debug( + "Re-creating %s for the new config entry", entity_entry.entity_id + ) + # We will precreate the entity so that any customizations can be preserved + new_entity_entry = ent_reg.async_get_or_create( + entity_entry.domain, + DOMAIN, + new_unique_id, + suggested_object_id=entity_entry.entity_id.split(".")[1], + disabled_by=entity_entry.disabled_by, + config_entry=entry, + original_name=entity_entry.original_name, + original_icon=entity_entry.original_icon, + ) + _LOGGER.debug("Re-created %s", new_entity_entry.entity_id) + # If there are customizations on the old entity, apply them to the new one + if entity_entry.name or entity_entry.icon: + ent_reg.async_update_entity( + new_entity_entry.entity_id, + name=entity_entry.name, + icon=entity_entry.icon, + ) + + # We only have one device in the registry but we will do a loop just in case + for old_device in dr.async_entries_for_config_entry( + dev_reg, old_config_entry_id + ): + if old_device.name_by_user: + dev_reg.async_update_device( + device.id, name_by_user=old_device.name_by_user + ) + + # Remove the old config entry and now the entry is fully migrated + hass.async_create_task(hass.config_entries.async_remove(old_config_entry_id)) + + api = TomorrowioV4( + entry.data[CONF_API_KEY], + entry.data[CONF_LATITUDE], + entry.data[CONF_LONGITUDE], + session=async_get_clientsession(hass), + ) + + coordinator = TomorrowioDataUpdateCoordinator( + hass, + entry, + api, + _set_update_interval(hass, entry), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) + + hass.data[DOMAIN].pop(config_entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + + return unload_ok + + +class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator): + """Define an object to hold Tomorrow.io data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + api: TomorrowioV4, + update_interval: timedelta, + ) -> None: + """Initialize.""" + + self._config_entry = config_entry + self._api = api + self.name = config_entry.data[CONF_NAME] + self.data = {CURRENT: {}, FORECASTS: {}} + + super().__init__( + hass, + _LOGGER, + name=config_entry.data[CONF_NAME], + update_interval=update_interval, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via library.""" + try: + return await self._api.realtime_and_all_forecasts( + [ + TMRW_ATTR_TEMPERATURE, + TMRW_ATTR_HUMIDITY, + TMRW_ATTR_PRESSURE, + TMRW_ATTR_WIND_SPEED, + TMRW_ATTR_WIND_DIRECTION, + TMRW_ATTR_CONDITION, + TMRW_ATTR_VISIBILITY, + TMRW_ATTR_OZONE, + TMRW_ATTR_WIND_GUST, + TMRW_ATTR_CLOUD_COVER, + TMRW_ATTR_PRECIPITATION_TYPE, + *( + TMRW_ATTR_CARBON_MONOXIDE, + TMRW_ATTR_CHINA_AQI, + TMRW_ATTR_CHINA_HEALTH_CONCERN, + TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, + TMRW_ATTR_CLOUD_BASE, + TMRW_ATTR_CLOUD_CEILING, + TMRW_ATTR_CLOUD_COVER, + TMRW_ATTR_DEW_POINT, + TMRW_ATTR_EPA_AQI, + TMRW_ATTR_EPA_HEALTH_CONCERN, + TMRW_ATTR_EPA_PRIMARY_POLLUTANT, + TMRW_ATTR_FEELS_LIKE, + TMRW_ATTR_FIRE_INDEX, + TMRW_ATTR_NITROGEN_DIOXIDE, + TMRW_ATTR_OZONE, + TMRW_ATTR_PARTICULATE_MATTER_10, + TMRW_ATTR_PARTICULATE_MATTER_25, + TMRW_ATTR_POLLEN_GRASS, + TMRW_ATTR_POLLEN_TREE, + TMRW_ATTR_POLLEN_WEED, + TMRW_ATTR_PRECIPITATION_TYPE, + TMRW_ATTR_PRESSURE_SURFACE_LEVEL, + TMRW_ATTR_SOLAR_GHI, + TMRW_ATTR_SULPHUR_DIOXIDE, + TMRW_ATTR_WIND_GUST, + ), + ], + [ + TMRW_ATTR_TEMPERATURE_LOW, + TMRW_ATTR_TEMPERATURE_HIGH, + TMRW_ATTR_WIND_SPEED, + TMRW_ATTR_WIND_DIRECTION, + TMRW_ATTR_CONDITION, + TMRW_ATTR_PRECIPITATION, + TMRW_ATTR_PRECIPITATION_PROBABILITY, + ], + nowcast_timestep=self._config_entry.options[CONF_TIMESTEP], + ) + except ( + CantConnectException, + InvalidAPIKeyException, + RateLimitedException, + UnknownException, + ) as error: + raise UpdateFailed from error + + +class TomorrowioEntity(CoordinatorEntity[TomorrowioDataUpdateCoordinator]): + """Base Tomorrow.io Entity.""" + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: TomorrowioDataUpdateCoordinator, + api_version: int, + ) -> None: + """Initialize Tomorrow.io Entity.""" + super().__init__(coordinator) + self.api_version = api_version + self._config_entry = config_entry + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._config_entry.data[CONF_API_KEY])}, + name="Tomorrow.io", + manufacturer="Tomorrow.io", + sw_version=f"v{self.api_version}", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + def _get_current_property(self, property_name: str) -> int | str | float | None: + """ + Get property from current conditions. + + Used for V4 API. + """ + return self.coordinator.data.get(CURRENT, {}).get(property_name) + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py new file mode 100644 index 0000000000000..ac8ceeaa9d5b3 --- /dev/null +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -0,0 +1,214 @@ +"""Config flow for Tomorrow.io integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from pytomorrowio.exceptions import ( + CantConnectException, + InvalidAPIKeyException, + RateLimitedException, +) +from pytomorrowio.pytomorrowio import TomorrowioV4 +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.components.zone import async_active_zone +from homeassistant.const import ( + CONF_API_KEY, + CONF_API_VERSION, + CONF_FRIENDLY_NAME, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import ( + AUTO_MIGRATION_MESSAGE, + CC_DOMAIN, + CONF_TIMESTEP, + DEFAULT_NAME, + DEFAULT_TIMESTEP, + DOMAIN, + INTEGRATION_NAME, + MANUAL_MIGRATION_MESSAGE, + TMRW_ATTR_TEMPERATURE, +) + +_LOGGER = logging.getLogger(__name__) + + +def _get_config_schema( + hass: core.HomeAssistant, source: str | None, input_dict: dict[str, Any] = None +) -> vol.Schema: + """ + Return schema defaults for init step based on user input/config dict. + + Retain info already provided for future form views by setting them as + defaults in schema. + """ + if input_dict is None: + input_dict = {} + + api_key_schema = { + vol.Required(CONF_API_KEY, default=input_dict.get(CONF_API_KEY)): str, + } + + # For imports we just need to ask for the API key + if source == config_entries.SOURCE_IMPORT: + return vol.Schema(api_key_schema, extra=vol.REMOVE_EXTRA) + + return vol.Schema( + { + **api_key_schema, + vol.Required( + CONF_LATITUDE, + "location", + default=input_dict.get(CONF_LATITUDE, hass.config.latitude), + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, + "location", + default=input_dict.get(CONF_LONGITUDE, hass.config.longitude), + ): cv.longitude, + }, + ) + + +def _get_unique_id(hass: HomeAssistant, input_dict: dict[str, Any]): + """Return unique ID from config data.""" + return ( + f"{input_dict[CONF_API_KEY]}" + f"_{input_dict.get(CONF_LATITUDE, hass.config.latitude)}" + f"_{input_dict.get(CONF_LONGITUDE, hass.config.longitude)}" + ) + + +class TomorrowioOptionsConfigFlow(config_entries.OptionsFlow): + """Handle Tomorrow.io options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize Tomorrow.io options flow.""" + self._config_entry = config_entry + + async def async_step_init(self, user_input: dict[str, Any] = None) -> FlowResult: + """Manage the Tomorrow.io options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options_schema = { + vol.Required( + CONF_TIMESTEP, + default=self._config_entry.options[CONF_TIMESTEP], + ): vol.In([1, 5, 15, 30]), + } + + return self.async_show_form( + step_id="init", data_schema=vol.Schema(options_schema) + ) + + +class TomorrowioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Tomorrow.io Weather API.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize config flow.""" + self._showed_import_message = 0 + self._import_config: dict[str, Any] | None = None + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> TomorrowioOptionsConfigFlow: + """Get the options flow for this handler.""" + return TomorrowioOptionsConfigFlow(config_entry) + + async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + # Grab the API key and add it to the rest of the config before continuing + if self._import_config: + self._import_config[CONF_API_KEY] = user_input[CONF_API_KEY] + user_input = self._import_config.copy() + await self.async_set_unique_id( + unique_id=_get_unique_id(self.hass, user_input) + ) + self._abort_if_unique_id_configured() + + latitude = user_input.get(CONF_LATITUDE, self.hass.config.latitude) + longitude = user_input.get(CONF_LONGITUDE, self.hass.config.longitude) + if CONF_NAME not in user_input: + user_input[CONF_NAME] = DEFAULT_NAME + # Append zone name if it exists and we are using the default name + if zone_state := async_active_zone(self.hass, latitude, longitude): + zone_name = zone_state.attributes[CONF_FRIENDLY_NAME] + user_input[CONF_NAME] += f" - {zone_name}" + try: + await TomorrowioV4( + user_input[CONF_API_KEY], + str(latitude), + str(longitude), + session=async_get_clientsession(self.hass), + ).realtime([TMRW_ATTR_TEMPERATURE]) + except CantConnectException: + errors["base"] = "cannot_connect" + except InvalidAPIKeyException: + errors[CONF_API_KEY] = "invalid_api_key" + except RateLimitedException: + errors[CONF_API_KEY] = "rate_limited" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + options: Mapping[str, Any] = {CONF_TIMESTEP: DEFAULT_TIMESTEP} + # Store the old config entry ID and retrieve options to recreate the entry + if self.source == config_entries.SOURCE_IMPORT: + old_config_entry_id = self.context["old_config_entry_id"] + old_config_entry = self.hass.config_entries.async_get_entry( + old_config_entry_id + ) + assert old_config_entry + options = dict(old_config_entry.options) + user_input["old_config_entry_id"] = old_config_entry_id + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input, + options=options, + ) + + return self.async_show_form( + step_id="user", + data_schema=_get_config_schema(self.hass, self.source, user_input), + errors=errors, + ) + + async def async_step_import(self, import_config: dict) -> FlowResult: + """Import from config.""" + # Store import config for later + self._import_config = dict(import_config) + if self._import_config.pop(CONF_API_VERSION, 3) == 3: + # Clear API key from import config + self._import_config[CONF_API_KEY] = "" + self.hass.components.persistent_notification.async_create( + MANUAL_MIGRATION_MESSAGE, + INTEGRATION_NAME, + f"{CC_DOMAIN}_to_{DOMAIN}_new_api_key_needed", + ) + return await self.async_step_user() + + self.hass.components.persistent_notification.async_create( + AUTO_MIGRATION_MESSAGE, + INTEGRATION_NAME, + f"{CC_DOMAIN}_to_{DOMAIN}", + ) + return await self.async_step_user(self._import_config) diff --git a/homeassistant/components/tomorrowio/const.py b/homeassistant/components/tomorrowio/const.py new file mode 100644 index 0000000000000..5f49700e511c2 --- /dev/null +++ b/homeassistant/components/tomorrowio/const.py @@ -0,0 +1,143 @@ +"""Constants for the Tomorrow.io integration.""" +from __future__ import annotations + +from pytomorrowio.const import DAILY, HOURLY, NOWCAST, WeatherCode + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_FOG, + ATTR_CONDITION_HAIL, + ATTR_CONDITION_LIGHTNING, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, +) + +CONF_TIMESTEP = "timestep" +FORECAST_TYPES = [DAILY, HOURLY, NOWCAST] + +DEFAULT_TIMESTEP = 15 +DEFAULT_FORECAST_TYPE = DAILY +CC_DOMAIN = "climacell" +DOMAIN = "tomorrowio" +INTEGRATION_NAME = "Tomorrow.io" +DEFAULT_NAME = INTEGRATION_NAME +ATTRIBUTION = "Powered by Tomorrow.io" + +MAX_REQUESTS_PER_DAY = 500 + +CLEAR_CONDITIONS = {"night": ATTR_CONDITION_CLEAR_NIGHT, "day": ATTR_CONDITION_SUNNY} + +MAX_FORECASTS = { + DAILY: 14, + HOURLY: 24, + NOWCAST: 30, +} + +# Additional attributes +ATTR_WIND_GUST = "wind_gust" +ATTR_CLOUD_COVER = "cloud_cover" +ATTR_PRECIPITATION_TYPE = "precipitation_type" + +# V4 constants +CONDITIONS = { + WeatherCode.WIND: ATTR_CONDITION_WINDY, + WeatherCode.LIGHT_WIND: ATTR_CONDITION_WINDY, + WeatherCode.STRONG_WIND: ATTR_CONDITION_WINDY, + WeatherCode.FREEZING_RAIN: ATTR_CONDITION_SNOWY_RAINY, + WeatherCode.HEAVY_FREEZING_RAIN: ATTR_CONDITION_SNOWY_RAINY, + WeatherCode.LIGHT_FREEZING_RAIN: ATTR_CONDITION_SNOWY_RAINY, + WeatherCode.FREEZING_DRIZZLE: ATTR_CONDITION_SNOWY_RAINY, + WeatherCode.ICE_PELLETS: ATTR_CONDITION_HAIL, + WeatherCode.HEAVY_ICE_PELLETS: ATTR_CONDITION_HAIL, + WeatherCode.LIGHT_ICE_PELLETS: ATTR_CONDITION_HAIL, + WeatherCode.SNOW: ATTR_CONDITION_SNOWY, + WeatherCode.HEAVY_SNOW: ATTR_CONDITION_SNOWY, + WeatherCode.LIGHT_SNOW: ATTR_CONDITION_SNOWY, + WeatherCode.FLURRIES: ATTR_CONDITION_SNOWY, + WeatherCode.THUNDERSTORM: ATTR_CONDITION_LIGHTNING, + WeatherCode.RAIN: ATTR_CONDITION_POURING, + WeatherCode.HEAVY_RAIN: ATTR_CONDITION_RAINY, + WeatherCode.LIGHT_RAIN: ATTR_CONDITION_RAINY, + WeatherCode.DRIZZLE: ATTR_CONDITION_RAINY, + WeatherCode.FOG: ATTR_CONDITION_FOG, + WeatherCode.LIGHT_FOG: ATTR_CONDITION_FOG, + WeatherCode.CLOUDY: ATTR_CONDITION_CLOUDY, + WeatherCode.MOSTLY_CLOUDY: ATTR_CONDITION_CLOUDY, + WeatherCode.PARTLY_CLOUDY: ATTR_CONDITION_PARTLYCLOUDY, +} + +# Weather constants +TMRW_ATTR_TIMESTAMP = "startTime" +TMRW_ATTR_TEMPERATURE = "temperature" +TMRW_ATTR_TEMPERATURE_HIGH = "temperatureMax" +TMRW_ATTR_TEMPERATURE_LOW = "temperatureMin" +TMRW_ATTR_PRESSURE = "pressureSeaLevel" +TMRW_ATTR_HUMIDITY = "humidity" +TMRW_ATTR_WIND_SPEED = "windSpeed" +TMRW_ATTR_WIND_DIRECTION = "windDirection" +TMRW_ATTR_OZONE = "pollutantO3" +TMRW_ATTR_CONDITION = "weatherCode" +TMRW_ATTR_VISIBILITY = "visibility" +TMRW_ATTR_PRECIPITATION = "precipitationIntensityAvg" +TMRW_ATTR_PRECIPITATION_PROBABILITY = "precipitationProbability" +TMRW_ATTR_WIND_GUST = "windGust" +TMRW_ATTR_CLOUD_COVER = "cloudCover" +TMRW_ATTR_PRECIPITATION_TYPE = "precipitationType" + +# Sensor attributes +TMRW_ATTR_PARTICULATE_MATTER_25 = "particulateMatter25" +TMRW_ATTR_PARTICULATE_MATTER_10 = "particulateMatter10" +TMRW_ATTR_NITROGEN_DIOXIDE = "pollutantNO2" +TMRW_ATTR_CARBON_MONOXIDE = "pollutantCO" +TMRW_ATTR_SULPHUR_DIOXIDE = "pollutantSO2" +TMRW_ATTR_EPA_AQI = "epaIndex" +TMRW_ATTR_EPA_PRIMARY_POLLUTANT = "epaPrimaryPollutant" +TMRW_ATTR_EPA_HEALTH_CONCERN = "epaHealthConcern" +TMRW_ATTR_CHINA_AQI = "mepIndex" +TMRW_ATTR_CHINA_PRIMARY_POLLUTANT = "mepPrimaryPollutant" +TMRW_ATTR_CHINA_HEALTH_CONCERN = "mepHealthConcern" +TMRW_ATTR_POLLEN_TREE = "treeIndex" +TMRW_ATTR_POLLEN_WEED = "weedIndex" +TMRW_ATTR_POLLEN_GRASS = "grassIndex" +TMRW_ATTR_FIRE_INDEX = "fireIndex" +TMRW_ATTR_FEELS_LIKE = "temperatureApparent" +TMRW_ATTR_DEW_POINT = "dewPoint" +TMRW_ATTR_PRESSURE_SURFACE_LEVEL = "pressureSurfaceLevel" +TMRW_ATTR_SOLAR_GHI = "solarGHI" +TMRW_ATTR_CLOUD_BASE = "cloudBase" +TMRW_ATTR_CLOUD_CEILING = "cloudCeiling" + +MANUAL_MIGRATION_MESSAGE = ( + "As part of [ClimaCell's rebranding to Tomorrow.io](https://www.tomorrow.io/blog/my-last-day-as-ceo-of-climacell/) " + "we will migrate your existing ClimaCell config entry (or config " + "entries) to the new Tomorrow.io integration, but because **the " + " V3 API is now deprecated**, you will need to get a new V4 API " + "key from [Tomorrow.io](https://app.tomorrow.io/development/keys)." + " Once that is done, visit the " + "[Integrations Configuration](/config/integrations) page and " + "click Configure on the Tomorrow.io card(s) to submit the new " + "key. Once your key has been validated, your config entry will " + "automatically be migrated. The new integration is a drop in " + "replacement and your existing entities will be migrated over, " + "just note that the location of the integration card on the " + "[Integrations Configuration](/config/integrations) page has changed " + "since the integration name has changed." +) + +AUTO_MIGRATION_MESSAGE = ( + "As part of [ClimaCell's rebranding to Tomorrow.io](https://www.tomorrow.io/blog/my-last-day-as-ceo-of-climacell/) " + "we have automatically migrated your existing ClimaCell config entry " + "(or as many of your ClimaCell config entries as we could) to the new " + "Tomorrow.io integration. There is nothing you need to do since the " + "new integration is a drop in replacement and your existing entities " + "have been migrated over, just note that the location of the " + "integration card on the " + "[Integrations Configuration](/config/integrations) page has changed " + "since the integration name has changed." +) diff --git a/homeassistant/components/tomorrowio/manifest.json b/homeassistant/components/tomorrowio/manifest.json new file mode 100644 index 0000000000000..6fc8a2f12ce5b --- /dev/null +++ b/homeassistant/components/tomorrowio/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "tomorrowio", + "name": "Tomorrow.io", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tomorrowio", + "requirements": ["pytomorrowio==0.1.0"], + "codeowners": ["@raman325"], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py new file mode 100644 index 0000000000000..11e10f0d7f960 --- /dev/null +++ b/homeassistant/components/tomorrowio/sensor.py @@ -0,0 +1,365 @@ +"""Sensor component that handles additional Tomorrowio data for your location.""" +from __future__ import annotations + +from abc import abstractmethod +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from pytomorrowio.const import ( + HealthConcernType, + PollenIndex, + PrecipitationType, + PrimaryPollutantType, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + CONF_NAME, + IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, + IRRADIATION_WATTS_PER_SQUARE_METER, + LENGTH_KILOMETERS, + LENGTH_METERS, + LENGTH_MILES, + PERCENTAGE, + PRESSURE_HPA, + PRESSURE_INHG, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify +from homeassistant.util.distance import convert as distance_convert +from homeassistant.util.pressure import convert as pressure_convert +from homeassistant.util.temperature import convert as temp_convert + +from . import TomorrowioDataUpdateCoordinator, TomorrowioEntity +from .const import ( + DOMAIN, + TMRW_ATTR_CARBON_MONOXIDE, + TMRW_ATTR_CHINA_AQI, + TMRW_ATTR_CHINA_HEALTH_CONCERN, + TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, + TMRW_ATTR_CLOUD_BASE, + TMRW_ATTR_CLOUD_CEILING, + TMRW_ATTR_CLOUD_COVER, + TMRW_ATTR_DEW_POINT, + TMRW_ATTR_EPA_AQI, + TMRW_ATTR_EPA_HEALTH_CONCERN, + TMRW_ATTR_EPA_PRIMARY_POLLUTANT, + TMRW_ATTR_FEELS_LIKE, + TMRW_ATTR_FIRE_INDEX, + TMRW_ATTR_NITROGEN_DIOXIDE, + TMRW_ATTR_OZONE, + TMRW_ATTR_PARTICULATE_MATTER_10, + TMRW_ATTR_PARTICULATE_MATTER_25, + TMRW_ATTR_POLLEN_GRASS, + TMRW_ATTR_POLLEN_TREE, + TMRW_ATTR_POLLEN_WEED, + TMRW_ATTR_PRECIPITATION_TYPE, + TMRW_ATTR_PRESSURE_SURFACE_LEVEL, + TMRW_ATTR_SOLAR_GHI, + TMRW_ATTR_SULPHUR_DIOXIDE, + TMRW_ATTR_WIND_GUST, +) + + +@dataclass +class TomorrowioSensorEntityDescription(SensorEntityDescription): + """Describes a Tomorrow.io sensor entity.""" + + unit_imperial: str | None = None + unit_metric: str | None = None + metric_conversion: Callable[[float], float] | float = 1.0 + is_metric_check: bool | None = None + value_map: Any | None = None + + +SENSOR_TYPES = ( + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_FEELS_LIKE, + name="Feels Like", + unit_imperial=TEMP_FAHRENHEIT, + unit_metric=TEMP_CELSIUS, + metric_conversion=lambda val: temp_convert(val, TEMP_FAHRENHEIT, TEMP_CELSIUS), + is_metric_check=True, + device_class=SensorDeviceClass.TEMPERATURE, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_DEW_POINT, + name="Dew Point", + unit_imperial=TEMP_FAHRENHEIT, + unit_metric=TEMP_CELSIUS, + metric_conversion=lambda val: temp_convert(val, TEMP_FAHRENHEIT, TEMP_CELSIUS), + is_metric_check=True, + device_class=SensorDeviceClass.TEMPERATURE, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_PRESSURE_SURFACE_LEVEL, + name="Pressure (Surface Level)", + unit_metric=PRESSURE_HPA, + metric_conversion=lambda val: pressure_convert( + val, PRESSURE_INHG, PRESSURE_HPA + ), + is_metric_check=True, + device_class=SensorDeviceClass.PRESSURE, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_SOLAR_GHI, + name="Global Horizontal Irradiance", + unit_imperial=IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, + unit_metric=IRRADIATION_WATTS_PER_SQUARE_METER, + metric_conversion=3.15459, + is_metric_check=True, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_CLOUD_BASE, + name="Cloud Base", + unit_imperial=LENGTH_MILES, + unit_metric=LENGTH_KILOMETERS, + metric_conversion=lambda val: distance_convert( + val, LENGTH_MILES, LENGTH_KILOMETERS + ), + is_metric_check=True, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_CLOUD_CEILING, + name="Cloud Ceiling", + unit_imperial=LENGTH_MILES, + unit_metric=LENGTH_KILOMETERS, + metric_conversion=lambda val: distance_convert( + val, LENGTH_MILES, LENGTH_KILOMETERS + ), + is_metric_check=True, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_CLOUD_COVER, + name="Cloud Cover", + unit_imperial=PERCENTAGE, + unit_metric=PERCENTAGE, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_WIND_GUST, + name="Wind Gust", + unit_imperial=SPEED_MILES_PER_HOUR, + unit_metric=SPEED_METERS_PER_SECOND, + metric_conversion=lambda val: distance_convert(val, LENGTH_MILES, LENGTH_METERS) + / 3600, + is_metric_check=True, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_PRECIPITATION_TYPE, + name="Precipitation Type", + value_map=PrecipitationType, + device_class="tomorrowio__precipitation_type", + icon="mdi:weather-snowy-rainy", + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_OZONE, + name="Ozone", + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=2.03, + is_metric_check=True, + device_class=SensorDeviceClass.OZONE, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_PARTICULATE_MATTER_25, + name="Particulate Matter < 2.5 μm", + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=3.2808399**3, + is_metric_check=True, + device_class=SensorDeviceClass.PM25, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_PARTICULATE_MATTER_10, + name="Particulate Matter < 10 μm", + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=3.2808399**3, + is_metric_check=True, + device_class=SensorDeviceClass.PM10, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_NITROGEN_DIOXIDE, + name="Nitrogen Dioxide", + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=1.95, + is_metric_check=True, + device_class=SensorDeviceClass.NITROGEN_DIOXIDE, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_CARBON_MONOXIDE, + name="Carbon Monoxide", + unit_imperial=CONCENTRATION_PARTS_PER_MILLION, + unit_metric=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_SULPHUR_DIOXIDE, + name="Sulphur Dioxide", + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=2.71, + is_metric_check=True, + device_class=SensorDeviceClass.SULPHUR_DIOXIDE, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_EPA_AQI, + name="US EPA Air Quality Index", + device_class=SensorDeviceClass.AQI, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_EPA_PRIMARY_POLLUTANT, + name="US EPA Primary Pollutant", + value_map=PrimaryPollutantType, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_EPA_HEALTH_CONCERN, + name="US EPA Health Concern", + value_map=HealthConcernType, + device_class="tomorrowio__health_concern", + icon="mdi:hospital", + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_CHINA_AQI, + name="China MEP Air Quality Index", + device_class=SensorDeviceClass.AQI, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, + name="China MEP Primary Pollutant", + value_map=PrimaryPollutantType, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_CHINA_HEALTH_CONCERN, + name="China MEP Health Concern", + value_map=HealthConcernType, + device_class="tomorrowio__health_concern", + icon="mdi:hospital", + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_POLLEN_TREE, + name="Tree Pollen Index", + value_map=PollenIndex, + device_class="tomorrowio__pollen_index", + icon="mdi:flower-pollen", + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_POLLEN_WEED, + name="Weed Pollen Index", + value_map=PollenIndex, + device_class="tomorrowio__pollen_index", + icon="mdi:flower-pollen", + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_POLLEN_GRASS, + name="Grass Pollen Index", + value_map=PollenIndex, + device_class="tomorrowio__pollen_index", + icon="mdi:flower-pollen", + ), + TomorrowioSensorEntityDescription( + TMRW_ATTR_FIRE_INDEX, + name="Fire Index", + icon="mdi:fire", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + entities = [ + TomorrowioSensorEntity(hass, config_entry, coordinator, 4, description) + for description in SENSOR_TYPES + ] + async_add_entities(entities) + + +class BaseTomorrowioSensorEntity(TomorrowioEntity, SensorEntity): + """Base Tomorrow.io sensor entity.""" + + entity_description: TomorrowioSensorEntityDescription + _attr_entity_registry_enabled_default = False + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + coordinator: TomorrowioDataUpdateCoordinator, + api_version: int, + description: TomorrowioSensorEntityDescription, + ) -> None: + """Initialize Tomorrow.io Sensor Entity.""" + super().__init__(config_entry, coordinator, api_version) + self.entity_description = description + self._attr_name = f"{self._config_entry.data[CONF_NAME]} - {description.name}" + self._attr_unique_id = ( + f"{self._config_entry.unique_id}_{slugify(description.name)}" + ) + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: self.attribution} + # Fallback to metric always in case imperial isn't defined (for metric only + # sensors) + self._attr_native_unit_of_measurement = ( + description.unit_metric + if hass.config.units.is_metric + else description.unit_imperial + ) or description.unit_metric + + @property + @abstractmethod + def _state(self) -> str | int | float | None: + """Return the raw state.""" + + @property + def native_value(self) -> str | int | float | None: + """Return the state.""" + state = self._state + + # If an imperial unit isn't provided, we always want to convert to metric since + # that is what the UI expects + if state is not None and ( + ( + self.entity_description.metric_conversion != 1.0 + and self.entity_description.is_metric_check is not None + and self.hass.config.units.is_metric + == self.entity_description.is_metric_check + ) + or ( + self.entity_description.unit_imperial is None + and self.entity_description.unit_metric is not None + ) + ): + conversion = self.entity_description.metric_conversion + # When conversion is a callable, we assume it's a single input function + if callable(conversion): + return round(conversion(float(state)), 2) + + return round(float(state) * conversion, 2) + + if self.entity_description.value_map is not None and state is not None: + return self.entity_description.value_map(state).name.lower() + + return state + + +class TomorrowioSensorEntity(BaseTomorrowioSensorEntity): + """Sensor entity that talks to Tomorrow.io v4 API to retrieve non-weather data.""" + + @property + def _state(self) -> str | int | float | None: + """Return the raw state.""" + return self._get_current_property(self.entity_description.key) diff --git a/homeassistant/components/tomorrowio/strings.json b/homeassistant/components/tomorrowio/strings.json new file mode 100644 index 0000000000000..b681dc4d043f4 --- /dev/null +++ b/homeassistant/components/tomorrowio/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "description": "To get an API key, sign up at [Tomorrow.io](https://app.tomorrow.io/signup).", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "api_key": "[%key:common::config_flow::data::api_key%]", + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "rate_limited": "Currently rate limited, please try again later." + } + }, + "options": { + "step": { + "init": { + "title": "Update Tomorrow.io Options", + "description": "If you choose to enable the `nowcast` forecast entity, you can configure the number of minutes between each forecast. The number of forecasts provided depends on the number of minutes chosen between forecasts.", + "data": { + "timestep": "Min. Between NowCast Forecasts" + } + } + } + } +} diff --git a/homeassistant/components/tomorrowio/strings.sensor.json b/homeassistant/components/tomorrowio/strings.sensor.json new file mode 100644 index 0000000000000..385357915228f --- /dev/null +++ b/homeassistant/components/tomorrowio/strings.sensor.json @@ -0,0 +1,27 @@ +{ + "state": { + "tomorrowio__pollen_index": { + "none": "None", + "very_low": "Very Low", + "low": "Low", + "medium": "Medium", + "high": "High", + "very_high": "Very High" + }, + "tomorrowio__health_concern": { + "good": "Good", + "moderate": "Moderate", + "unhealthy_for_sensitive_groups": "Unhealthy for Sensitive Groups", + "unhealthy": "Unhealthy", + "very_unhealthy": "Very Unhealthy", + "hazardous": "Hazardous" + }, + "tomorrowio__precipitation_type": { + "none": "None", + "rain": "Rain", + "snow": "Snow", + "freezing_rain": "Freezing Rain", + "ice_pellets": "Ice Pellets" + } + } + } \ No newline at end of file diff --git a/homeassistant/components/tomorrowio/translations/en.json b/homeassistant/components/tomorrowio/translations/en.json new file mode 100644 index 0000000000000..7c653b005747f --- /dev/null +++ b/homeassistant/components/tomorrowio/translations/en.json @@ -0,0 +1,32 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "invalid_api_key": "Invalid API key", + "rate_limited": "Currently rate limited, please try again later.", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Name" + }, + "description": "To get an API key, sign up at [Tomorrow.io](https://app.tomorrow.io/signup)." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "timestep": "Min. Between NowCast Forecasts" + }, + "description": "If you choose to enable the `nowcast` forecast entity, you can configure the number of minutes between each forecast. The number of forecasts provided depends on the number of minutes chosen between forecasts.", + "title": "Update Tomorrow.io Options" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tomorrowio/translations/sensor.en.json b/homeassistant/components/tomorrowio/translations/sensor.en.json new file mode 100644 index 0000000000000..52b767dec3c0f --- /dev/null +++ b/homeassistant/components/tomorrowio/translations/sensor.en.json @@ -0,0 +1,27 @@ +{ + "state": { + "tomorrowio__health_concern": { + "good": "Good", + "hazardous": "Hazardous", + "moderate": "Moderate", + "unhealthy": "Unhealthy", + "unhealthy_for_sensitive_groups": "Unhealthy for Sensitive Groups", + "very_unhealthy": "Very Unhealthy" + }, + "tomorrowio__pollen_index": { + "high": "High", + "low": "Low", + "medium": "Medium", + "none": "None", + "very_high": "Very High", + "very_low": "Very Low" + }, + "tomorrowio__precipitation_type": { + "freezing_rain": "Freezing Rain", + "ice_pellets": "Ice Pellets", + "none": "None", + "rain": "Rain", + "snow": "Snow" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py new file mode 100644 index 0000000000000..3006be989bac0 --- /dev/null +++ b/homeassistant/components/tomorrowio/weather.py @@ -0,0 +1,253 @@ +"""Weather component that handles meteorological data for your location.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pytomorrowio.const import DAILY, FORECASTS, HOURLY, NOWCAST, WeatherCode + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, + WeatherEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_NAME, + LENGTH_INCHES, + LENGTH_MILES, + PRESSURE_INHG, + SPEED_MILES_PER_HOUR, + TEMP_FAHRENHEIT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.sun import is_up +from homeassistant.util import dt as dt_util + +from . import TomorrowioDataUpdateCoordinator, TomorrowioEntity +from .const import ( + CLEAR_CONDITIONS, + CONDITIONS, + CONF_TIMESTEP, + DEFAULT_FORECAST_TYPE, + DOMAIN, + MAX_FORECASTS, + TMRW_ATTR_CONDITION, + TMRW_ATTR_HUMIDITY, + TMRW_ATTR_OZONE, + TMRW_ATTR_PRECIPITATION, + TMRW_ATTR_PRECIPITATION_PROBABILITY, + TMRW_ATTR_PRESSURE, + TMRW_ATTR_TEMPERATURE, + TMRW_ATTR_TEMPERATURE_HIGH, + TMRW_ATTR_TEMPERATURE_LOW, + TMRW_ATTR_TIMESTAMP, + TMRW_ATTR_VISIBILITY, + TMRW_ATTR_WIND_DIRECTION, + TMRW_ATTR_WIND_SPEED, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities = [ + TomorrowioWeatherEntity(config_entry, coordinator, 4, forecast_type) + for forecast_type in (DAILY, HOURLY, NOWCAST) + ] + async_add_entities(entities) + + +class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): + """Entity that talks to Tomorrow.io v4 API to retrieve weather data.""" + + _attr_temperature_unit = TEMP_FAHRENHEIT + _attr_pressure_unit = PRESSURE_INHG + _attr_wind_speed_unit = SPEED_MILES_PER_HOUR + _attr_visibility_unit = LENGTH_MILES + _attr_precipitation_unit = LENGTH_INCHES + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: TomorrowioDataUpdateCoordinator, + api_version: int, + forecast_type: str, + ) -> None: + """Initialize Tomorrow.io Weather Entity.""" + super().__init__(config_entry, coordinator, api_version) + self.forecast_type = forecast_type + self._attr_entity_registry_enabled_default = ( + forecast_type == DEFAULT_FORECAST_TYPE + ) + self._attr_name = f"{config_entry.data[CONF_NAME]} - {forecast_type.title()}" + self._attr_unique_id = f"{config_entry.unique_id}_{forecast_type}" + + def _forecast_dict( + self, + forecast_dt: datetime, + use_datetime: bool, + condition: int, + precipitation: float | None, + precipitation_probability: float | None, + temp: float | None, + temp_low: float | None, + wind_direction: float | None, + wind_speed: float | None, + ) -> dict[str, Any]: + """Return formatted Forecast dict from Tomorrow.io forecast data.""" + if use_datetime: + translated_condition = self._translate_condition( + condition, is_up(self.hass, forecast_dt) + ) + else: + translated_condition = self._translate_condition(condition, True) + + data = { + ATTR_FORECAST_TIME: forecast_dt.isoformat(), + ATTR_FORECAST_CONDITION: translated_condition, + ATTR_FORECAST_PRECIPITATION: precipitation, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: precipitation_probability, + ATTR_FORECAST_TEMP: temp, + ATTR_FORECAST_TEMP_LOW: temp_low, + ATTR_FORECAST_WIND_BEARING: wind_direction, + ATTR_FORECAST_WIND_SPEED: wind_speed, + } + + return {k: v for k, v in data.items() if v is not None} + + @staticmethod + def _translate_condition( + condition: int | None, sun_is_up: bool = True + ) -> str | None: + """Translate Tomorrow.io condition into an HA condition.""" + if condition is None: + return None + # We won't guard here, instead we will fail hard + condition = WeatherCode(condition) + if condition in (WeatherCode.CLEAR, WeatherCode.MOSTLY_CLEAR): + if sun_is_up: + return CLEAR_CONDITIONS["day"] + return CLEAR_CONDITIONS["night"] + return CONDITIONS[condition] + + @property + def temperature(self): + """Return the platform temperature.""" + return self._get_current_property(TMRW_ATTR_TEMPERATURE) + + @property + def pressure(self): + """Return the raw pressure.""" + return self._get_current_property(TMRW_ATTR_PRESSURE) + + @property + def humidity(self): + """Return the humidity.""" + return self._get_current_property(TMRW_ATTR_HUMIDITY) + + @property + def wind_speed(self): + """Return the raw wind speed.""" + return self._get_current_property(TMRW_ATTR_WIND_SPEED) + + @property + def wind_bearing(self): + """Return the wind bearing.""" + return self._get_current_property(TMRW_ATTR_WIND_DIRECTION) + + @property + def ozone(self): + """Return the O3 (ozone) level.""" + return self._get_current_property(TMRW_ATTR_OZONE) + + @property + def condition(self): + """Return the condition.""" + return self._translate_condition( + self._get_current_property(TMRW_ATTR_CONDITION), + is_up(self.hass), + ) + + @property + def visibility(self): + """Return the raw visibility.""" + return self._get_current_property(TMRW_ATTR_VISIBILITY) + + @property + def forecast(self): + """Return the forecast.""" + # Check if forecasts are available + raw_forecasts = self.coordinator.data.get(FORECASTS, {}).get(self.forecast_type) + if not raw_forecasts: + return None + + forecasts = [] + max_forecasts = MAX_FORECASTS[self.forecast_type] + forecast_count = 0 + + # Set default values (in cases where keys don't exist), None will be + # returned. Override properties per forecast type as needed + for forecast in raw_forecasts: + forecast_dt = dt_util.parse_datetime(forecast[TMRW_ATTR_TIMESTAMP]) + + # Throw out past data + if forecast_dt.date() < dt_util.utcnow().date(): + continue + + values = forecast["values"] + use_datetime = True + + condition = values.get(TMRW_ATTR_CONDITION) + precipitation = values.get(TMRW_ATTR_PRECIPITATION) + precipitation_probability = values.get(TMRW_ATTR_PRECIPITATION_PROBABILITY) + + temp = values.get(TMRW_ATTR_TEMPERATURE_HIGH) + temp_low = None + wind_direction = values.get(TMRW_ATTR_WIND_DIRECTION) + wind_speed = values.get(TMRW_ATTR_WIND_SPEED) + + if self.forecast_type == DAILY: + use_datetime = False + temp_low = values.get(TMRW_ATTR_TEMPERATURE_LOW) + if precipitation: + precipitation = precipitation * 24 + elif self.forecast_type == NOWCAST: + # Precipitation is forecasted in CONF_TIMESTEP increments but in a + # per hour rate, so value needs to be converted to an amount. + if precipitation: + precipitation = ( + precipitation / 60 * self._config_entry.options[CONF_TIMESTEP] + ) + + forecasts.append( + self._forecast_dict( + forecast_dt, + use_datetime, + condition, + precipitation, + precipitation_probability, + temp, + temp_low, + wind_direction, + wind_speed, + ) + ) + + forecast_count += 1 + if forecast_count == max_forecasts: + break + + return forecasts diff --git a/homeassistant/components/toon/models.py b/homeassistant/components/toon/models.py index 57db44beb6bfa..e39faa1efc651 100644 --- a/homeassistant/components/toon/models.py +++ b/homeassistant/components/toon/models.py @@ -10,11 +10,9 @@ from .coordinator import ToonDataUpdateCoordinator -class ToonEntity(CoordinatorEntity): +class ToonEntity(CoordinatorEntity[ToonDataUpdateCoordinator]): """Defines a base Toon entity.""" - coordinator: ToonDataUpdateCoordinator - class ToonDisplayDeviceEntity(ToonEntity): """Defines a Toon display device entity.""" diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 4380b1397b6a4..173d1d7930f47 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -30,11 +30,9 @@ async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: return _async_wrap -class CoordinatedTPLinkEntity(CoordinatorEntity): +class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator]): """Common base class for all coordinated tplink entities.""" - coordinator: TPLinkDataUpdateCoordinator - def __init__( self, device: SmartDevice, coordinator: TPLinkDataUpdateCoordinator ) -> None: diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 6efabe537f71d..30d7fbde40ad7 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -49,7 +49,6 @@ async def async_setup_entry( class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): """Representation of a TPLink Smart Bulb.""" - coordinator: TPLinkDataUpdateCoordinator device: SmartBulb def __init__( diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index fe9cb699114ab..7ba28702114b4 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -140,7 +140,6 @@ def _async_sensors_for_device(device: SmartDevice) -> list[SmartPlugSensor]: class SmartPlugSensor(CoordinatedTPLinkEntity, SensorEntity): """Representation of a TPLink Smart Plug energy sensor.""" - coordinator: TPLinkDataUpdateCoordinator entity_description: TPLinkSensorEntityDescription def __init__( diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 451ec6d5f8b05..2b53c67d29649 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -47,7 +47,6 @@ async def async_setup_entry( class SmartPlugLedSwitch(CoordinatedTPLinkEntity, SwitchEntity): """Representation of switch for the LED of a TPLink Smart Plug.""" - coordinator: TPLinkDataUpdateCoordinator device: SmartPlug _attr_entity_category = EntityCategory.CONFIG @@ -85,8 +84,6 @@ def is_on(self) -> bool: class SmartPlugSwitch(CoordinatedTPLinkEntity, SwitchEntity): """Representation of a TPLink Smart Plug switch.""" - coordinator: TPLinkDataUpdateCoordinator - def __init__( self, device: SmartDevice, diff --git a/homeassistant/components/tradfri/base_class.py b/homeassistant/components/tradfri/base_class.py index a2bd28e386840..727e611dca414 100644 --- a/homeassistant/components/tradfri/base_class.py +++ b/homeassistant/components/tradfri/base_class.py @@ -37,11 +37,9 @@ async def wrapper(command: Command | list[Command]) -> None: return wrapper -class TradfriBaseEntity(CoordinatorEntity): +class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]): """Base Tradfri device.""" - coordinator: TradfriDeviceDataUpdateCoordinator - def __init__( self, device_coordinator: TradfriDeviceDataUpdateCoordinator, diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index 9b6ad3e9f0676..bae626017e7b9 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -78,7 +78,7 @@ async def async_setup_entry( async_add_entities(entities) -class TradfriGroup(CoordinatorEntity, LightEntity): +class TradfriGroup(CoordinatorEntity[TradfriGroupDataUpdateCoordinator], LightEntity): """The platform class for light groups required by hass.""" _attr_supported_features = SUPPORTED_GROUP_FEATURES diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py index 5f364bb74722f..345d625c7c1f0 100644 --- a/homeassistant/components/trafikverket_weatherstation/config_flow.py +++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py @@ -1,8 +1,6 @@ """Adds config flow for Trafikverket Weather integration.""" from __future__ import annotations -from typing import Any - from pytrafikverket.trafikverket_weather import TrafikverketWeather import voluptuous as vol @@ -14,13 +12,6 @@ from .const import CONF_STATION, DOMAIN -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_STATION): cv.string, - } -) - class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Trafikverket Weatherstation integration.""" @@ -39,18 +30,8 @@ async def validate_input(self, sensor_api: str, station: str) -> str: return str(err) return "connected" - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Import a configuration from config.yaml.""" - - self.context.update( - {"title_placeholders": {CONF_STATION: f"YAML import {DOMAIN}"}} - ) - - self._async_abort_entries_match({CONF_STATION: config[CONF_STATION]}) - return await self.async_step_user(user_input=config) - async def async_step_user( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, str] | None = None ) -> FlowResult: """Handle the initial step.""" errors = {} @@ -80,6 +61,11 @@ async def async_step_user( return self.async_show_form( step_id="user", - data_schema=DATA_SCHEMA, + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_STATION): cv.string, + } + ), errors=errors, ) diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index 92211b4d68151..0a5f069824c36 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -8,20 +8,16 @@ import aiohttp from pytrafikverket.trafikverket_weather import TrafikverketWeather, WeatherStationInfo -import voluptuous as vol from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, - CONF_MONITORED_CONDITIONS, - CONF_NAME, DEGREE, LENGTH_MILLIMETERS, PERCENTAGE, @@ -30,11 +26,9 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle from .const import ( @@ -151,37 +145,6 @@ class TrafikverketSensorEntityDescription( ), ) -SENSOR_KEYS = [desc.key for desc in SENSOR_TYPES] - -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_STATION): cv.string, - vol.Required(CONF_MONITORED_CONDITIONS, default=[]): [vol.In(SENSOR_KEYS)], - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Import Trafikverket Weather configuration from YAML.""" - _LOGGER.warning( - # Config flow added in Home Assistant Core 2021.12, remove import flow in 2022.4 - "Loading Trafikverket Weather via platform setup is deprecated; Please remove it from your configuration" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback diff --git a/homeassistant/components/twentemilieu/__init__.py b/homeassistant/components/twentemilieu/__init__.py index cee6ffbf38eac..bbe306392ba81 100644 --- a/homeassistant/components/twentemilieu/__init__.py +++ b/homeassistant/components/twentemilieu/__init__.py @@ -20,7 +20,7 @@ SERVICE_UPDATE = "update" SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_ID): cv.string}) -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.CALENDAR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py new file mode 100644 index 0000000000000..0d2768e5eb2d0 --- /dev/null +++ b/homeassistant/components/twentemilieu/calendar.py @@ -0,0 +1,101 @@ +"""Support for Twente Milieu Calendar.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from homeassistant.components.calendar import CalendarEventDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +import homeassistant.util.dt as dt_util + +from .const import DOMAIN, WASTE_TYPE_TO_DESCRIPTION +from .entity import TwenteMilieuEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Twente Milieu calendar based on a config entry.""" + coordinator = hass.data[DOMAIN][entry.data[CONF_ID]] + async_add_entities([TwenteMilieuCalendar(coordinator, entry)]) + + +class TwenteMilieuCalendar(TwenteMilieuEntity, CalendarEventDevice): + """Defines a Twente Milieu calendar.""" + + _attr_name = "Twente Milieu" + _attr_icon = "mdi:delete-empty" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + entry: ConfigEntry, + ) -> None: + """Initialize the Twente Milieu entity.""" + super().__init__(coordinator, entry) + self._attr_unique_id = str(entry.data[CONF_ID]) + self._event: dict[str, Any] | None = None + + @property + def event(self) -> dict[str, Any] | None: + """Return the next upcoming event.""" + return self._event + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[dict[str, Any]]: + """Return calendar events within a datetime range.""" + events: list[dict[str, Any]] = [] + for waste_type, waste_dates in self.coordinator.data.items(): + events.extend( + { + "all_day": True, + "start": {"date": waste_date.isoformat()}, + "end": {"date": waste_date.isoformat()}, + "summary": WASTE_TYPE_TO_DESCRIPTION[waste_type], + } + for waste_date in waste_dates + if start_date.date() <= waste_date <= end_date.date() + ) + + return events + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + next_waste_pickup_type = None + next_waste_pickup_date = None + for waste_type, waste_dates in self.coordinator.data.items(): + if ( + waste_dates + and ( + next_waste_pickup_date is None + or waste_dates[0] # type: ignore[unreachable] + < next_waste_pickup_date + ) + and waste_dates[0] >= dt_util.now().date() + ): + next_waste_pickup_date = waste_dates[0] + next_waste_pickup_type = waste_type + + self._event = None + if next_waste_pickup_date is not None and next_waste_pickup_type is not None: + self._event = { + "all_day": True, + "start": {"date": next_waste_pickup_date.isoformat()}, + "end": {"date": next_waste_pickup_date.isoformat()}, + "summary": WASTE_TYPE_TO_DESCRIPTION[next_waste_pickup_type], + } + + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/homeassistant/components/twentemilieu/const.py b/homeassistant/components/twentemilieu/const.py index 95ab903cc1722..c9f2f935772d9 100644 --- a/homeassistant/components/twentemilieu/const.py +++ b/homeassistant/components/twentemilieu/const.py @@ -3,6 +3,8 @@ import logging from typing import Final +from twentemilieu import WasteType + DOMAIN: Final = "twentemilieu" LOGGER = logging.getLogger(__package__) @@ -11,3 +13,11 @@ CONF_POST_CODE = "post_code" CONF_HOUSE_NUMBER = "house_number" CONF_HOUSE_LETTER = "house_letter" + +WASTE_TYPE_TO_DESCRIPTION = { + WasteType.NON_RECYCLABLE: "Non-recyclable Waste Pickup", + WasteType.ORGANIC: "Organic Waste Pickup", + WasteType.PACKAGES: "Packages Waste Pickup", + WasteType.PAPER: "Paper Waste Pickup", + WasteType.TREE: "Christmas Tree Pickup", +} diff --git a/homeassistant/components/twentemilieu/entity.py b/homeassistant/components/twentemilieu/entity.py new file mode 100644 index 0000000000000..008c0fa441e1d --- /dev/null +++ b/homeassistant/components/twentemilieu/entity.py @@ -0,0 +1,38 @@ +"""Base entity for the Twente Milieu integration.""" +from __future__ import annotations + +from datetime import date + +from twentemilieu import WasteType + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + + +class TwenteMilieuEntity( + CoordinatorEntity[DataUpdateCoordinator[dict[WasteType, list[date]]]], Entity +): + """Defines a Twente Milieu entity.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[WasteType, list[date]]], + entry: ConfigEntry, + ) -> None: + """Initialize the Twente Milieu entity.""" + super().__init__(coordinator=coordinator) + self._attr_device_info = DeviceInfo( + configuration_url="https://www.twentemilieu.nl", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, str(entry.data[CONF_ID]))}, + manufacturer="Twente Milieu", + name="Twente Milieu", + ) diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index a56523d53d0ae..ab69aba9abf14 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -14,15 +14,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .const import DOMAIN, WASTE_TYPE_TO_DESCRIPTION +from .entity import TwenteMilieuEntity @dataclass @@ -43,35 +39,35 @@ class TwenteMilieuSensorDescription( TwenteMilieuSensorDescription( key="tree", waste_type=WasteType.TREE, - name="Christmas Tree Pickup", + name=WASTE_TYPE_TO_DESCRIPTION[WasteType.TREE], icon="mdi:pine-tree", device_class=SensorDeviceClass.DATE, ), TwenteMilieuSensorDescription( key="Non-recyclable", waste_type=WasteType.NON_RECYCLABLE, - name="Non-recyclable Waste Pickup", + name=WASTE_TYPE_TO_DESCRIPTION[WasteType.NON_RECYCLABLE], icon="mdi:delete-empty", device_class=SensorDeviceClass.DATE, ), TwenteMilieuSensorDescription( key="Organic", waste_type=WasteType.ORGANIC, - name="Organic Waste Pickup", + name=WASTE_TYPE_TO_DESCRIPTION[WasteType.ORGANIC], icon="mdi:delete-empty", device_class=SensorDeviceClass.DATE, ), TwenteMilieuSensorDescription( key="Paper", waste_type=WasteType.PAPER, - name="Paper Waste Pickup", + name=WASTE_TYPE_TO_DESCRIPTION[WasteType.PAPER], icon="mdi:delete-empty", device_class=SensorDeviceClass.DATE, ), TwenteMilieuSensorDescription( key="Plastic", waste_type=WasteType.PACKAGES, - name="Packages Waste Pickup", + name=WASTE_TYPE_TO_DESCRIPTION[WasteType.PACKAGES], icon="mdi:delete-empty", device_class=SensorDeviceClass.DATE, ), @@ -90,7 +86,7 @@ async def async_setup_entry( ) -class TwenteMilieuSensor(CoordinatorEntity[dict[WasteType, list[date]]], SensorEntity): +class TwenteMilieuSensor(TwenteMilieuEntity, SensorEntity): """Defines a Twente Milieu sensor.""" entity_description: TwenteMilieuSensorDescription @@ -102,16 +98,9 @@ def __init__( entry: ConfigEntry, ) -> None: """Initialize the Twente Milieu entity.""" - super().__init__(coordinator=coordinator) + super().__init__(coordinator, entry) self.entity_description = description self._attr_unique_id = f"{DOMAIN}_{entry.data[CONF_ID]}_{description.key}" - self._attr_device_info = DeviceInfo( - configuration_url="https://www.twentemilieu.nl", - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, str(entry.data[CONF_ID]))}, - manufacturer="Twente Milieu", - name="Twente Milieu", - ) @property def native_value(self) -> date | None: diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index a824bc596d004..e42659948d8b8 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -174,7 +174,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok -class UpCloudServerEntity(CoordinatorEntity): +class UpCloudServerEntity(CoordinatorEntity[UpCloudDataUpdateCoordinator]): """Entity class for UpCloud servers.""" def __init__( diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py new file mode 100644 index 0000000000000..7fb92d8164c89 --- /dev/null +++ b/homeassistant/components/update/__init__.py @@ -0,0 +1,355 @@ +"""Component to allow for providing device or service updates.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any, Final, final + +import voluptuous as vol + +from homeassistant.backports.enum import StrEnum +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.config_validation import ( + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import EntityCategory, EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType + +from .const import ( + ATTR_BACKUP, + ATTR_CURRENT_VERSION, + ATTR_IN_PROGRESS, + ATTR_LATEST_VERSION, + ATTR_RELEASE_SUMMARY, + ATTR_RELEASE_URL, + ATTR_SKIPPED_VERSION, + ATTR_TITLE, + ATTR_VERSION, + DOMAIN, + SERVICE_INSTALL, + SERVICE_SKIP, + UpdateEntityFeature, +) + +SCAN_INTERVAL = timedelta(minutes=15) + +ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" + +_LOGGER = logging.getLogger(__name__) + + +class UpdateDeviceClass(StrEnum): + """Device class for update.""" + + FIRMWARE = "firmware" + + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(UpdateDeviceClass)) + + +__all__ = [ + "ATTR_BACKUP", + "ATTR_VERSION", + "DEVICE_CLASSES_SCHEMA", + "DOMAIN", + "PLATFORM_SCHEMA_BASE", + "PLATFORM_SCHEMA", + "SERVICE_INSTALL", + "SERVICE_SKIP", + "UpdateDeviceClass", + "UpdateEntity", + "UpdateEntityDescription", + "UpdateEntityFeature", +] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Select entities.""" + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_INSTALL, + { + vol.Optional(ATTR_VERSION): cv.string, + vol.Optional(ATTR_BACKUP): cv.boolean, + }, + async_install, + [UpdateEntityFeature.INSTALL], + ) + + component.async_register_entity_service( + SERVICE_SKIP, + {}, + UpdateEntity.async_skip.__name__, + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None: + """Service call wrapper to validate the call.""" + # If version is not specified, but no update is available. + if (version := service_call.data.get(ATTR_VERSION)) is None and ( + entity.current_version == entity.latest_version or entity.latest_version is None + ): + raise HomeAssistantError(f"No update available for {entity.name}") + + # If version is specified, but not supported by the entity. + if ( + version is not None + and not entity.supported_features & UpdateEntityFeature.SPECIFIC_VERSION + ): + raise HomeAssistantError( + f"Installing a specific version is not supported for {entity.name}" + ) + + # If backup is requested, but not supported by the entity. + if ( + backup := service_call.data.get(ATTR_BACKUP) + ) and not entity.supported_features & UpdateEntityFeature.BACKUP: + raise HomeAssistantError(f"Backup is not supported for {entity.name}") + + # Update is already in progress. + if entity.in_progress is not False: + raise HomeAssistantError( + f"Update installation already in progress for {entity.name}" + ) + + await entity.async_install_with_progress(version, backup) + + +@dataclass +class UpdateEntityDescription(EntityDescription): + """A class that describes update entities.""" + + device_class: UpdateDeviceClass | str | None = None + entity_category: EntityCategory | None = EntityCategory.CONFIG + + +class UpdateEntity(RestoreEntity): + """Representation of an update entity.""" + + entity_description: UpdateEntityDescription + _attr_current_version: str | None = None + _attr_device_class: UpdateDeviceClass | str | None + _attr_in_progress: bool | int = False + _attr_latest_version: str | None = None + _attr_release_summary: str | None = None + _attr_release_url: str | None = None + _attr_state: None = None + _attr_supported_features: int = 0 + _attr_title: str | None = None + __skipped_version: str | None = None + __in_progress: bool = False + + @property + def current_version(self) -> str | None: + """Version currently in use.""" + return self._attr_current_version + + @property + def device_class(self) -> UpdateDeviceClass | str | None: + """Return the class of this entity.""" + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class + return None + + @property + def entity_category(self) -> EntityCategory | str | None: + """Return the category of the entity, if any.""" + if hasattr(self, "_attr_entity_category"): + return self._attr_entity_category + if hasattr(self, "entity_description"): + return self.entity_description.entity_category + if self.supported_features & UpdateEntityFeature.INSTALL: + return EntityCategory.CONFIG + return EntityCategory.DIAGNOSTIC + + @property + def in_progress(self) -> bool | int | None: + """Update installation progress. + + Needs UpdateEntityFeature.PROGRESS flag to be set for it to be used. + + Can either return a boolean (True if in progress, False if not) + or an integer to indicate the progress in from 0 to 100%. + """ + return self._attr_in_progress + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + return self._attr_latest_version + + @property + def release_summary(self) -> str | None: + """Summary of the release notes or changelog. + + This is not suitable for long changelogs, but merely suitable + for a short excerpt update description of max 255 characters. + """ + return self._attr_release_summary + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + return self._attr_release_url + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._attr_supported_features + + @property + def title(self) -> str | None: + """Title of the software. + + This helps to differentiate between the device or entity name + versus the title of the software installed. + """ + return self._attr_title + + @final + async def async_skip(self) -> None: + """Skip the current offered version to update.""" + if (latest_version := self.latest_version) is None: + raise HomeAssistantError(f"Cannot skip an unknown version for {self.name}") + if self.current_version == latest_version: + raise HomeAssistantError(f"No update available to skip for {self.name}") + self.__skipped_version = latest_version + self.async_write_ha_state() + + async def async_install( + self, + version: str | None = None, + backup: bool | None = None, + **kwargs: Any, + ) -> None: + """Install an update. + + Version can be specified to install a specific version. When `None`, the + latest version needs to be installed. + + The backup parameter indicates a backup should be taken before + installing the update. + """ + await self.hass.async_add_executor_job(self.install, version, backup) + + def install( + self, + version: str | None = None, + backup: bool | None = None, + **kwargs: Any, + ) -> None: + """Install an update. + + Version can be specified to install a specific version. When `None`, the + latest version needs to be installed. + + The backup parameter indicates a backup should be taken before + installing the update. + """ + raise NotImplementedError() + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if (current_version := self.current_version) is None or ( + latest_version := self.latest_version + ) is None: + return None + + if latest_version not in (current_version, self.__skipped_version): + return STATE_ON + return STATE_OFF + + @final + @property + def state_attributes(self) -> dict[str, Any] | None: + """Return state attributes.""" + if (release_summary := self.release_summary) is not None: + release_summary = release_summary[:255] + + # If entity supports progress, return the in_progress value. + # Otherwise, we use the internal progress value. + if self.supported_features & UpdateEntityFeature.PROGRESS: + in_progress = self.in_progress + else: + in_progress = self.__in_progress + + # Clear skipped version in case it matches the current version or + # the latest version diverged. + if ( + self.__skipped_version == self.current_version + or self.__skipped_version != self.latest_version + ): + self.__skipped_version = None + + return { + ATTR_CURRENT_VERSION: self.current_version, + ATTR_IN_PROGRESS: in_progress, + ATTR_LATEST_VERSION: self.latest_version, + ATTR_RELEASE_SUMMARY: release_summary, + ATTR_RELEASE_URL: self.release_url, + ATTR_SKIPPED_VERSION: self.__skipped_version, + ATTR_TITLE: self.title, + } + + @final + async def async_install_with_progress( + self, + version: str | None = None, + backup: bool | None = None, + ) -> None: + """Install update and handle progress if needed. + + Handles setting the in_progress state in case the entity doesn't + support it natively. + """ + if not self.supported_features & UpdateEntityFeature.PROGRESS: + self.__in_progress = True + self.async_write_ha_state() + + try: + await self.async_install(version, backup) + finally: + # No matter what happens, we always stop progress in the end + self._attr_in_progress = False + self.__in_progress = False + self.async_write_ha_state() + + async def async_internal_added_to_hass(self) -> None: + """Call when the update entity is added to hass. + + It is used to restore the skipped version, if any. + """ + await super().async_internal_added_to_hass() + state = await self.async_get_last_state() + if state is not None and state.attributes.get(ATTR_SKIPPED_VERSION) is not None: + self.__skipped_version = state.attributes[ATTR_SKIPPED_VERSION] diff --git a/homeassistant/components/update/const.py b/homeassistant/components/update/const.py new file mode 100644 index 0000000000000..2ddf08a20ff22 --- /dev/null +++ b/homeassistant/components/update/const.py @@ -0,0 +1,30 @@ +"""Constants for the update component.""" +from __future__ import annotations + +from enum import IntEnum +from typing import Final + +DOMAIN: Final = "update" + + +class UpdateEntityFeature(IntEnum): + """Supported features of the update entity.""" + + INSTALL = 1 + SPECIFIC_VERSION = 2 + PROGRESS = 4 + BACKUP = 8 + + +SERVICE_INSTALL: Final = "install" +SERVICE_SKIP: Final = "skip" + +ATTR_BACKUP: Final = "backup" +ATTR_CURRENT_VERSION: Final = "current_version" +ATTR_IN_PROGRESS: Final = "in_progress" +ATTR_LATEST_VERSION: Final = "latest_version" +ATTR_RELEASE_SUMMARY: Final = "release_summary" +ATTR_RELEASE_URL: Final = "release_url" +ATTR_SKIPPED_VERSION: Final = "skipped_version" +ATTR_TITLE: Final = "title" +ATTR_VERSION: Final = "version" diff --git a/homeassistant/components/update/manifest.json b/homeassistant/components/update/manifest.json new file mode 100644 index 0000000000000..f5fe74c9d021e --- /dev/null +++ b/homeassistant/components/update/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "update", + "name": "Update", + "documentation": "https://www.home-assistant.io/integrations/update", + "codeowners": ["@home-assistant/core"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/update/services.yaml b/homeassistant/components/update/services.yaml new file mode 100644 index 0000000000000..2a3370493cc0f --- /dev/null +++ b/homeassistant/components/update/services.yaml @@ -0,0 +1,27 @@ +install: + name: Install update + description: Install an update for this device or service + target: + entity: + domain: update + fields: + version: + name: Version + description: Version to install, if omitted, the latest version will be installed. + required: false + example: "1.0.0" + selector: + text: + backup: + name: Backup + description: Backup before installing the update, if supported by the integration. + required: false + selector: + boolean: + +skip: + name: Skip update + description: Mark currently available update as skipped. + target: + entity: + domain: update diff --git a/homeassistant/components/update/significant_change.py b/homeassistant/components/update/significant_change.py new file mode 100644 index 0000000000000..400734f2e439f --- /dev/null +++ b/homeassistant/components/update/significant_change.py @@ -0,0 +1,30 @@ +"""Helper to test significant update state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback + +from .const import ATTR_CURRENT_VERSION, ATTR_LATEST_VERSION + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + if old_attrs.get(ATTR_CURRENT_VERSION) != new_attrs.get(ATTR_CURRENT_VERSION): + return True + + if old_attrs.get(ATTR_LATEST_VERSION) != new_attrs.get(ATTR_LATEST_VERSION): + return True + + return False diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json new file mode 100644 index 0000000000000..b079c9ec8b6cd --- /dev/null +++ b/homeassistant/components/update/strings.json @@ -0,0 +1,3 @@ +{ + "title": "Update" +} diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index c7988fe98c9bb..e15c90c7079b9 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -235,10 +235,9 @@ async def _async_update_data(self) -> Mapping[str, Any]: } -class UpnpEntity(CoordinatorEntity): +class UpnpEntity(CoordinatorEntity[UpnpDataUpdateCoordinator]): """Base class for UPnP/IGD entities.""" - coordinator: UpnpDataUpdateCoordinator entity_description: UpnpSensorEntityDescription | UpnpBinarySensorEntityDescription def __init__( diff --git a/homeassistant/components/uptimerobot/entity.py b/homeassistant/components/uptimerobot/entity.py index 6f7c616b7a489..7991525c2a036 100644 --- a/homeassistant/components/uptimerobot/entity.py +++ b/homeassistant/components/uptimerobot/entity.py @@ -11,11 +11,10 @@ from .const import ATTR_TARGET, ATTRIBUTION, DOMAIN -class UptimeRobotEntity(CoordinatorEntity): +class UptimeRobotEntity(CoordinatorEntity[UptimeRobotDataUpdateCoordinator]): """Base UptimeRobot entity.""" _attr_attribution = ATTRIBUTION - coordinator: UptimeRobotDataUpdateCoordinator def __init__( self, diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 525b4f3b43c36..6cd6cc46933b4 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -5,18 +5,16 @@ from croniter import croniter import voluptuous as vol +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from .const import ( - ATTR_TARIFF, CONF_CRON_PATTERN, CONF_METER, CONF_METER_DELTA_VALUES, @@ -27,22 +25,15 @@ CONF_TARIFF, CONF_TARIFF_ENTITY, CONF_TARIFFS, + DATA_LEGACY_COMPONENT, DATA_TARIFF_SENSORS, DATA_UTILITY, DOMAIN, METER_TYPES, - SERVICE_RESET, - SERVICE_SELECT_NEXT_TARIFF, - SERVICE_SELECT_TARIFF, - SIGNAL_RESET_METER, ) _LOGGER = logging.getLogger(__name__) -TARIFF_ICON = "mdi:clock-outline" - -ATTR_TARIFFS = "tariffs" - DEFAULT_OFFSET = timedelta(hours=0) @@ -105,9 +96,9 @@ def max_28_days(config): async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an Utility Meter.""" - component = EntityComponent(_LOGGER, DOMAIN, hass) + hass.data[DATA_LEGACY_COMPONENT] = EntityComponent(_LOGGER, DOMAIN, hass) + hass.data[DATA_UTILITY] = {} - register_services = False for meter, conf in config[DOMAIN].items(): _LOGGER.debug("Setup %s.%s", DOMAIN, meter) @@ -129,11 +120,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) else: # create tariff selection - await component.async_add_entities( - [TariffSelect(meter, list(conf[CONF_TARIFFS]))] + hass.async_create_task( + discovery.async_load_platform( + hass, + SELECT_DOMAIN, + DOMAIN, + {CONF_METER: meter, CONF_TARIFFS: conf[CONF_TARIFFS]}, + config, + ) ) + hass.data[DATA_UTILITY][meter][CONF_TARIFF_ENTITY] = "{}.{}".format( - DOMAIN, meter + SELECT_DOMAIN, meter ) # add one meter for each tariff @@ -151,89 +149,5 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, SENSOR_DOMAIN, DOMAIN, tariff_confs, config ) ) - register_services = True - - if register_services: - component.async_register_entity_service(SERVICE_RESET, {}, "async_reset_meters") - - component.async_register_entity_service( - SERVICE_SELECT_TARIFF, - {vol.Required(ATTR_TARIFF): cv.string}, - "async_select_tariff", - ) - - component.async_register_entity_service( - SERVICE_SELECT_NEXT_TARIFF, {}, "async_next_tariff" - ) return True - - -class TariffSelect(RestoreEntity): - """Representation of a Tariff selector.""" - - def __init__(self, name, tariffs): - """Initialize a tariff selector.""" - self._name = name - self._current_tariff = None - self._tariffs = tariffs - self._icon = TARIFF_ICON - - async def async_added_to_hass(self): - """Run when entity about to be added.""" - await super().async_added_to_hass() - - state = await self.async_get_last_state() - if not state or state.state not in self._tariffs: - self._current_tariff = self._tariffs[0] - else: - self._current_tariff = state.state - - @property - def should_poll(self): - """If entity should be polled.""" - return False - - @property - def name(self): - """Return the name of the select input.""" - return self._name - - @property - def icon(self): - """Return the icon to be used for this entity.""" - return self._icon - - @property - def state(self): - """Return the state of the component.""" - return self._current_tariff - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_TARIFFS: self._tariffs} - - async def async_reset_meters(self): - """Reset all sensors of this meter.""" - _LOGGER.debug("reset meter %s", self.entity_id) - async_dispatcher_send(self.hass, SIGNAL_RESET_METER, self.entity_id) - - async def async_select_tariff(self, tariff): - """Select new option.""" - if tariff not in self._tariffs: - _LOGGER.warning( - "Invalid tariff: %s (possible tariffs: %s)", - tariff, - ", ".join(self._tariffs), - ) - return - self._current_tariff = tariff - self.async_write_ha_state() - - async def async_next_tariff(self): - """Offset current index.""" - current_index = self._tariffs.index(self._current_tariff) - new_index = (current_index + 1) % len(self._tariffs) - self._current_tariff = self._tariffs[new_index] - self.async_write_ha_state() diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index 097496e231dd3..2bac649aace55 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -1,6 +1,8 @@ """Constants for the utility meter component.""" DOMAIN = "utility_meter" +TARIFF_ICON = "mdi:clock-outline" + QUARTER_HOURLY = "quarter-hourly" HOURLY = "hourly" DAILY = "daily" @@ -23,6 +25,7 @@ DATA_UTILITY = "utility_meter_data" DATA_TARIFF_SENSORS = "utility_meter_sensors" +DATA_LEGACY_COMPONENT = "utility_meter_legacy_component" CONF_METER = "meter" CONF_SOURCE_SENSOR = "source" @@ -37,6 +40,7 @@ CONF_CRON_PATTERN = "cron" ATTR_TARIFF = "tariff" +ATTR_TARIFFS = "tariffs" ATTR_VALUE = "value" ATTR_CRON_PATTERN = "cron pattern" diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py new file mode 100644 index 0000000000000..b523d72aba475 --- /dev/null +++ b/homeassistant/components/utility_meter/select.py @@ -0,0 +1,204 @@ +"""Support for tariff selection.""" +from __future__ import annotations + +import logging + +import voluptuous as vol + +from homeassistant.components.select import SelectEntity +from homeassistant.components.select.const import ( + ATTR_OPTION, + ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE +from homeassistant.core import Event, callback, split_entity_id +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import ( + ATTR_TARIFF, + ATTR_TARIFFS, + CONF_METER, + CONF_TARIFFS, + DATA_LEGACY_COMPONENT, + DOMAIN, + SERVICE_RESET, + SERVICE_SELECT_NEXT_TARIFF, + SERVICE_SELECT_TARIFF, + SIGNAL_RESET_METER, + TARIFF_ICON, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, conf, async_add_entities, discovery_info=None): + """Set up the utility meter select.""" + legacy_component = hass.data[DATA_LEGACY_COMPONENT] + async_add_entities( + [ + TariffSelect( + discovery_info[CONF_METER], + discovery_info[CONF_TARIFFS], + legacy_component.async_add_entities, + ) + ] + ) + + async def async_reset_meters(service_call): + """Reset all sensors of a meter.""" + entity_id = service_call.data["entity_id"] + + domain = split_entity_id(entity_id)[0] + if domain == DOMAIN: + for entity in legacy_component.entities: + if entity_id == entity.entity_id: + _LOGGER.debug( + "forward reset meter from %s to %s", + entity_id, + entity.tracked_entity_id, + ) + entity_id = entity.tracked_entity_id + + _LOGGER.debug("reset meter %s", entity_id) + async_dispatcher_send(hass, SIGNAL_RESET_METER, entity_id) + + hass.services.async_register( + DOMAIN, + SERVICE_RESET, + async_reset_meters, + vol.Schema({ATTR_ENTITY_ID: cv.entity_id}), + ) + + legacy_component.async_register_entity_service( + SERVICE_SELECT_TARIFF, + {vol.Required(ATTR_TARIFF): cv.string}, + "async_select_tariff", + ) + + legacy_component.async_register_entity_service( + SERVICE_SELECT_NEXT_TARIFF, {}, "async_next_tariff" + ) + + +class TariffSelect(SelectEntity, RestoreEntity): + """Representation of a Tariff selector.""" + + def __init__(self, name, tariffs, add_legacy_entities): + """Initialize a tariff selector.""" + self._attr_name = name + self._current_tariff = None + self._tariffs = tariffs + self._attr_icon = TARIFF_ICON + self._attr_should_poll = False + self._add_legacy_entities = add_legacy_entities + + @property + def options(self): + """Return the available tariffs.""" + return self._tariffs + + @property + def current_option(self): + """Return current tariff.""" + return self._current_tariff + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + + await self._add_legacy_entities([LegacyTariffSelect(self.entity_id)]) + + state = await self.async_get_last_state() + if not state or state.state not in self._tariffs: + self._current_tariff = self._tariffs[0] + else: + self._current_tariff = state.state + + async def async_select_option(self, option: str) -> None: + """Select new tariff (option).""" + self._current_tariff = option + self.async_write_ha_state() + + +class LegacyTariffSelect(Entity): + """Backwards compatibility for deprecated utility_meter select entity.""" + + def __init__(self, tracked_entity_id): + """Initialize the entity.""" + self._attr_icon = TARIFF_ICON + # Set name to influence enity_id + self._attr_name = split_entity_id(tracked_entity_id)[1] + self.tracked_entity_id = tracked_entity_id + + @callback + def async_state_changed_listener(self, event: Event | None = None) -> None: + """Handle child updates.""" + if ( + state := self.hass.states.get(self.tracked_entity_id) + ) is None or state.state == STATE_UNAVAILABLE: + self._attr_available = False + return + + self._attr_available = True + + self._attr_name = state.attributes.get(ATTR_FRIENDLY_NAME) + self._attr_state = state.state + self._attr_extra_state_attributes = { + ATTR_TARIFFS: state.attributes.get(ATTR_OPTIONS) + } + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + @callback + def _async_state_changed_listener(event: Event | None = None) -> None: + """Handle child updates.""" + self.async_state_changed_listener(event) + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, [self.tracked_entity_id], _async_state_changed_listener + ) + ) + + # Call once on adding + _async_state_changed_listener() + + async def async_select_tariff(self, tariff): + """Select new option.""" + _LOGGER.warning( + "The 'utility_meter.select_tariff' service has been deprecated and will " + "be removed in HA Core 2022.7. Please use 'select.select_option' instead", + ) + await self.hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: self.tracked_entity_id, ATTR_OPTION: tariff}, + blocking=True, + context=self._context, + ) + + async def async_next_tariff(self): + """Offset current index.""" + _LOGGER.warning( + "The 'utility_meter.next_tariff' service has been deprecated and will " + "be removed in HA Core 2022.7. Please use 'select.select_option' instead", + ) + if ( + not self.available + or (state := self.hass.states.get(self.tracked_entity_id)) is None + ): + return + tariffs = state.attributes.get(ATTR_OPTIONS) + current_tariff = state.state + current_index = tariffs.index(current_tariff) + new_index = (current_index + 1) % len(tariffs) + + await self.async_select_tariff(tariffs[new_index]) diff --git a/homeassistant/components/utility_meter/services.yaml b/homeassistant/components/utility_meter/services.yaml index c3f95d221756d..800e001f6fff3 100644 --- a/homeassistant/components/utility_meter/services.yaml +++ b/homeassistant/components/utility_meter/services.yaml @@ -2,10 +2,10 @@ reset: name: Reset - description: Resets the counter of a utility meter. + description: Resets all counters of an utility meter. target: entity: - domain: utility_meter + domain: select next_tariff: name: Next Tariff diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index aeb9e59e28682..23e5cb53f975f 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -131,11 +131,9 @@ def get_next_filter_change_date(self) -> date | None: return next_filter_change_date -class ValloxDataUpdateCoordinator(DataUpdateCoordinator): +class ValloxDataUpdateCoordinator(DataUpdateCoordinator[ValloxState]): """The DataUpdateCoordinator for Vallox.""" - data: ValloxState - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the integration from configuration.yaml (DEPRECATED).""" diff --git a/homeassistant/components/vallox/binary_sensor.py b/homeassistant/components/vallox/binary_sensor.py index e7b25ca2a800c..348bad9715870 100644 --- a/homeassistant/components/vallox/binary_sensor.py +++ b/homeassistant/components/vallox/binary_sensor.py @@ -16,11 +16,12 @@ from .const import DOMAIN -class ValloxBinarySensor(CoordinatorEntity, BinarySensorEntity): +class ValloxBinarySensor( + CoordinatorEntity[ValloxDataUpdateCoordinator], BinarySensorEntity +): """Representation of a Vallox binary sensor.""" entity_description: ValloxBinarySensorEntityDescription - coordinator: ValloxDataUpdateCoordinator def __init__( self, diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 21e89f49d41dc..52535be3a29ab 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -80,11 +80,9 @@ async def async_setup_entry( async_add_entities([device]) -class ValloxFan(CoordinatorEntity, FanEntity): +class ValloxFan(CoordinatorEntity[ValloxDataUpdateCoordinator], FanEntity): """Representation of the fan.""" - coordinator: ValloxDataUpdateCoordinator - def __init__( self, name: str, diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index eece054c82e18..92f0bc32e7612 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -32,11 +32,10 @@ ) -class ValloxSensor(CoordinatorEntity, SensorEntity): +class ValloxSensor(CoordinatorEntity[ValloxDataUpdateCoordinator], SensorEntity): """Representation of a Vallox sensor.""" entity_description: ValloxSensorEntityDescription - coordinator: ValloxDataUpdateCoordinator def __init__( self, diff --git a/homeassistant/components/venstar/__init__.py b/homeassistant/components/venstar/__init__.py index 66a458f210a1a..759908c87e45c 100644 --- a/homeassistant/components/venstar/__init__.py +++ b/homeassistant/components/venstar/__init__.py @@ -126,11 +126,9 @@ async def _async_update_data(self) -> None: return None -class VenstarEntity(CoordinatorEntity): +class VenstarEntity(CoordinatorEntity[VenstarDataUpdateCoordinator]): """Representation of a Venstar entity.""" - coordinator: VenstarDataUpdateCoordinator - def __init__( self, venstar_data_coordinator: VenstarDataUpdateCoordinator, diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py index e9cb2f49842d0..4cc5e8f6cb3cb 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -30,11 +30,11 @@ async def async_setup_entry( async_add_entities([VerisureAlarm(coordinator=hass.data[DOMAIN][entry.entry_id])]) -class VerisureAlarm(CoordinatorEntity, AlarmControlPanelEntity): +class VerisureAlarm( + CoordinatorEntity[VerisureDataUpdateCoordinator], AlarmControlPanelEntity +): """Representation of a Verisure alarm status.""" - coordinator: VerisureDataUpdateCoordinator - _attr_code_format = FORMAT_NUMBER _attr_name = "Verisure Alarm" _attr_supported_features = SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py index 05e0d77845aac..217890b8a01f9 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -33,11 +33,11 @@ async def async_setup_entry( async_add_entities(sensors) -class VerisureDoorWindowSensor(CoordinatorEntity, BinarySensorEntity): +class VerisureDoorWindowSensor( + CoordinatorEntity[VerisureDataUpdateCoordinator], BinarySensorEntity +): """Representation of a Verisure door window sensor.""" - coordinator: VerisureDataUpdateCoordinator - _attr_device_class = BinarySensorDeviceClass.OPENING def __init__( @@ -79,11 +79,11 @@ def available(self) -> bool: ) -class VerisureEthernetStatus(CoordinatorEntity, BinarySensorEntity): +class VerisureEthernetStatus( + CoordinatorEntity[VerisureDataUpdateCoordinator], BinarySensorEntity +): """Representation of a Verisure VBOX internet status.""" - coordinator: VerisureDataUpdateCoordinator - _attr_name = "Verisure Ethernet status" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY _attr_entity_category = EntityCategory.DIAGNOSTIC diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index 787e496202f6c..c753bf2c5dcbe 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -43,11 +43,9 @@ async def async_setup_entry( ) -class VerisureSmartcam(CoordinatorEntity, Camera): +class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera): """Representation of a Verisure camera.""" - coordinator: VerisureDataUpdateCoordinator - def __init__( self, coordinator: VerisureDataUpdateCoordinator, diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 86b232d54fd85..0e28298b2e86b 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -55,11 +55,9 @@ async def async_setup_entry( ) -class VerisureDoorlock(CoordinatorEntity, LockEntity): +class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEntity): """Representation of a Verisure doorlock.""" - coordinator: VerisureDataUpdateCoordinator - def __init__( self, coordinator: VerisureDataUpdateCoordinator, serial_number: str ) -> None: diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py index 9e19e5d865f90..3b8f722c6f70c 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -45,11 +45,11 @@ async def async_setup_entry( async_add_entities(sensors) -class VerisureThermometer(CoordinatorEntity, SensorEntity): +class VerisureThermometer( + CoordinatorEntity[VerisureDataUpdateCoordinator], SensorEntity +): """Representation of a Verisure thermometer.""" - coordinator: VerisureDataUpdateCoordinator - _attr_device_class = SensorDeviceClass.TEMPERATURE _attr_native_unit_of_measurement = TEMP_CELSIUS _attr_state_class = SensorStateClass.MEASUREMENT @@ -100,11 +100,11 @@ def available(self) -> bool: ) -class VerisureHygrometer(CoordinatorEntity, SensorEntity): +class VerisureHygrometer( + CoordinatorEntity[VerisureDataUpdateCoordinator], SensorEntity +): """Representation of a Verisure hygrometer.""" - coordinator: VerisureDataUpdateCoordinator - _attr_device_class = SensorDeviceClass.HUMIDITY _attr_native_unit_of_measurement = PERCENTAGE _attr_state_class = SensorStateClass.MEASUREMENT @@ -155,11 +155,11 @@ def available(self) -> bool: ) -class VerisureMouseDetection(CoordinatorEntity, SensorEntity): +class VerisureMouseDetection( + CoordinatorEntity[VerisureDataUpdateCoordinator], SensorEntity +): """Representation of a Verisure mouse detector.""" - coordinator: VerisureDataUpdateCoordinator - _attr_native_unit_of_measurement = "Mice" def __init__( diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py index 777195d1a5186..5d1fd728f4a65 100644 --- a/homeassistant/components/verisure/switch.py +++ b/homeassistant/components/verisure/switch.py @@ -27,11 +27,9 @@ async def async_setup_entry( ) -class VerisureSmartplug(CoordinatorEntity, SwitchEntity): +class VerisureSmartplug(CoordinatorEntity[VerisureDataUpdateCoordinator], SwitchEntity): """Representation of a Verisure smartplug.""" - coordinator: VerisureDataUpdateCoordinator - def __init__( self, coordinator: VerisureDataUpdateCoordinator, serial_number: str ) -> None: diff --git a/homeassistant/components/version/entity.py b/homeassistant/components/version/entity.py index 1dcdc23fa9fac..d950c6394b8a0 100644 --- a/homeassistant/components/version/entity.py +++ b/homeassistant/components/version/entity.py @@ -8,7 +8,7 @@ from .coordinator import VersionDataUpdateCoordinator -class VersionEntity(CoordinatorEntity): +class VersionEntity(CoordinatorEntity[VersionDataUpdateCoordinator]): """Common entity class for Version integration.""" _attr_device_info = DeviceInfo( @@ -18,8 +18,6 @@ class VersionEntity(CoordinatorEntity): entry_type=DeviceEntryType.SERVICE, ) - coordinator: VersionDataUpdateCoordinator - def __init__( self, coordinator: VersionDataUpdateCoordinator, diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py index ad38f30991b9c..27d0ed8b631e6 100644 --- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py +++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py @@ -92,15 +92,15 @@ def __init__(self, coordinator: YaleDataUpdateCoordinator) -> None: self._attr_name = coordinator.entry.data[CONF_NAME] self._attr_unique_id = coordinator.entry.entry_id - async def async_alarm_disarm(self, code=None) -> None: + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" return await self.async_set_alarm(YALE_STATE_DISARM, code) - async def async_alarm_arm_home(self, code=None) -> None: + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" return await self.async_set_alarm(YALE_STATE_ARM_PARTIAL, code) - async def async_alarm_arm_away(self, code=None) -> None: + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" return await self.async_set_alarm(YALE_STATE_ARM_FULL, code) diff --git a/homeassistant/components/yale_smart_alarm/binary_sensor.py b/homeassistant/components/yale_smart_alarm/binary_sensor.py index 42f8b446abc5c..566dbed8c3302 100644 --- a/homeassistant/components/yale_smart_alarm/binary_sensor.py +++ b/homeassistant/components/yale_smart_alarm/binary_sensor.py @@ -69,7 +69,7 @@ class YaleDoorSensor(YaleEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return self.coordinator.data["sensor_map"][self._attr_unique_id] == "open" + return bool(self.coordinator.data["sensor_map"][self._attr_unique_id] == "open") class YaleProblemSensor(YaleAlarmEntity, BinarySensorEntity): @@ -93,7 +93,7 @@ def __init__( @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - return ( + return bool( self.coordinator.data["status"][self.entity_description.key] != "main.normal" ) diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py index 0ac16d23276db..a97a98a2afb48 100644 --- a/homeassistant/components/yale_smart_alarm/lock.py +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -1,7 +1,7 @@ """Lock for Yale Alarm.""" from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry @@ -47,12 +47,14 @@ def __init__( super().__init__(coordinator, data) self._attr_code_format = f"^\\d{code_format}$" - async def async_unlock(self, **kwargs) -> None: + async def async_unlock(self, **kwargs: Any) -> None: """Send unlock command.""" - code = kwargs.get(ATTR_CODE, self.coordinator.entry.options.get(CONF_CODE)) + code: str | None = kwargs.get( + ATTR_CODE, self.coordinator.entry.options.get(CONF_CODE) + ) return await self.async_set_lock("unlocked", code) - async def async_lock(self, **kwargs) -> None: + async def async_lock(self, **kwargs: Any) -> None: """Send lock command.""" return await self.async_set_lock("locked", None) @@ -88,4 +90,4 @@ async def async_set_lock(self, command: str, code: str | None) -> None: @property def is_locked(self) -> bool | None: """Return true if the lock is locked.""" - return self.coordinator.data["lock_map"][self._attr_unique_id] == "locked" + return bool(self.coordinator.data["lock_map"][self._attr_unique_id] == "locked") diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index feebff87c8b17..ac028597ea82f 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -69,11 +69,13 @@ get_matched_clusters, qr_to_install_code, ) -from .core.typing import ZhaDeviceType, ZhaGatewayType +from .core.typing import ZhaDeviceType if TYPE_CHECKING: from homeassistant.components.websocket_api.connection import ActiveConnection + from .core.gateway import ZHAGateway + _LOGGER = logging.getLogger(__name__) TYPE = "type" @@ -210,9 +212,9 @@ async def websocket_permit_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Permit ZHA zigbee devices.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - duration = msg.get(ATTR_DURATION) - ieee = msg.get(ATTR_IEEE) + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + duration: int = msg[ATTR_DURATION] + ieee: EUI64 | None = msg.get(ATTR_IEEE) async def forward_messages(data): """Forward events to websocket.""" @@ -230,6 +232,8 @@ def async_cleanup() -> None: connection.subscriptions[msg["id"]] = async_cleanup zha_gateway.async_enable_debug_mode() + src_ieee: EUI64 + code: bytes if ATTR_SOURCE_IEEE in msg: src_ieee = msg[ATTR_SOURCE_IEEE] code = msg[ATTR_INSTALL_CODE] @@ -255,10 +259,8 @@ async def websocket_get_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA devices.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] devices = [device.zha_device_info for device in zha_gateway.devices.values()] - connection.send_result(msg[ID], devices) @@ -269,7 +271,7 @@ async def websocket_get_groupable_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA devices that can be grouped.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] devices = [device for device in zha_gateway.devices.values() if device.is_groupable] groupable_devices = [] @@ -309,7 +311,7 @@ async def websocket_get_groups( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA groups.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] groups = [group.group_info for group in zha_gateway.groups.values()] connection.send_result(msg[ID], groups) @@ -326,8 +328,8 @@ async def websocket_get_device( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA devices.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee = msg[ATTR_IEEE] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] device = None if ieee in zha_gateway.devices: device = zha_gateway.devices[ieee].zha_device_info @@ -353,8 +355,8 @@ async def websocket_get_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA group.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id = msg[GROUP_ID] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_id: int = msg[GROUP_ID] group = None if group_id in zha_gateway.groups: @@ -397,10 +399,10 @@ async def websocket_add_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Add a new ZHA group.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_name = msg[GROUP_NAME] - members = msg.get(ATTR_MEMBERS) - group_id = msg.get(GROUP_ID) + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_name: str = msg[GROUP_NAME] + group_id: int | None = msg.get(GROUP_ID) + members: list[GroupMember] | None = msg.get(ATTR_MEMBERS) group = await zha_gateway.async_create_zigpy_group(group_name, members, group_id) connection.send_result(msg[ID], group.group_info) @@ -417,8 +419,8 @@ async def websocket_remove_groups( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Remove the specified ZHA groups.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_ids = msg[GROUP_IDS] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_ids: list[int] = msg[GROUP_IDS] if len(group_ids) > 1: tasks = [] @@ -444,9 +446,9 @@ async def websocket_add_group_members( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Add members to a ZHA group.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id = msg[GROUP_ID] - members = msg[ATTR_MEMBERS] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_id: int = msg[GROUP_ID] + members: list[GroupMember] = msg[ATTR_MEMBERS] zha_group = None if group_id in zha_gateway.groups: @@ -476,9 +478,9 @@ async def websocket_remove_group_members( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Remove members from a ZHA group.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - group_id = msg[GROUP_ID] - members = msg[ATTR_MEMBERS] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + group_id: int = msg[GROUP_ID] + members: list[GroupMember] = msg[ATTR_MEMBERS] zha_group = None if group_id in zha_gateway.groups: @@ -507,8 +509,8 @@ async def websocket_reconfigure_node( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Reconfigure a ZHA nodes entities by its ieee address.""" - zha_gateway: ZhaGatewayType = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee = msg[ATTR_IEEE] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] device: ZhaDeviceType = zha_gateway.get_device(ieee) async def forward_messages(data): @@ -541,21 +543,24 @@ async def websocket_update_topology( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Update the ZHA network topology.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] hass.async_create_task(zha_gateway.application_controller.topology.scan()) @websocket_api.require_admin @websocket_api.websocket_command( - {vol.Required(TYPE): "zha/devices/clusters", vol.Required(ATTR_IEEE): EUI64.convert} + { + vol.Required(TYPE): "zha/devices/clusters", + vol.Required(ATTR_IEEE): EUI64.convert, + } ) @websocket_api.async_response async def websocket_device_clusters( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Return a list of device clusters.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee = msg[ATTR_IEEE] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] zha_device = zha_gateway.get_device(ieee) response_clusters = [] if zha_device is not None: @@ -598,12 +603,12 @@ async def websocket_device_cluster_attributes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Return a list of cluster attributes.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee = msg[ATTR_IEEE] - endpoint_id = msg[ATTR_ENDPOINT_ID] - cluster_id = msg[ATTR_CLUSTER_ID] - cluster_type = msg[ATTR_CLUSTER_TYPE] - cluster_attributes = [] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] + cluster_attributes: list[dict[str, Any]] = [] zha_device = zha_gateway.get_device(ieee) attributes = None if zha_device is not None: @@ -645,11 +650,11 @@ async def websocket_device_cluster_commands( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Return a list of cluster commands.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - cluster_id = msg[ATTR_CLUSTER_ID] - cluster_type = msg[ATTR_CLUSTER_TYPE] - ieee = msg[ATTR_IEEE] - endpoint_id = msg[ATTR_ENDPOINT_ID] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] zha_device = zha_gateway.get_device(ieee) cluster_commands = [] commands = None @@ -707,13 +712,13 @@ async def websocket_read_zigbee_cluster_attributes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Read zigbee attribute for cluster on zha entity.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - ieee = msg[ATTR_IEEE] - endpoint_id = msg[ATTR_ENDPOINT_ID] - cluster_id = msg[ATTR_CLUSTER_ID] - cluster_type = msg[ATTR_CLUSTER_TYPE] - attribute = msg[ATTR_ATTRIBUTE] - manufacturer = msg.get(ATTR_MANUFACTURER) or None + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = msg[ATTR_IEEE] + endpoint_id: int = msg[ATTR_ENDPOINT_ID] + cluster_id: int = msg[ATTR_CLUSTER_ID] + cluster_type: str = msg[ATTR_CLUSTER_TYPE] + attribute: int = msg[ATTR_ATTRIBUTE] + manufacturer: Any | None = msg.get(ATTR_MANUFACTURER) zha_device = zha_gateway.get_device(ieee) if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: manufacturer = zha_device.manufacturer_code @@ -747,15 +752,18 @@ async def websocket_read_zigbee_cluster_attributes( @websocket_api.require_admin @websocket_api.websocket_command( - {vol.Required(TYPE): "zha/devices/bindable", vol.Required(ATTR_IEEE): EUI64.convert} + { + vol.Required(TYPE): "zha/devices/bindable", + vol.Required(ATTR_IEEE): EUI64.convert, + } ) @websocket_api.async_response async def websocket_get_bindable_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Directly bind devices.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee = msg[ATTR_IEEE] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_IEEE] source_device = zha_gateway.get_device(source_ieee) devices = [ @@ -788,9 +796,9 @@ async def websocket_bind_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Directly bind devices.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee = msg[ATTR_SOURCE_IEEE] - target_ieee = msg[ATTR_TARGET_IEEE] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] await async_binding_operation( zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Bind_req ) @@ -816,9 +824,9 @@ async def websocket_unbind_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Remove a direct binding between devices.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee = msg[ATTR_SOURCE_IEEE] - target_ieee = msg[ATTR_TARGET_IEEE] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] await async_binding_operation( zha_gateway, source_ieee, target_ieee, zdo_types.ZDOCmd.Unbind_req ) @@ -862,12 +870,11 @@ async def websocket_bind_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Directly bind a device to a group.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee = msg[ATTR_SOURCE_IEEE] - group_id = msg[GROUP_ID] - bindings = msg[BINDINGS] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + group_id: int = msg[GROUP_ID] + bindings: list[ClusterBinding] = msg[BINDINGS] source_device = zha_gateway.get_device(source_ieee) - await source_device.async_bind_to_group(group_id, bindings) @@ -885,15 +892,20 @@ async def websocket_unbind_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Unbind a device from a group.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - source_ieee = msg[ATTR_SOURCE_IEEE] - group_id = msg[GROUP_ID] - bindings = msg[BINDINGS] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] + group_id: int = msg[GROUP_ID] + bindings: list[ClusterBinding] = msg[BINDINGS] source_device = zha_gateway.get_device(source_ieee) await source_device.async_unbind_from_group(group_id, bindings) -async def async_binding_operation(zha_gateway, source_ieee, target_ieee, operation): +async def async_binding_operation( + zha_gateway: ZHAGateway, + source_ieee: EUI64, + target_ieee: EUI64, + operation: zdo_types.ZDOCmd, +) -> None: """Create or remove a direct zigbee binding between 2 devices.""" source_device = zha_gateway.get_device(source_ieee) @@ -982,7 +994,7 @@ async def websocket_update_zha_configuration( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Update the ZHA configuration.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] options = zha_gateway.config_entry.options data_to_save = {**options, **{CUSTOM_CONFIGURATION: msg["data"]}} @@ -1002,13 +1014,15 @@ async def websocket_update_zha_configuration( @callback def async_load_api(hass: HomeAssistant) -> None: """Set up the web socket API.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] application_controller = zha_gateway.application_controller async def permit(service: ServiceCall) -> None: """Allow devices to join this network.""" - duration = service.data[ATTR_DURATION] - ieee = service.data.get(ATTR_IEEE) + duration: int = service.data[ATTR_DURATION] + ieee: EUI64 | None = service.data.get(ATTR_IEEE) + src_ieee: EUI64 + code: bytes if ATTR_SOURCE_IEEE in service.data: src_ieee = service.data[ATTR_SOURCE_IEEE] code = service.data[ATTR_INSTALL_CODE] @@ -1038,8 +1052,8 @@ async def permit(service: ServiceCall) -> None: async def remove(service: ServiceCall) -> None: """Remove a node from the network.""" - ieee = service.data[ATTR_IEEE] - zha_gateway: ZhaGatewayType = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee: EUI64 = service.data[ATTR_IEEE] zha_device: ZhaDeviceType = zha_gateway.get_device(ieee) if zha_device is not None and ( zha_device.is_coordinator @@ -1056,13 +1070,13 @@ async def remove(service: ServiceCall) -> None: async def set_zigbee_cluster_attributes(service: ServiceCall) -> None: """Set zigbee attribute for cluster on zha entity.""" - ieee = service.data.get(ATTR_IEEE) - endpoint_id = service.data.get(ATTR_ENDPOINT_ID) - cluster_id = service.data.get(ATTR_CLUSTER_ID) - cluster_type = service.data.get(ATTR_CLUSTER_TYPE) - attribute = service.data.get(ATTR_ATTRIBUTE) - value = service.data.get(ATTR_VALUE) - manufacturer = service.data.get(ATTR_MANUFACTURER) or None + ieee: EUI64 = service.data[ATTR_IEEE] + endpoint_id: int = service.data[ATTR_ENDPOINT_ID] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + cluster_type: str = service.data[ATTR_CLUSTER_TYPE] + attribute: int | str = service.data[ATTR_ATTRIBUTE] + value: int | bool | str = service.data[ATTR_VALUE] + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) zha_device = zha_gateway.get_device(ieee) if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: manufacturer = zha_device.manufacturer_code @@ -1104,14 +1118,14 @@ async def set_zigbee_cluster_attributes(service: ServiceCall) -> None: async def issue_zigbee_cluster_command(service: ServiceCall) -> None: """Issue command on zigbee cluster on zha entity.""" - ieee = service.data.get(ATTR_IEEE) - endpoint_id = service.data.get(ATTR_ENDPOINT_ID) - cluster_id = service.data.get(ATTR_CLUSTER_ID) - cluster_type = service.data.get(ATTR_CLUSTER_TYPE) - command = service.data.get(ATTR_COMMAND) - command_type = service.data.get(ATTR_COMMAND_TYPE) - args = service.data.get(ATTR_ARGS) - manufacturer = service.data.get(ATTR_MANUFACTURER) or None + ieee: EUI64 = service.data[ATTR_IEEE] + endpoint_id: int = service.data[ATTR_ENDPOINT_ID] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + cluster_type: str = service.data[ATTR_CLUSTER_TYPE] + command: int = service.data[ATTR_COMMAND] + command_type: str = service.data[ATTR_COMMAND_TYPE] + args: list = service.data[ATTR_ARGS] + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) zha_device = zha_gateway.get_device(ieee) if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: manufacturer = zha_device.manufacturer_code @@ -1156,11 +1170,11 @@ async def issue_zigbee_cluster_command(service: ServiceCall) -> None: async def issue_zigbee_group_command(service: ServiceCall) -> None: """Issue command on zigbee cluster on a zigbee group.""" - group_id = service.data.get(ATTR_GROUP) - cluster_id = service.data.get(ATTR_CLUSTER_ID) - command = service.data.get(ATTR_COMMAND) - args = service.data.get(ATTR_ARGS) - manufacturer = service.data.get(ATTR_MANUFACTURER) or None + group_id: int = service.data[ATTR_GROUP] + cluster_id: int = service.data[ATTR_CLUSTER_ID] + command: int = service.data[ATTR_COMMAND] + args: list = service.data[ATTR_ARGS] + manufacturer: int | None = service.data.get(ATTR_MANUFACTURER) group = zha_gateway.get_group(group_id) if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None: _LOGGER.error("Missing manufacturer attribute for cluster: %d", cluster_id) @@ -1203,10 +1217,10 @@ def _get_ias_wd_channel(zha_device): async def warning_device_squawk(service: ServiceCall) -> None: """Issue the squawk command for an IAS warning device.""" - ieee = service.data[ATTR_IEEE] - mode = service.data.get(ATTR_WARNING_DEVICE_MODE) - strobe = service.data.get(ATTR_WARNING_DEVICE_STROBE) - level = service.data.get(ATTR_LEVEL) + ieee: EUI64 = service.data[ATTR_IEEE] + mode: int = service.data[ATTR_WARNING_DEVICE_MODE] + strobe: int = service.data[ATTR_WARNING_DEVICE_STROBE] + level: int = service.data[ATTR_LEVEL] if (zha_device := zha_gateway.get_device(ieee)) is not None: if channel := _get_ias_wd_channel(zha_device): diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 26323793e1346..9f7523d41f078 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -4,6 +4,7 @@ from collections import Counter from collections.abc import Callable import logging +from typing import TYPE_CHECKING from homeassistant import const as ha_const from homeassistant.core import HomeAssistant, callback @@ -11,9 +12,11 @@ async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_entries_for_device +from homeassistant.helpers.typing import ConfigType -from . import const as zha_const, registries as zha_regs, typing as zha_typing +from . import const as zha_const, registries as zha_regs from .. import ( # noqa: F401 pylint: disable=unused-import, alarm_control_panel, binary_sensor, @@ -32,16 +35,23 @@ ) from .channels import base +if TYPE_CHECKING: + from ..entity import ZhaEntity + from .channels import ChannelPool + from .device import ZHADevice + from .gateway import ZHAGateway + from .group import ZHAGroup + _LOGGER = logging.getLogger(__name__) @callback async def async_add_entities( - _async_add_entities: Callable, + _async_add_entities: AddEntitiesCallback, entities: list[ tuple[ - zha_typing.ZhaEntityType, - tuple[str, zha_typing.ZhaDeviceType, list[zha_typing.ChannelType]], + type[ZhaEntity], + tuple[str, ZHADevice, list[base.ZigbeeChannel]], ] ], update_before_add: bool = True, @@ -50,20 +60,20 @@ async def async_add_entities( if not entities: return to_add = [ent_cls.create_entity(*args) for ent_cls, args in entities] - to_add = [entity for entity in to_add if entity is not None] - _async_add_entities(to_add, update_before_add=update_before_add) + entities_to_add = [entity for entity in to_add if entity is not None] + _async_add_entities(entities_to_add, update_before_add=update_before_add) entities.clear() class ProbeEndpoint: """All discovered channels and entities of an endpoint.""" - def __init__(self): + def __init__(self) -> None: """Initialize instance.""" - self._device_configs = {} + self._device_configs: ConfigType = {} @callback - def discover_entities(self, channel_pool: zha_typing.ChannelPoolType) -> None: + def discover_entities(self, channel_pool: ChannelPool) -> None: """Process an endpoint on a zigpy device.""" self.discover_by_device_type(channel_pool) self.discover_multi_entities(channel_pool) @@ -71,12 +81,14 @@ def discover_entities(self, channel_pool: zha_typing.ChannelPoolType) -> None: zha_regs.ZHA_ENTITIES.clean_up() @callback - def discover_by_device_type(self, channel_pool: zha_typing.ChannelPoolType) -> None: + def discover_by_device_type(self, channel_pool: ChannelPool) -> None: """Process an endpoint on a zigpy device.""" unique_id = channel_pool.unique_id - component = self._device_configs.get(unique_id, {}).get(ha_const.CONF_TYPE) + component: str | None = self._device_configs.get(unique_id, {}).get( + ha_const.CONF_TYPE + ) if component is None: ep_profile_id = channel_pool.endpoint.profile_id ep_device_type = channel_pool.endpoint.device_type @@ -93,7 +105,7 @@ def discover_by_device_type(self, channel_pool: zha_typing.ChannelPoolType) -> N channel_pool.async_new_entity(component, entity_class, unique_id, claimed) @callback - def discover_by_cluster_id(self, channel_pool: zha_typing.ChannelPoolType) -> None: + def discover_by_cluster_id(self, channel_pool: ChannelPool) -> None: """Process an endpoint on a zigpy device.""" items = zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS.items() @@ -125,8 +137,8 @@ def discover_by_cluster_id(self, channel_pool: zha_typing.ChannelPoolType) -> No @staticmethod def probe_single_cluster( component: str, - channel: zha_typing.ChannelType, - ep_channels: zha_typing.ChannelPoolType, + channel: base.ZigbeeChannel, + ep_channels: ChannelPool, ) -> None: """Probe specified cluster for specific component.""" if component is None or component not in zha_const.PLATFORMS: @@ -142,9 +154,7 @@ def probe_single_cluster( ep_channels.claim_channels(claimed) ep_channels.async_new_entity(component, entity_class, unique_id, claimed) - def handle_on_off_output_cluster_exception( - self, ep_channels: zha_typing.ChannelPoolType - ) -> None: + def handle_on_off_output_cluster_exception(self, ep_channels: ChannelPool) -> None: """Process output clusters of the endpoint.""" profile_id = ep_channels.endpoint.profile_id @@ -167,7 +177,7 @@ def handle_on_off_output_cluster_exception( @staticmethod @callback - def discover_multi_entities(channel_pool: zha_typing.ChannelPoolType) -> None: + def discover_multi_entities(channel_pool: ChannelPool) -> None: """Process an endpoint on and discover multiple entities.""" ep_profile_id = channel_pool.endpoint.profile_id @@ -209,7 +219,9 @@ def discover_multi_entities(channel_pool: zha_typing.ChannelPoolType) -> None: def initialize(self, hass: HomeAssistant) -> None: """Update device overrides config.""" - zha_config = hass.data[zha_const.DATA_ZHA].get(zha_const.DATA_ZHA_CONFIG, {}) + zha_config: ConfigType = hass.data[zha_const.DATA_ZHA].get( + zha_const.DATA_ZHA_CONFIG, {} + ) if overrides := zha_config.get(zha_const.CONF_DEVICE_CONFIG): self._device_configs.update(overrides) @@ -217,10 +229,11 @@ def initialize(self, hass: HomeAssistant) -> None: class GroupProbe: """Determine the appropriate component for a group.""" - def __init__(self): + _hass: HomeAssistant + + def __init__(self) -> None: """Initialize instance.""" - self._hass = None - self._unsubs = [] + self._unsubs: list[Callable[[], None]] = [] def initialize(self, hass: HomeAssistant) -> None: """Initialize the group probe.""" @@ -231,7 +244,7 @@ def initialize(self, hass: HomeAssistant) -> None: ) ) - def cleanup(self): + def cleanup(self) -> None: """Clean up on when zha shuts down.""" for unsub in self._unsubs[:]: unsub() @@ -240,13 +253,15 @@ def cleanup(self): @callback def _reprobe_group(self, group_id: int) -> None: """Reprobe a group for entities after its members change.""" - zha_gateway = self._hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] + zha_gateway: ZHAGateway = self._hass.data[zha_const.DATA_ZHA][ + zha_const.DATA_ZHA_GATEWAY + ] if (zha_group := zha_gateway.groups.get(group_id)) is None: return self.discover_group_entities(zha_group) @callback - def discover_group_entities(self, group: zha_typing.ZhaGroupType) -> None: + def discover_group_entities(self, group: ZHAGroup) -> None: """Process a group and create any entities that are needed.""" # only create a group entity if there are 2 or more members in a group if len(group.members) < 2: @@ -262,7 +277,9 @@ def discover_group_entities(self, group: zha_typing.ZhaGroupType) -> None: if not entity_domains: return - zha_gateway = self._hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] + zha_gateway: ZHAGateway = self._hass.data[zha_const.DATA_ZHA][ + zha_const.DATA_ZHA_GATEWAY + ] for domain in entity_domains: entity_class = zha_regs.ZHA_ENTITIES.get_group_entity(domain) if entity_class is None: @@ -281,12 +298,12 @@ def discover_group_entities(self, group: zha_typing.ZhaGroupType) -> None: async_dispatcher_send(self._hass, zha_const.SIGNAL_ADD_ENTITIES) @staticmethod - def determine_entity_domains( - hass: HomeAssistant, group: zha_typing.ZhaGroupType - ) -> list[str]: + def determine_entity_domains(hass: HomeAssistant, group: ZHAGroup) -> list[str]: """Determine the entity domains for this group.""" entity_domains: list[str] = [] - zha_gateway = hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] + zha_gateway: ZHAGateway = hass.data[zha_const.DATA_ZHA][ + zha_const.DATA_ZHA_GATEWAY + ] all_domain_occurrences = [] for member in group.members: if member.device.is_coordinator: diff --git a/homeassistant/components/zha/core/group.py b/homeassistant/components/zha/core/group.py index c8970b2d3939c..16202291860e6 100644 --- a/homeassistant/components/zha/core/group.py +++ b/homeassistant/components/zha/core/group.py @@ -4,21 +4,20 @@ import asyncio import collections import logging -from typing import Any +from typing import TYPE_CHECKING, Any +import zigpy.endpoint import zigpy.exceptions +import zigpy.group from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import async_entries_for_device from .helpers import LogMixin -from .typing import ( - ZhaDeviceType, - ZhaGatewayType, - ZhaGroupType, - ZigpyEndpointType, - ZigpyGroupType, -) + +if TYPE_CHECKING: + from .device import ZHADevice + from .gateway import ZHAGateway _LOGGER = logging.getLogger(__name__) @@ -32,15 +31,15 @@ class ZHAGroupMember(LogMixin): """Composite object that represents a device endpoint in a Zigbee group.""" def __init__( - self, zha_group: ZhaGroupType, zha_device: ZhaDeviceType, endpoint_id: int + self, zha_group: ZHAGroup, zha_device: ZHADevice, endpoint_id: int ) -> None: """Initialize the group member.""" - self._zha_group: ZhaGroupType = zha_group - self._zha_device: ZhaDeviceType = zha_device - self._endpoint_id: int = endpoint_id + self._zha_group = zha_group + self._zha_device = zha_device + self._endpoint_id = endpoint_id @property - def group(self) -> ZhaGroupType: + def group(self) -> ZHAGroup: """Return the group this member belongs to.""" return self._zha_group @@ -50,12 +49,12 @@ def endpoint_id(self) -> int: return self._endpoint_id @property - def endpoint(self) -> ZigpyEndpointType: + def endpoint(self) -> zigpy.endpoint.Endpoint: """Return the endpoint for this group member.""" return self._zha_device.device.endpoints.get(self.endpoint_id) @property - def device(self) -> ZhaDeviceType: + def device(self) -> ZHADevice: """Return the zha device for this group member.""" return self._zha_device @@ -101,7 +100,7 @@ async def async_remove_from_group(self) -> None: str(ex), ) - def log(self, level: int, msg: str, *args) -> None: + def log(self, level: int, msg: str, *args: Any) -> None: """Log a message.""" msg = f"[%s](%s): {msg}" args = (f"0x{self._zha_group.group_id:04x}", self.endpoint_id) + args @@ -114,13 +113,13 @@ class ZHAGroup(LogMixin): def __init__( self, hass: HomeAssistant, - zha_gateway: ZhaGatewayType, - zigpy_group: ZigpyGroupType, + zha_gateway: ZHAGateway, + zigpy_group: zigpy.group.Group, ) -> None: """Initialize the group.""" - self.hass: HomeAssistant = hass - self._zigpy_group: ZigpyGroupType = zigpy_group - self._zha_gateway: ZhaGatewayType = zha_gateway + self.hass = hass + self._zha_gateway = zha_gateway + self._zigpy_group = zigpy_group @property def name(self) -> str: @@ -133,7 +132,7 @@ def group_id(self) -> int: return self._zigpy_group.group_id @property - def endpoint(self) -> ZigpyEndpointType: + def endpoint(self) -> zigpy.endpoint.Endpoint: """Return the endpoint for this group.""" return self._zigpy_group.endpoint @@ -192,7 +191,7 @@ def member_entity_ids(self) -> list[str]: all_entity_ids.append(entity_reference["entity_id"]) return all_entity_ids - def get_domain_entity_ids(self, domain) -> list[str]: + def get_domain_entity_ids(self, domain: str) -> list[str]: """Return entity ids from the entity domain for this group.""" domain_entity_ids: list[str] = [] for member in self.members: @@ -217,7 +216,7 @@ def group_info(self) -> dict[str, Any]: group_info["members"] = [member.member_info for member in self.members] return group_info - def log(self, level: int, msg: str, *args): + def log(self, level: int, msg: str, *args: Any) -> None: """Log a message.""" msg = f"[%s](%s): {msg}" args = (self.name, self.group_id) + args diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index 8b59c38d405b5..c9e45f096853d 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -5,6 +5,7 @@ from zwave_js_server.dump import dump_msgs from zwave_js_server.model.node import NodeDataType +from homeassistant.components.diagnostics.util import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant @@ -14,6 +15,8 @@ from .const import DATA_CLIENT, DOMAIN from .helpers import get_home_and_node_id_from_device_entry +TO_REDACT = {"homeId", "location"} + async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry @@ -22,7 +25,7 @@ async def async_get_config_entry_diagnostics( msgs: list[dict] = await dump_msgs( config_entry.data[CONF_URL], async_get_clientsession(hass) ) - return msgs + return async_redact_data(msgs, TO_REDACT) async def async_get_device_diagnostics( @@ -42,5 +45,5 @@ async def async_get_device_diagnostics( "minSchemaVersion": client.version.min_schema_version, "maxSchemaVersion": client.version.max_schema_version, }, - "state": node.data, + "state": async_redact_data(node.data, TO_REDACT), } diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index e580833da9d2c..7d9c235c00a1b 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -49,10 +49,11 @@ from .const import LOGGER from .discovery_data_template import ( BaseDiscoverySchemaDataTemplate, - ConfigurableFanSpeedDataTemplate, + ConfigurableFanValueMappingDataTemplate, CoverTiltDataTemplate, DynamicCurrentTempClimateDataTemplate, - FixedFanSpeedDataTemplate, + FanValueMapping, + FixedFanValueMappingDataTemplate, NumericSensorDataTemplate, ZwaveValueID, ) @@ -239,25 +240,25 @@ def get_config_parameter_discovery_schema( # GE/Jasco - In-Wall Smart Fan Control - 12730 / ZW4002 ZWaveDiscoverySchema( platform="fan", - hint="configured_fan_speed", + hint="has_fan_value_mapping", manufacturer_id={0x0063}, product_id={0x3034}, product_type={0x4944}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, - data_template=FixedFanSpeedDataTemplate( - speeds=[33, 67, 99], + data_template=FixedFanValueMappingDataTemplate( + FanValueMapping(speeds=[(1, 33), (34, 67), (68, 99)]), ), ), # GE/Jasco - In-Wall Smart Fan Control - 14287 / ZW4002 ZWaveDiscoverySchema( platform="fan", - hint="configured_fan_speed", + hint="has_fan_value_mapping", manufacturer_id={0x0063}, product_id={0x3131}, product_type={0x4944}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, - data_template=FixedFanSpeedDataTemplate( - speeds=[32, 66, 99], + data_template=FixedFanValueMappingDataTemplate( + FanValueMapping(speeds=[(1, 32), (33, 66), (67, 99)]), ), ), # GE/Jasco - In-Wall Smart Fan Control - 14314 / ZW4002 @@ -280,6 +281,7 @@ def get_config_parameter_discovery_schema( # The fan is endpoint 2, the light is endpoint 1. ZWaveDiscoverySchema( platform="fan", + hint="has_fan_value_mapping", manufacturer_id={0x031E}, product_id={0x0001}, product_type={0x000E}, @@ -289,20 +291,28 @@ def get_config_parameter_discovery_schema( property={CURRENT_VALUE_PROPERTY}, type={"number"}, ), + data_template=FixedFanValueMappingDataTemplate( + FanValueMapping( + presets={1: "breeze"}, speeds=[(2, 33), (34, 66), (67, 99)] + ), + ), ), # HomeSeer HS-FC200+ ZWaveDiscoverySchema( platform="fan", - hint="configured_fan_speed", + hint="has_fan_value_mapping", manufacturer_id={0x000C}, product_id={0x0001}, product_type={0x0203}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, - data_template=ConfigurableFanSpeedDataTemplate( + data_template=ConfigurableFanValueMappingDataTemplate( configuration_option=ZwaveValueID( 5, CommandClass.CONFIGURATION, endpoint=0 ), - configuration_value_to_speeds={0: [33, 66, 99], 1: [24, 49, 74, 99]}, + configuration_value_to_fan_value_mapping={ + 0: FanValueMapping(speeds=[(1, 33), (34, 66), (67, 99)]), + 1: FanValueMapping(speeds=[(1, 24), (25, 49), (50, 74), (75, 99)]), + }, ), ), # Fibaro Shutter Fibaro FGR222 diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 3e7db7cdcd977..bfcc1ed87a551 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -432,43 +432,49 @@ def current_tilt_value(resolved_data: dict[str, Any]) -> ZwaveValue | None: @dataclass -class FanSpeedDataTemplate: - """Mixin to define get_speed_config.""" +class FanValueMapping: + """Data class to represent how a fan's values map to features.""" - def get_speed_config(self, resolved_data: dict[str, Any]) -> list[int] | None: - """ - Get the fan speed configuration for this device. + presets: dict[int, str] = field(default_factory=dict) + speeds: list[tuple[int, int]] = field(default_factory=list) - Values should indicate the highest allowed device setting for each - actual speed, and should be sorted in ascending order. + def __post_init__(self) -> None: + """ + Validate inputs. - Empty lists are not permissible. + These inputs are hardcoded in `discovery.py`, so these checks should + only fail due to developer error. """ - raise NotImplementedError + assert len(self.speeds) > 0, "At least one speed must be specified" + for speed_range in self.speeds: + (low, high) = speed_range + assert high >= low, "Speed range values must be ordered" @dataclass -class ConfigurableFanSpeedValueMix: - """Mixin data class for defining configurable fan speeds.""" +class FanValueMappingDataTemplate: + """Mixin to define `get_fan_value_mapping`.""" - configuration_option: ZwaveValueID - configuration_value_to_speeds: dict[int, list[int]] + def get_fan_value_mapping( + self, resolved_data: dict[str, Any] + ) -> FanValueMapping | None: + """Get the value mappings for this device.""" + raise NotImplementedError - def __post_init__(self) -> None: - """ - Validate inputs. - These inputs are hardcoded in `discovery.py`, so these checks should - only fail due to developer error. - """ - for speeds in self.configuration_value_to_speeds.values(): - assert len(speeds) > 0 - assert sorted(speeds) == speeds +@dataclass +class ConfigurableFanValueMappingValueMix: + """Mixin data class for defining fan properties that change based on a device configuration option.""" + + configuration_option: ZwaveValueID + configuration_value_to_fan_value_mapping: dict[int, FanValueMapping] @dataclass -class ConfigurableFanSpeedDataTemplate( - BaseDiscoverySchemaDataTemplate, FanSpeedDataTemplate, ConfigurableFanSpeedValueMix +class ConfigurableFanValueMappingDataTemplate( + BaseDiscoverySchemaDataTemplate, + FanValueMappingDataTemplate, + ConfigurableFanValueMappingValueMix, ): """ Gets fan speeds based on a configuration value. @@ -476,22 +482,23 @@ class ConfigurableFanSpeedDataTemplate( Example: ZWaveDiscoverySchema( platform="fan", - hint="configured_fan_speed", + hint="has_fan_value_mapping", ... - data_template=ConfigurableFanSpeedDataTemplate( + data_template=ConfigurableFanValueMappingDataTemplate( configuration_option=ZwaveValueID( 5, CommandClass.CONFIGURATION, endpoint=0 ), - configuration_value_to_speeds={0: [32, 65, 99], 1: [24, 49, 74, 99]}, + configuration_value_to_fan_value_mapping={ + 0: FanValueMapping(speeds=[(1,33), (34,66), (67,99)]), + 1: FanValueMapping(speeds=[(1,24), (25,49), (50,74), (75,99)]), + }, ), - ), - `configuration_option` is a reference to the setting that determines how - many speeds are supported. + `configuration_option` is a reference to the setting that determines which + value mapping to use (e.g., 3 speeds or 4 speeds). - `configuration_value_to_speeds` maps the values from `configuration_option` - to a list of speeds. The specified speeds indicate the maximum setting on - the underlying switch for each actual speed. + `configuration_value_to_fan_value_mapping` maps the values from + `configuration_option` to the value mapping object. """ def resolve_data(self, value: ZwaveValue) -> dict[str, ZwaveConfigurationValue]: @@ -507,64 +514,61 @@ def values_to_watch(self, resolved_data: dict[str, Any]) -> Iterable[ZwaveValue] resolved_data["configuration_value"], ] - def get_speed_config( + def get_fan_value_mapping( self, resolved_data: dict[str, ZwaveConfigurationValue] - ) -> list[int] | None: - """Get current speed configuration from resolved data.""" + ) -> FanValueMapping | None: + """Get current fan properties from resolved data.""" zwave_value: ZwaveValue = resolved_data["configuration_value"] + if zwave_value is None: + _LOGGER.warning("Unable to read device configuration value") + return None + if zwave_value.value is None: - _LOGGER.warning("Unable to read fan speed configuration value") + _LOGGER.warning("Fan configuration value is missing") return None - speed_config = self.configuration_value_to_speeds.get(zwave_value.value) - if speed_config is None: - _LOGGER.warning("Unrecognized speed configuration value") + fan_value_mapping = self.configuration_value_to_fan_value_mapping.get( + zwave_value.value + ) + if fan_value_mapping is None: + _LOGGER.warning("Unrecognized fan configuration value") return None - return speed_config + return fan_value_mapping @dataclass -class FixedFanSpeedValueMix: +class FixedFanValueMappingValueMix: """Mixin data class for defining supported fan speeds.""" - speeds: list[int] - - def __post_init__(self) -> None: - """ - Validate inputs. - - These inputs are hardcoded in `discovery.py`, so these checks should - only fail due to developer error. - """ - assert len(self.speeds) > 0 - assert sorted(self.speeds) == self.speeds + fan_value_mapping: FanValueMapping @dataclass -class FixedFanSpeedDataTemplate( - BaseDiscoverySchemaDataTemplate, FanSpeedDataTemplate, FixedFanSpeedValueMix +class FixedFanValueMappingDataTemplate( + BaseDiscoverySchemaDataTemplate, + FanValueMappingDataTemplate, + FixedFanValueMappingValueMix, ): """ - Specifies a fixed set of fan speeds. + Specifies a fixed set of properties for a fan. Example: ZWaveDiscoverySchema( platform="fan", - hint="configured_fan_speed", + hint="has_fan_value_mapping", ... - data_template=FixedFanSpeedDataTemplate( - speeds=[32,65,99] + data_template=FixedFanValueMappingDataTemplate( + config=FanValueMapping( + speeds=[(1, 32), (33, 65), (66, 99)] + ) ), ), - - `speeds` indicates the maximum setting on the underlying fan controller - for each actual speed. """ - def get_speed_config( + def get_fan_value_mapping( self, resolved_data: dict[str, ZwaveConfigurationValue] - ) -> list[int]: - """Get the fan speed configuration for this device.""" - return self.speeds + ) -> FanValueMapping: + """Get the fan properties for this device.""" + return self.fan_value_mapping diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 585a72fc6de5b..21c45bbbf423b 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -17,6 +17,7 @@ SUPPORT_PRESET_MODE, SUPPORT_SET_SPEED, FanEntity, + NotValidPresetModeError, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -31,12 +32,10 @@ from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo -from .discovery_data_template import FanSpeedDataTemplate +from .discovery_data_template import FanValueMapping, FanValueMappingDataTemplate from .entity import ZWaveBaseEntity from .helpers import get_value_of_zwave_value -SUPPORTED_FEATURES = SUPPORT_SET_SPEED - DEFAULT_SPEED_RANGE = (1, 99) # off is not included ATTR_FAN_STATE = "fan_state" @@ -54,8 +53,8 @@ async def async_setup_entry( def async_add_fan(info: ZwaveDiscoveryInfo) -> None: """Add Z-Wave fan.""" entities: list[ZWaveBaseEntity] = [] - if info.platform_hint == "configured_fan_speed": - entities.append(ConfiguredSpeedRangeZwaveFan(config_entry, client, info)) + if info.platform_hint == "has_fan_value_mapping": + entities.append(ValueMappingZwaveFan(config_entry, client, info)) elif info.platform_hint == "thermostat_fan": entities.append(ZwaveThermostatFan(config_entry, client, info)) else: @@ -100,11 +99,13 @@ async def async_turn_on( **kwargs: Any, ) -> None: """Turn the device on.""" - if percentage is None: + if percentage is not None: + await self.async_set_percentage(percentage) + elif preset_mode is not None: + await self.async_set_preset_mode(preset_mode) + else: # Value 255 tells device to return to previous value await self.info.node.async_set_value(self._target_value, 255) - else: - await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" @@ -141,11 +142,11 @@ def speed_count(self) -> int: @property def supported_features(self) -> int: """Flag supported features.""" - return SUPPORTED_FEATURES + return SUPPORT_SET_SPEED -class ConfiguredSpeedRangeZwaveFan(ZwaveFan): - """A Zwave fan with a configured speed range (e.g., 1-24 is low).""" +class ValueMappingZwaveFan(ZwaveFan): + """A Zwave fan with a value mapping data (e.g., 1-24 is low).""" def __init__( self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo @@ -153,7 +154,7 @@ def __init__( """Initialize the fan.""" super().__init__(config_entry, client, info) self.data_template = cast( - FanSpeedDataTemplate, self.info.platform_data_template + FanValueMappingDataTemplate, self.info.platform_data_template ) async def async_set_percentage(self, percentage: int) -> None: @@ -161,10 +162,21 @@ async def async_set_percentage(self, percentage: int) -> None: zwave_speed = self.percentage_to_zwave_speed(percentage) await self.info.node.async_set_value(self._target_value, zwave_speed) + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + for zwave_value, mapped_preset_mode in self.fan_value_mapping.presets.items(): + if preset_mode == mapped_preset_mode: + await self.info.node.async_set_value(self._target_value, zwave_value) + return + + raise NotValidPresetModeError( + f"The preset_mode {preset_mode} is not a valid preset_mode: {self.preset_modes}" + ) + @property def available(self) -> bool: """Return whether the entity is available.""" - return super().available and self.has_speed_configuration + return super().available and self.has_fan_value_mapping @property def percentage(self) -> int | None: @@ -173,6 +185,9 @@ def percentage(self) -> int | None: # guard missing value return None + if self.preset_mode is not None: + return None + return self.zwave_speed_to_percentage(self.info.primary_value.value) @property @@ -184,26 +199,51 @@ def percentage_step(self) -> float: return 100 / self.speed_count @property - def has_speed_configuration(self) -> bool: + def preset_modes(self) -> list[str]: + """Return the available preset modes.""" + if not self.has_fan_value_mapping: + return [] + + return list(self.fan_value_mapping.presets.values()) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self.fan_value_mapping.presets.get(self.info.primary_value.value) + + @property + def has_fan_value_mapping(self) -> bool: """Check if the speed configuration is valid.""" - return self.data_template.get_speed_config(self.info.platform_data) is not None + return ( + self.data_template.get_fan_value_mapping(self.info.platform_data) + is not None + ) @property - def speed_configuration(self) -> list[int]: + def fan_value_mapping(self) -> FanValueMapping: """Return the speed configuration for this fan.""" - speed_configuration = self.data_template.get_speed_config( + fan_value_mapping = self.data_template.get_fan_value_mapping( self.info.platform_data ) # Entity should be unavailable if this isn't set - assert speed_configuration is not None + assert fan_value_mapping is not None - return speed_configuration + return fan_value_mapping @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" - return len(self.speed_configuration) + return len(self.fan_value_mapping.speeds) + + @property + def supported_features(self) -> int: + """Flag supported features.""" + flags = SUPPORT_SET_SPEED + if self.has_fan_value_mapping and self.fan_value_mapping.presets: + flags |= SUPPORT_PRESET_MODE + + return flags def percentage_to_zwave_speed(self, percentage: int) -> int: """Map a percentage to a ZWave speed.""" @@ -212,30 +252,46 @@ def percentage_to_zwave_speed(self, percentage: int) -> int: # Since the percentage steps are computed with rounding, we have to # search to find the appropriate speed. - for speed_limit in self.speed_configuration: - step_percentage = self.zwave_speed_to_percentage(speed_limit) + for speed_range in self.fan_value_mapping.speeds: + (_, max_speed) = speed_range + step_percentage = self.zwave_speed_to_percentage(max_speed) + + # zwave_speed_to_percentage will only return None if + # `self.fan_value_mapping.speeds` doesn't contain the + # specified speed. This can't happen here, because + # the input is coming from the same data structure. + assert step_percentage + if percentage <= step_percentage: - return speed_limit + return max_speed # This shouldn't actually happen; the last entry in - # `self.speed_configuration` should map to 100%. - return self.speed_configuration[-1] + # `self.fan_value_mapping.speeds` should map to 100%. + (_, last_max_speed) = self.fan_value_mapping.speeds[-1] + return last_max_speed + + def zwave_speed_to_percentage(self, zwave_speed: int) -> int | None: + """ + Convert a Zwave speed to a percentage. - def zwave_speed_to_percentage(self, zwave_speed: int) -> int: - """Convert a Zwave speed to a percentage.""" + This method may return None if the device's value mapping doesn't cover + the specified Z-Wave speed. + """ if zwave_speed == 0: return 0 percentage = 0.0 - for speed_limit in self.speed_configuration: + for speed_range in self.fan_value_mapping.speeds: + (min_speed, max_speed) = speed_range percentage += self.percentage_step - if zwave_speed <= speed_limit: - break - - # This choice of rounding function is to provide consistency with how - # the UI handles steps e.g., for a 3-speed fan, you get steps at 33, - # 67, and 100. - return round(percentage) + if min_speed <= zwave_speed <= max_speed: + # This choice of rounding function is to provide consistency with how + # the UI handles steps e.g., for a 3-speed fan, you get steps at 33, + # 67, and 100. + return round(percentage) + + # The specified Z-Wave device value doesn't map to a defined speed. + return None class ZwaveThermostatFan(ZWaveBaseEntity, FanEntity): diff --git a/homeassistant/components/zwave_me/const.py b/homeassistant/components/zwave_me/const.py index 87d740f1eceb3..cbb096c91f3da 100644 --- a/homeassistant/components/zwave_me/const.py +++ b/homeassistant/components/zwave_me/const.py @@ -12,6 +12,7 @@ class ZWaveMePlatform(StrEnum): BINARY_SENSOR = "sensorBinary" BUTTON = "toggleButton" CLIMATE = "thermostat" + COVER = "motor" LOCK = "doorlock" NUMBER = "switchMultilevel" SWITCH = "switchBinary" @@ -25,6 +26,7 @@ class ZWaveMePlatform(StrEnum): Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.COVER, Platform.LIGHT, Platform.LOCK, Platform.NUMBER, diff --git a/homeassistant/components/zwave_me/cover.py b/homeassistant/components/zwave_me/cover.py new file mode 100644 index 0000000000000..0425a99b5681a --- /dev/null +++ b/homeassistant/components/zwave_me/cover.py @@ -0,0 +1,72 @@ +"""Representation of a cover.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.cover import ( + ATTR_POSITION, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + CoverEntity, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import ZWaveMeEntity +from .const import DOMAIN, ZWaveMePlatform + +DEVICE_NAME = ZWaveMePlatform.COVER + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the cover platform.""" + + @callback + def add_new_device(new_device): + controller = hass.data[DOMAIN][config_entry.entry_id] + cover = ZWaveMeCover(controller, new_device) + + async_add_entities( + [ + cover, + ] + ) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, f"ZWAVE_ME_NEW_{DEVICE_NAME.upper()}", add_new_device + ) + ) + + +class ZWaveMeCover(ZWaveMeEntity, CoverEntity): + """Representation of a ZWaveMe Multilevel Cover.""" + + def close_cover(self, **kwargs): + """Close cover.""" + self.controller.zwave_api.send_command(self.device.id, "exact?level=0") + + def open_cover(self, **kwargs): + """Open cover.""" + self.controller.zwave_api.send_command(self.device.id, "exact?level=99") + + def set_cover_position(self, **kwargs: Any) -> None: + """Update the current value.""" + value = kwargs[ATTR_POSITION] + self.controller.zwave_api.send_command( + self.device.id, f"exact?level={str(min(value, 99))}" + ) + + @property + def current_cover_position(self) -> int | None: + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self.device.level + + @property + def supported_features(self) -> int: + """Return the supported features.""" + return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json index 2e6efd9576b61..ed994594ff093 100644 --- a/homeassistant/components/zwave_me/manifest.json +++ b/homeassistant/components/zwave_me/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/zwave_me", "iot_class": "local_push", "requirements": [ - "zwave_me_ws==0.2.2", + "zwave_me_ws==0.2.3", "url-normalize==1.4.1" ], "after_dependencies": ["zeroconf"], diff --git a/homeassistant/const.py b/homeassistant/const.py index 4f2f5ed747882..2c2676d8bb447 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -50,6 +50,7 @@ class Platform(StrEnum): SWITCH = "switch" TTS = "tts" VACUUM = "vacuum" + UPDATE = "update" WATER_HEATER = "water_heater" WEATHER = "weather" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a96acda58244a..6a668b0d666c4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -5,395 +5,398 @@ # fmt: off -FLOWS = [ - "abode", - "accuweather", - "acmeda", - "adax", - "adguard", - "advantage_air", - "aemet", - "agent_dvr", - "airly", - "airnow", - "airthings", - "airtouch4", - "airvisual", - "airzone", - "alarmdecoder", - "almond", - "ambee", - "amberelectric", - "ambiclimate", - "ambient_station", - "androidtv", - "apple_tv", - "arcam_fmj", - "aseko_pool_live", - "asuswrt", - "atag", - "august", - "aurora", - "aurora_abb_powerone", - "aussie_broadband", - "awair", - "axis", - "azure_devops", - "azure_event_hub", - "balboa", - "blebox", - "blink", - "bmw_connected_drive", - "bond", - "bosch_shc", - "braviatv", - "broadlink", - "brother", - "brunt", - "bsblan", - "buienradar", - "canary", - "cast", - "cert_expiry", - "climacell", - "cloudflare", - "co2signal", - "coinbase", - "control4", - "coolmaster", - "coronavirus", - "cpuspeed", - "crownstone", - "daikin", - "deconz", - "denonavr", - "derivative", - "devolo_home_control", - "devolo_home_network", - "dexcom", - "dialogflow", - "directv", - "dlna_dmr", - "dlna_dms", - "dnsip", - "doorbird", - "dsmr", - "dunehd", - "dynalite", - "eafm", - "ecobee", - "econet", - "efergy", - "elgato", - "elkm1", - "elmax", - "emonitor", - "emulated_roku", - "enocean", - "enphase_envoy", - "environment_canada", - "epson", - "esphome", - "evil_genius_labs", - "ezviz", - "faa_delays", - "fireservicerota", - "fivem", - "fjaraskupan", - "flick_electric", - "flipr", - "flo", - "flume", - "flunearyou", - "flux_led", - "forecast_solar", - "forked_daapd", - "foscam", - "freebox", - "freedompro", - "fritz", - "fritzbox", - "fritzbox_callmonitor", - "fronius", - "garages_amsterdam", - "gdacs", - "geofency", - "geonetnz_quakes", - "geonetnz_volcano", - "gios", - "github", - "glances", - "goalzero", - "gogogate2", - "goodwe", - "google", - "google_travel_time", - "gpslogger", - "gree", - "group", - "growatt_server", - "guardian", - "habitica", - "hangouts", - "harmony", - "heos", - "hisense_aehw4a1", - "hive", - "hlk_sw16", - "home_connect", - "home_plus_control", - "homekit", - "homekit_controller", - "homematicip_cloud", - "homewizard", - "honeywell", - "huawei_lte", - "hue", - "huisbaasje", - "hunterdouglas_powerview", - "hvv_departures", - "hyperion", - "ialarm", - "iaqualink", - "icloud", - "ifttt", - "insteon", - "integration", - "intellifire", - "ios", - "iotawatt", - "ipma", - "ipp", - "iqvia", - "islamic_prayer_times", - "iss", - "isy994", - "izone", - "jellyfin", - "juicenet", - "kaleidescape", - "keenetic_ndms2", - "kmtronic", - "knx", - "kodi", - "konnected", - "kostal_plenticore", - "kraken", - "kulersky", - "launch_library", - "life360", - "lifx", - "litejet", - "litterrobot", - "local_ip", - "locative", - "logi_circle", - "lookin", - "luftdaten", - "lutron_caseta", - "lyric", - "mailgun", - "mazda", - "melcloud", - "met", - "met_eireann", - "meteo_france", - "meteoclimatic", - "metoffice", - "mikrotik", - "mill", - "minecraft_server", - "mjpeg", - "mobile_app", - "modem_callerid", - "modern_forms", - "moehlenhoff_alpha2", - "monoprice", - "moon", - "motion_blinds", - "motioneye", - "mqtt", - "mullvad", - "mutesync", - "myq", - "mysensors", - "nam", - "nanoleaf", - "neato", - "nest", - "netatmo", - "netgear", - "nexia", - "nfandroidtv", - "nightscout", - "nina", - "nmap_tracker", - "notion", - "nuheat", - "nuki", - "nut", - "nws", - "nzbget", - "octoprint", - "omnilogic", - "oncue", - "ondilo_ico", - "onewire", - "onvif", - "open_meteo", - "opengarage", - "opentherm_gw", - "openuv", - "openweathermap", - "overkiz", - "ovo_energy", - "owntracks", - "p1_monitor", - "panasonic_viera", - "philips_js", - "pi_hole", - "picnic", - "plaato", - "plex", - "plugwise", - "plum_lightpad", - "point", - "poolsense", - "powerwall", - "profiler", - "progettihwsw", - "prosegur", - "ps4", - "pure_energie", - "pvoutput", - "pvpc_hourly_pricing", - "rachio", - "radio_browser", - "rainforest_eagle", - "rainmachine", - "rdw", - "recollect_waste", - "renault", - "rfxtrx", - "ridwell", - "ring", - "risco", - "rituals_perfume_genie", - "roku", - "roomba", - "roon", - "rpi_power", - "rtsp_to_webrtc", - "ruckus_unleashed", - "samsungtv", - "screenlogic", - "season", - "sense", - "senseme", - "sensibo", - "sentry", - "sharkiq", - "shelly", - "shopping_list", - "sia", - "simplisafe", - "sleepiq", - "sma", - "smappee", - "smart_meter_texas", - "smarthab", - "smartthings", - "smarttub", - "smhi", - "sms", - "solaredge", - "solarlog", - "solax", - "soma", - "somfy", - "somfy_mylink", - "sonarr", - "songpal", - "sonos", - "speedtestdotnet", - "spider", - "spotify", - "squeezebox", - "srp_energy", - "starline", - "steamist", - "stookalert", - "subaru", - "sun", - "surepetcare", - "switch_as_x", - "switchbot", - "switcher_kis", - "syncthing", - "syncthru", - "synology_dsm", - "system_bridge", - "tado", - "tailscale", - "tasmota", - "tellduslive", - "tesla_wall_connector", - "tibber", - "tile", - "tolo", - "toon", - "totalconnect", - "tplink", - "traccar", - "tractive", - "tradfri", - "trafikverket_weatherstation", - "transmission", - "tuya", - "twentemilieu", - "twilio", - "twinkly", - "unifi", - "unifiprotect", - "upb", - "upcloud", - "upnp", - "uptime", - "uptimerobot", - "vallox", - "velbus", - "venstar", - "vera", - "verisure", - "version", - "vesync", - "vicare", - "vilfo", - "vizio", - "vlc_telnet", - "volumio", - "wallbox", - "watttime", - "waze_travel_time", - "webostv", - "wemo", - "whirlpool", - "whois", - "wiffi", - "wilight", - "withings", - "wiz", - "wled", - "wolflink", - "xbox", - "xiaomi_aqara", - "xiaomi_miio", - "yale_smart_alarm", - "yamaha_musiccast", - "yeelight", - "youless", - "zerproc", - "zha", - "zwave_js", - "zwave_me" -] +FLOWS = { + "integration": [ + "abode", + "accuweather", + "acmeda", + "adax", + "adguard", + "advantage_air", + "aemet", + "agent_dvr", + "airly", + "airnow", + "airthings", + "airtouch4", + "airvisual", + "airzone", + "alarmdecoder", + "almond", + "ambee", + "amberelectric", + "ambiclimate", + "ambient_station", + "androidtv", + "apple_tv", + "arcam_fmj", + "aseko_pool_live", + "asuswrt", + "atag", + "august", + "aurora", + "aurora_abb_powerone", + "aussie_broadband", + "awair", + "axis", + "azure_devops", + "azure_event_hub", + "balboa", + "blebox", + "blink", + "bmw_connected_drive", + "bond", + "bosch_shc", + "braviatv", + "broadlink", + "brother", + "brunt", + "bsblan", + "buienradar", + "canary", + "cast", + "cert_expiry", + "cloudflare", + "co2signal", + "coinbase", + "control4", + "coolmaster", + "coronavirus", + "cpuspeed", + "crownstone", + "daikin", + "deconz", + "denonavr", + "devolo_home_control", + "devolo_home_network", + "dexcom", + "dialogflow", + "directv", + "dlna_dmr", + "dlna_dms", + "dnsip", + "doorbird", + "dsmr", + "dunehd", + "dynalite", + "eafm", + "ecobee", + "econet", + "efergy", + "elgato", + "elkm1", + "elmax", + "emonitor", + "emulated_roku", + "enocean", + "enphase_envoy", + "environment_canada", + "epson", + "esphome", + "evil_genius_labs", + "ezviz", + "faa_delays", + "fireservicerota", + "fivem", + "fjaraskupan", + "flick_electric", + "flipr", + "flo", + "flume", + "flunearyou", + "flux_led", + "forecast_solar", + "forked_daapd", + "foscam", + "freebox", + "freedompro", + "fritz", + "fritzbox", + "fritzbox_callmonitor", + "fronius", + "garages_amsterdam", + "gdacs", + "geofency", + "geonetnz_quakes", + "geonetnz_volcano", + "gios", + "github", + "glances", + "goalzero", + "gogogate2", + "goodwe", + "google", + "google_travel_time", + "gpslogger", + "gree", + "group", + "growatt_server", + "guardian", + "habitica", + "hangouts", + "harmony", + "heos", + "hisense_aehw4a1", + "hive", + "hlk_sw16", + "home_connect", + "home_plus_control", + "homekit", + "homekit_controller", + "homematicip_cloud", + "homewizard", + "honeywell", + "huawei_lte", + "hue", + "huisbaasje", + "hunterdouglas_powerview", + "hvv_departures", + "hyperion", + "ialarm", + "iaqualink", + "icloud", + "ifttt", + "insteon", + "integration", + "intellifire", + "ios", + "iotawatt", + "ipma", + "ipp", + "iqvia", + "islamic_prayer_times", + "iss", + "isy994", + "izone", + "jellyfin", + "juicenet", + "kaleidescape", + "keenetic_ndms2", + "kmtronic", + "knx", + "kodi", + "konnected", + "kostal_plenticore", + "kraken", + "kulersky", + "launch_library", + "life360", + "lifx", + "litejet", + "litterrobot", + "local_ip", + "locative", + "logi_circle", + "lookin", + "luftdaten", + "lutron_caseta", + "lyric", + "mailgun", + "mazda", + "melcloud", + "met", + "met_eireann", + "meteo_france", + "meteoclimatic", + "metoffice", + "mikrotik", + "mill", + "minecraft_server", + "mjpeg", + "mobile_app", + "modem_callerid", + "modern_forms", + "moehlenhoff_alpha2", + "monoprice", + "moon", + "motion_blinds", + "motioneye", + "mqtt", + "mullvad", + "mutesync", + "myq", + "mysensors", + "nam", + "nanoleaf", + "neato", + "nest", + "netatmo", + "netgear", + "nexia", + "nfandroidtv", + "nightscout", + "nina", + "nmap_tracker", + "notion", + "nuheat", + "nuki", + "nut", + "nws", + "nzbget", + "octoprint", + "omnilogic", + "oncue", + "ondilo_ico", + "onewire", + "onvif", + "open_meteo", + "opengarage", + "opentherm_gw", + "openuv", + "openweathermap", + "overkiz", + "ovo_energy", + "owntracks", + "p1_monitor", + "panasonic_viera", + "philips_js", + "pi_hole", + "picnic", + "plaato", + "plex", + "plugwise", + "plum_lightpad", + "point", + "poolsense", + "powerwall", + "profiler", + "progettihwsw", + "prosegur", + "ps4", + "pure_energie", + "pvoutput", + "pvpc_hourly_pricing", + "rachio", + "radio_browser", + "rainforest_eagle", + "rainmachine", + "rdw", + "recollect_waste", + "renault", + "rfxtrx", + "ridwell", + "ring", + "risco", + "rituals_perfume_genie", + "roku", + "roomba", + "roon", + "rpi_power", + "rtsp_to_webrtc", + "ruckus_unleashed", + "samsungtv", + "screenlogic", + "season", + "sense", + "senseme", + "sensibo", + "sentry", + "sharkiq", + "shelly", + "shopping_list", + "sia", + "simplisafe", + "sleepiq", + "sma", + "smappee", + "smart_meter_texas", + "smartthings", + "smarttub", + "smhi", + "sms", + "solaredge", + "solarlog", + "solax", + "soma", + "somfy", + "somfy_mylink", + "sonarr", + "songpal", + "sonos", + "speedtestdotnet", + "spider", + "spotify", + "squeezebox", + "srp_energy", + "starline", + "steamist", + "stookalert", + "subaru", + "sun", + "surepetcare", + "switch_as_x", + "switchbot", + "switcher_kis", + "syncthing", + "syncthru", + "synology_dsm", + "system_bridge", + "tado", + "tailscale", + "tasmota", + "tellduslive", + "tesla_wall_connector", + "tibber", + "tile", + "tolo", + "tomorrowio", + "toon", + "totalconnect", + "tplink", + "traccar", + "tractive", + "tradfri", + "trafikverket_weatherstation", + "transmission", + "tuya", + "twentemilieu", + "twilio", + "twinkly", + "unifi", + "unifiprotect", + "upb", + "upcloud", + "upnp", + "uptime", + "uptimerobot", + "vallox", + "velbus", + "venstar", + "vera", + "verisure", + "version", + "vesync", + "vicare", + "vilfo", + "vizio", + "vlc_telnet", + "volumio", + "wallbox", + "watttime", + "waze_travel_time", + "webostv", + "wemo", + "whirlpool", + "whois", + "wiffi", + "wilight", + "withings", + "wiz", + "wled", + "wolflink", + "xbox", + "xiaomi_aqara", + "xiaomi_miio", + "yale_smart_alarm", + "yamaha_musiccast", + "yeelight", + "youless", + "zerproc", + "zha", + "zwave_js", + "zwave_me" + ], + "helper": [ + "derivative" + ] +} diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 8633534e97660..4b4c2d7b97964 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -49,6 +49,7 @@ {'domain': 'hunterdouglas_powerview', 'hostname': 'hunter*', 'macaddress': '002674*'}, + {'domain': 'intellifire', 'hostname': 'zentrios-*'}, {'domain': 'isy994', 'registered_devices': True}, {'domain': 'isy994', 'hostname': 'isy*', 'macaddress': '0021B9*'}, {'domain': 'lyric', 'hostname': 'lyric-*', 'macaddress': '48A2E6*'}, diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index a1dba0d69622f..1cef123b292d2 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -201,8 +201,8 @@ def async_register_entity_service( async def handle_service(call: ServiceCall) -> None: """Handle the service.""" - await self.hass.helpers.service.entity_service_call( - self._platforms.values(), func, call, required_features + await service.entity_service_call( + self.hass, self._platforms.values(), func, call, required_features ) self.hass.services.async_register(self.domain, name, handle_service, schema) diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index 76c51fa29d2ae..5fa10fd6fe8f1 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -62,7 +62,13 @@ def get_supervisor_network_url( def is_hass_url(hass: HomeAssistant, url: str) -> bool: """Return if the URL points at this Home Assistant instance.""" - parsed = yarl.URL(normalize_url(url)) + parsed = yarl.URL(url) + + if not parsed.is_absolute(): + return False + + if parsed.is_default_port(): + parsed = parsed.with_port(None) def host_ip() -> str | None: if hass.config.api is None or is_loopback(ip_address(hass.config.api.local_ip)): diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index da562fcded63d..966666b27fc3a 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -527,7 +527,7 @@ def async_set_service_schema( @bind_hass -async def entity_service_call( +async def entity_service_call( # noqa: C901 hass: HomeAssistant, platforms: Iterable[EntityPlatform], func: str | Callable[..., Any], @@ -646,6 +646,12 @@ async def entity_service_call( for feature_set in required_features ) ): + # If entity explicitly referenced, raise an error + if referenced is not None and entity.entity_id in referenced.referenced: + raise HomeAssistantError( + f"Entity {entity.entity_id} does not support this service." + ) + continue entities.append(entity) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 7453a845e9d57..0c0d647d48a3e 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta import logging from time import monotonic -from typing import Generic, TypeVar +from typing import Any, Generic, TypeVar # pylint: disable=unused-import import urllib.error import aiohttp @@ -24,6 +24,9 @@ REQUEST_REFRESH_DEFAULT_IMMEDIATE = True _T = TypeVar("_T") +_DataUpdateCoordinatorT = TypeVar( + "_DataUpdateCoordinatorT", bound="DataUpdateCoordinator[Any]" +) class UpdateFailed(Exception): @@ -295,10 +298,10 @@ def _async_stop_refresh(self, _: Event) -> None: self._unsub_refresh = None -class CoordinatorEntity(Generic[_T], entity.Entity): +class CoordinatorEntity(entity.Entity, Generic[_DataUpdateCoordinatorT]): """A class for entities using DataUpdateCoordinator.""" - def __init__(self, coordinator: DataUpdateCoordinator[_T]) -> None: + def __init__(self, coordinator: _DataUpdateCoordinatorT) -> None: """Create the entity with a DataUpdateCoordinator.""" self.coordinator = coordinator diff --git a/homeassistant/loader.py b/homeassistant/loader.py index be70dc50f0c73..364f212a1be2e 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -16,7 +16,7 @@ import pathlib import sys from types import ModuleType -from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast +from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeVar, cast from awesomeversion import ( AwesomeVersion, @@ -87,6 +87,7 @@ class Manifest(TypedDict, total=False): name: str disabled: str domain: str + integration_type: Literal["integration", "helper"] dependencies: list[str] after_dependencies: list[str] requirements: list[str] @@ -180,20 +181,29 @@ async def async_get_custom_components( return cast(dict[str, "Integration"], reg_or_evt) -async def async_get_config_flows(hass: HomeAssistant) -> set[str]: +async def async_get_config_flows( + hass: HomeAssistant, + type_filter: Literal["helper", "integration"] | None = None, +) -> set[str]: """Return cached list of config flows.""" # pylint: disable=import-outside-toplevel from .generated.config_flows import FLOWS + integrations = await async_get_custom_components(hass) flows: set[str] = set() - flows.update(FLOWS) - integrations = await async_get_custom_components(hass) + if type_filter is not None: + flows.update(FLOWS[type_filter]) + else: + for type_flows in FLOWS.values(): + flows.update(type_flows) + flows.update( [ integration.domain for integration in integrations.values() if integration.config_flow + and (type_filter is None or integration.integration_type == type_filter) ] ) @@ -474,6 +484,11 @@ def iot_class(self) -> str | None: """Return the integration IoT Class.""" return self.manifest.get("iot_class") + @property + def integration_type(self) -> Literal["integration", "helper"]: + """Return the integration type.""" + return self.manifest.get("integration_type", "integration") + @property def mqtt(self) -> list[str] | None: """Return Integration MQTT entries.""" diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py index 396ab56b8c25d..87077a0eb0a27 100644 --- a/homeassistant/util/network.py +++ b/homeassistant/util/network.py @@ -82,6 +82,6 @@ def is_ipv6_address(address: str) -> bool: def normalize_url(address: str) -> str: """Normalize a given URL.""" url = yarl.URL(address.rstrip("/")) - if url.is_default_port(): + if url.is_absolute() and url.is_default_port(): return str(url.with_port(None)) return str(url) diff --git a/mypy.ini b/mypy.ini index 0cde5eda9a349..b4d51d7f4e185 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2079,6 +2079,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.update.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.uptime.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2266,6 +2277,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.yale_smart_alarm.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.zodiac.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 52ad3feb66a13..7ba6a53525e81 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -229,7 +229,7 @@ aiopyarr==22.2.2 aiorecollect==1.0.8 # homeassistant.components.ridwell -aioridwell==2021.12.2 +aioridwell==2022.03.0 # homeassistant.components.senseme aiosenseme==0.6.1 @@ -421,7 +421,7 @@ boto3==1.20.24 bravia-tv==1.0.11 # homeassistant.components.broadlink -broadlink==0.18.0 +broadlink==0.18.1 # homeassistant.components.brother brother==1.1.0 @@ -1353,7 +1353,7 @@ pyatome==0.1.1 pyatv==0.10.0 # homeassistant.components.aussie_broadband -pyaussiebb==0.0.11 +pyaussiebb==0.0.14 # homeassistant.components.balboa pybalboa==0.13 @@ -1915,7 +1915,7 @@ python-qbittorrent==0.4.2 python-ripple-api==0.0.3 # homeassistant.components.smarttub -python-smarttub==0.0.29 +python-smarttub==0.0.30 # homeassistant.components.sochain python-sochain-api==0.0.2 @@ -1944,6 +1944,9 @@ pythonegardia==1.0.40 # homeassistant.components.tile pytile==2022.02.0 +# homeassistant.components.tomorrowio +pytomorrowio==0.1.0 + # homeassistant.components.touchline pytouchline==0.7 @@ -2115,7 +2118,7 @@ sendgrid==6.8.2 sense_energy==0.10.2 # homeassistant.components.sentry -sentry-sdk==1.5.7 +sentry-sdk==1.5.8 # homeassistant.components.sharkiq sharkiqpy==0.1.8 @@ -2133,7 +2136,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==2022.02.1 +simplisafe-python==2022.03.0 # homeassistant.components.sisyphus sisyphus-control==3.1.2 @@ -2150,9 +2153,6 @@ slixmpp==1.8.0.1 # homeassistant.components.smart_meter_texas smart-meter-texas==0.4.7 -# homeassistant.components.smarthab -smarthab==0.21 - # homeassistant.components.smhi smhi-pkg==1.0.15 @@ -2494,4 +2494,4 @@ zm-py==0.5.2 zwave-js-server-python==0.35.2 # homeassistant.components.zwave_me -zwave_me_ws==0.2.2 +zwave_me_ws==0.2.3 diff --git a/requirements_test.txt b/requirements_test.txt index 9e16676092a7d..a319d6c3005e8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,7 +11,7 @@ codecov==2.1.12 coverage==6.3.2 freezegun==1.2.0 mock-open==1.4.0 -mypy==0.940 +mypy==0.941 pre-commit==2.17.0 pylint==2.12.2 pipdeptree==2.2.1 @@ -24,7 +24,7 @@ pytest-test-groups==1.0.3 pytest-sugar==0.9.4 pytest-timeout==2.1.0 pytest-xdist==2.5.0 -pytest==7.1.0 +pytest==7.1.1 requests_mock==1.9.2 respx==0.19.0 stdlib-list==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2c68b9fcbc4b0..e9d7239f0244d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -192,7 +192,7 @@ aiopyarr==22.2.2 aiorecollect==1.0.8 # homeassistant.components.ridwell -aioridwell==2021.12.2 +aioridwell==2022.03.0 # homeassistant.components.senseme aiosenseme==0.6.1 @@ -310,7 +310,7 @@ boschshcpy==0.2.30 bravia-tv==1.0.11 # homeassistant.components.broadlink -broadlink==0.18.0 +broadlink==0.18.1 # homeassistant.components.brother brother==1.1.0 @@ -893,7 +893,7 @@ pyatmo==6.2.4 pyatv==0.10.0 # homeassistant.components.aussie_broadband -pyaussiebb==0.0.11 +pyaussiebb==0.0.14 # homeassistant.components.balboa pybalboa==0.13 @@ -1230,7 +1230,7 @@ python-nest==4.2.0 python-picnic-api==1.1.0 # homeassistant.components.smarttub -python-smarttub==0.0.29 +python-smarttub==0.0.30 # homeassistant.components.songpal python-songpal==0.14.1 @@ -1244,6 +1244,9 @@ python_awair==0.2.1 # homeassistant.components.tile pytile==2022.02.0 +# homeassistant.components.tomorrowio +pytomorrowio==0.1.0 + # homeassistant.components.traccar pytraccar==0.10.0 @@ -1349,7 +1352,7 @@ securetar==2022.2.0 sense_energy==0.10.2 # homeassistant.components.sentry -sentry-sdk==1.5.7 +sentry-sdk==1.5.8 # homeassistant.components.sharkiq sharkiqpy==0.1.8 @@ -1358,7 +1361,7 @@ sharkiqpy==0.1.8 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==2022.02.1 +simplisafe-python==2022.03.0 # homeassistant.components.slack slackclient==2.5.0 @@ -1366,9 +1369,6 @@ slackclient==2.5.0 # homeassistant.components.smart_meter_texas smart-meter-texas==0.4.7 -# homeassistant.components.smarthab -smarthab==0.21 - # homeassistant.components.smhi smhi-pkg==1.0.15 @@ -1596,4 +1596,4 @@ zigpy==0.43.0 zwave-js-server-python==0.35.2 # homeassistant.components.zwave_me -zwave_me_ws==0.2.2 +zwave_me_ws==0.2.3 diff --git a/script/hassfest/codeowners.py b/script/hassfest/codeowners.py index cf8fb02b98900..09498cfca01d9 100644 --- a/script/hassfest/codeowners.py +++ b/script/hassfest/codeowners.py @@ -56,7 +56,7 @@ def generate_and_validate(integrations: dict[str, Integration], config: Config): parts.append(f"homeassistant/components/{domain}/* {' '.join(codeowners)}") - if (config.root / "tests/components" / domain).exists(): + if (config.root / "tests/components" / domain / "__init__.py").exists(): parts.append(f"tests/components/{domain}/* {' '.join(codeowners)}") parts.append(f"\n{INDIVIDUAL_FILES.strip()}") diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index 87e9bea6291f5..169ccedf4a1f6 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -69,7 +69,10 @@ def validate_integration(config: Config, integration: Integration): def generate_and_validate(integrations: dict[str, Integration], config: Config): """Validate and generate config flow data.""" - domains = [] + domains = { + "integration": [], + "helper": [], + } for domain in sorted(integrations): integration = integrations[domain] @@ -79,7 +82,7 @@ def generate_and_validate(integrations: dict[str, Integration], config: Config): validate_integration(config, integration) - domains.append(domain) + domains[integration.integration_type].append(domain) return BASE.format(json.dumps(domains, indent=4)) diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 5b344ed505d3f..ca9acedd515fa 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -152,6 +152,7 @@ def verify_wildcard(value: str): { vol.Required("domain"): str, vol.Required("name"): str, + vol.Optional("integration_type"): "helper", vol.Optional("config_flow"): bool, vol.Optional("mqtt"): [str], vol.Optional("zeroconf"): [ diff --git a/script/hassfest/model.py b/script/hassfest/model.py index 7006c1e603260..2a6ea9ca85f69 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -112,6 +112,11 @@ def dependencies(self) -> list[str]: """List of dependencies.""" return self.manifest.get("dependencies", []) + @property + def integration_type(self) -> str: + """Get integration_type.""" + return self.manifest.get("integration_type", "integration") + def add_error(self, *args: Any, **kwargs: Any) -> None: """Add an error.""" self.errors.append(Error(*args, **kwargs)) diff --git a/script/pip_check b/script/pip_check index 9d5ec6c87ecdc..5b69e1569c667 100755 --- a/script/pip_check +++ b/script/pip_check @@ -3,7 +3,7 @@ PIP_CACHE=$1 # Number of existing dependency conflicts # Update if a PR resolve one! -DEPENDENCY_CONFLICTS=4 +DEPENDENCY_CONFLICTS=5 PIP_CHECK=$(pip check --cache-dir=$PIP_CACHE) LINE_COUNT=$(echo "$PIP_CHECK" | wc -l) @@ -14,6 +14,7 @@ then echo "------" echo "Requirements change added another dependency conflict." echo "Make sure to check the 'pip check' output above!" + echo "Expected $DEPENDENCY_CONFLICTS conflicts, got $LINE_COUNT." exit 1 elif [[ $((LINE_COUNT)) -lt $DEPENDENCY_CONFLICTS ]] then diff --git a/tests/common.py b/tests/common.py index bdebc7217a7ae..55878f76da7b3 100644 --- a/tests/common.py +++ b/tests/common.py @@ -282,7 +282,7 @@ async def _await_count_and_log_pending( hass.config.latitude = 32.87336 hass.config.longitude = -117.22743 hass.config.elevation = 0 - hass.config.time_zone = "US/Pacific" + hass.config.set_time_zone("US/Pacific") hass.config.units = METRIC_SYSTEM hass.config.media_dirs = {"local": get_test_config_dir("media")} hass.config.skip_pip = True diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py index 7887139a38609..b5e7679dbe691 100644 --- a/tests/components/aemet/test_sensor.py +++ b/tests/components/aemet/test_sensor.py @@ -15,6 +15,7 @@ async def test_aemet_forecast_create_sensors(hass): """Test creation of forecast sensors.""" + hass.config.set_time_zone("UTC") now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") with patch("homeassistant.util.dt.now", return_value=now), patch( "homeassistant.util.dt.utcnow", return_value=now diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index 43acf4c1c8731..d1f1889c80745 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -30,6 +30,7 @@ async def test_aemet_weather(hass): """Test states of the weather.""" + hass.config.set_time_zone("UTC") now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") with patch("homeassistant.util.dt.now", return_value=now), patch( "homeassistant.util.dt.utcnow", return_value=now diff --git a/tests/components/airzone/test_coordinator.py b/tests/components/airzone/test_coordinator.py new file mode 100644 index 0000000000000..00ef0616b3e12 --- /dev/null +++ b/tests/components/airzone/test_coordinator.py @@ -0,0 +1,39 @@ +"""Define tests for the Airzone coordinator.""" + +from unittest.mock import MagicMock, patch + +from aiohttp import ClientConnectorError + +from homeassistant.components.airzone.const import DOMAIN +from homeassistant.components.airzone.coordinator import SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from .util import CONFIG, HVAC_MOCK + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_coordinator_client_connector_error(hass: HomeAssistant): + """Test ClientConnectorError on coordinator update.""" + + entry = MockConfigEntry(domain=DOMAIN, data=CONFIG) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", + return_value=HVAC_MOCK, + ) as mock_hvac: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + mock_hvac.assert_called_once() + mock_hvac.reset_mock() + + mock_hvac.side_effect = ClientConnectorError(MagicMock(), MagicMock()) + async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + mock_hvac.assert_called_once() + + state = hass.states.get("sensor.despacho_temperature") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index 6993aa97081a6..7bfb8b620f666 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -222,15 +222,6 @@ "custom_calendars": [], } -ORIG_TZ = dt.DEFAULT_TIME_ZONE - - -@pytest.fixture(autouse=True) -def reset_tz(): - """Restore the default TZ after test runs.""" - yield - dt.DEFAULT_TIME_ZONE = ORIG_TZ - @pytest.fixture def set_tz(request): @@ -239,21 +230,21 @@ def set_tz(request): @pytest.fixture -def utc(): +def utc(hass): """Set the default TZ to UTC.""" - dt.set_default_time_zone(dt.get_time_zone("UTC")) + hass.config.set_time_zone("UTC") @pytest.fixture -def new_york(): +def new_york(hass): """Set the default TZ to America/New_York.""" - dt.set_default_time_zone(dt.get_time_zone("America/New_York")) + hass.config.set_time_zone("America/New_York") @pytest.fixture -def baghdad(): +def baghdad(hass): """Set the default TZ to Asia/Baghdad.""" - dt.set_default_time_zone(dt.get_time_zone("Asia/Baghdad")) + hass.config.set_time_zone("Asia/Baghdad") @pytest.fixture(autouse=True) @@ -364,8 +355,9 @@ async def test_setup_component_with_one_custom_calendar(hass, mock_dav_client): assert state.name == "HomeOffice" +@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch("homeassistant.util.dt.now", return_value=_local_datetime(17, 45)) -async def test_ongoing_event(mock_now, hass, calendar): +async def test_ongoing_event(mock_now, hass, calendar, set_tz): """Test that the ongoing event is returned.""" assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) await hass.async_block_till_done() @@ -385,8 +377,9 @@ async def test_ongoing_event(mock_now, hass, calendar): } +@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch("homeassistant.util.dt.now", return_value=_local_datetime(17, 30)) -async def test_just_ended_event(mock_now, hass, calendar): +async def test_just_ended_event(mock_now, hass, calendar, set_tz): """Test that the next ongoing event is returned.""" assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) await hass.async_block_till_done() @@ -406,8 +399,9 @@ async def test_just_ended_event(mock_now, hass, calendar): } +@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch("homeassistant.util.dt.now", return_value=_local_datetime(17, 00)) -async def test_ongoing_event_different_tz(mock_now, hass, calendar): +async def test_ongoing_event_different_tz(mock_now, hass, calendar, set_tz): """Test that the ongoing event with another timezone is returned.""" assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) await hass.async_block_till_done() @@ -427,8 +421,9 @@ async def test_ongoing_event_different_tz(mock_now, hass, calendar): } +@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch("homeassistant.util.dt.now", return_value=_local_datetime(19, 10)) -async def test_ongoing_floating_event_returned(mock_now, hass, calendar): +async def test_ongoing_floating_event_returned(mock_now, hass, calendar, set_tz): """Test that floating events without timezones work.""" assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) await hass.async_block_till_done() @@ -448,8 +443,9 @@ async def test_ongoing_floating_event_returned(mock_now, hass, calendar): } +@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch("homeassistant.util.dt.now", return_value=_local_datetime(8, 30)) -async def test_ongoing_event_with_offset(mock_now, hass, calendar): +async def test_ongoing_event_with_offset(mock_now, hass, calendar, set_tz): """Test that the offset is taken into account.""" assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) await hass.async_block_till_done() @@ -469,8 +465,9 @@ async def test_ongoing_event_with_offset(mock_now, hass, calendar): } +@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch("homeassistant.util.dt.now", return_value=_local_datetime(12, 00)) -async def test_matching_filter(mock_now, hass, calendar): +async def test_matching_filter(mock_now, hass, calendar, set_tz): """Test that the matching event is returned.""" config = dict(CALDAV_CONFIG) config["custom_calendars"] = [ @@ -495,8 +492,9 @@ async def test_matching_filter(mock_now, hass, calendar): } +@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch("homeassistant.util.dt.now", return_value=_local_datetime(12, 00)) -async def test_matching_filter_real_regexp(mock_now, hass, calendar): +async def test_matching_filter_real_regexp(mock_now, hass, calendar, set_tz): """Test that the event matching the regexp is returned.""" config = dict(CALDAV_CONFIG) config["custom_calendars"] = [ @@ -625,8 +623,9 @@ async def test_all_day_event_returned_late(hass, calendar, set_tz): ) +@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch("homeassistant.util.dt.now", return_value=_local_datetime(21, 45)) -async def test_event_rrule(mock_now, hass, calendar): +async def test_event_rrule(mock_now, hass, calendar, set_tz): """Test that the future recurring event is returned.""" assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) await hass.async_block_till_done() @@ -646,8 +645,9 @@ async def test_event_rrule(mock_now, hass, calendar): } +@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch("homeassistant.util.dt.now", return_value=_local_datetime(22, 15)) -async def test_event_rrule_ongoing(mock_now, hass, calendar): +async def test_event_rrule_ongoing(mock_now, hass, calendar, set_tz): """Test that the current recurring event is returned.""" assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) await hass.async_block_till_done() @@ -667,8 +667,9 @@ async def test_event_rrule_ongoing(mock_now, hass, calendar): } +@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch("homeassistant.util.dt.now", return_value=_local_datetime(22, 45)) -async def test_event_rrule_duration(mock_now, hass, calendar): +async def test_event_rrule_duration(mock_now, hass, calendar, set_tz): """Test that the future recurring event is returned.""" assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) await hass.async_block_till_done() @@ -688,8 +689,9 @@ async def test_event_rrule_duration(mock_now, hass, calendar): } +@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch("homeassistant.util.dt.now", return_value=_local_datetime(23, 15)) -async def test_event_rrule_duration_ongoing(mock_now, hass, calendar): +async def test_event_rrule_duration_ongoing(mock_now, hass, calendar, set_tz): """Test that the ongoing recurring event is returned.""" assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) await hass.async_block_till_done() @@ -709,8 +711,9 @@ async def test_event_rrule_duration_ongoing(mock_now, hass, calendar): } +@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch("homeassistant.util.dt.now", return_value=_local_datetime(23, 37)) -async def test_event_rrule_endless(mock_now, hass, calendar): +async def test_event_rrule_endless(mock_now, hass, calendar, set_tz): """Test that the endless recurring event is returned.""" assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) await hass.async_block_till_done() @@ -798,11 +801,12 @@ async def test_event_rrule_all_day_late(hass, calendar, set_tz): ) +@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch( "homeassistant.util.dt.now", return_value=dt.as_local(datetime.datetime(2015, 11, 27, 0, 15)), ) -async def test_event_rrule_hourly_on_first(mock_now, hass, calendar): +async def test_event_rrule_hourly_on_first(mock_now, hass, calendar, set_tz): """Test that the endless recurring event is returned.""" assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) await hass.async_block_till_done() @@ -822,11 +826,12 @@ async def test_event_rrule_hourly_on_first(mock_now, hass, calendar): } +@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch( "homeassistant.util.dt.now", return_value=dt.as_local(datetime.datetime(2015, 11, 27, 11, 15)), ) -async def test_event_rrule_hourly_on_last(mock_now, hass, calendar): +async def test_event_rrule_hourly_on_last(mock_now, hass, calendar, set_tz): """Test that the endless recurring event is returned.""" assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) await hass.async_block_till_done() diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 4cf96f1a9659e..2e6fafb02871b 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -39,6 +39,7 @@ EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er, network from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component @@ -973,7 +974,7 @@ async def test_entity_play_media(hass: HomeAssistant, quick_play_mock): { ATTR_ENTITY_ID: entity_id, media_player.ATTR_MEDIA_CONTENT_TYPE: "audio", - media_player.ATTR_MEDIA_CONTENT_ID: "best.mp3", + media_player.ATTR_MEDIA_CONTENT_ID: "http://example.com/best.mp3", media_player.ATTR_MEDIA_EXTRA: {"metadata": {"metadatatype": 3}}, }, blocking=True, @@ -984,7 +985,7 @@ async def test_entity_play_media(hass: HomeAssistant, quick_play_mock): chromecast, "default_media_receiver", { - "media_id": "best.mp3", + "media_id": "http://example.com/best.mp3", "media_type": "audio", "metadata": {"metadatatype": 3}, }, @@ -1225,15 +1226,18 @@ async def test_entity_control(hass: HomeAssistant): chromecast.media_controller.pause.assert_called_once_with() # Media previous - await common.async_media_previous_track(hass, entity_id) + with pytest.raises(HomeAssistantError): + await common.async_media_previous_track(hass, entity_id) chromecast.media_controller.queue_prev.assert_not_called() # Media next - await common.async_media_next_track(hass, entity_id) + with pytest.raises(HomeAssistantError): + await common.async_media_next_track(hass, entity_id) chromecast.media_controller.queue_next.assert_not_called() # Media seek - await common.async_media_seek(hass, 123, entity_id) + with pytest.raises(HomeAssistantError): + await common.async_media_seek(hass, 123, entity_id) chromecast.media_controller.seek.assert_not_called() # Enable support for queue and seek @@ -1519,13 +1523,15 @@ async def test_group_media_control(hass, mz_mock, quick_play_mock): assert not chromecast.media_controller.stop.called # Verify play_media is not forwarded - await common.async_play_media(hass, "music", "best.mp3", entity_id) + await common.async_play_media( + hass, "music", "http://example.com/best.mp3", entity_id + ) assert not grp_media.play_media.called assert not chromecast.media_controller.play_media.called quick_play_mock.assert_called_once_with( chromecast, "default_media_receiver", - {"media_id": "best.mp3", "media_type": "music"}, + {"media_id": "http://example.com/best.mp3", "media_type": "music"}, ) @@ -1799,7 +1805,7 @@ def can_play(*args): { ATTR_ENTITY_ID: entity_id, media_player.ATTR_MEDIA_CONTENT_TYPE: "audio", - media_player.ATTR_MEDIA_CONTENT_ID: "best.mp3", + media_player.ATTR_MEDIA_CONTENT_ID: "http://example.com/best.mp3", media_player.ATTR_MEDIA_EXTRA: {"metadata": {"metadatatype": 3}}, }, blocking=True, @@ -1807,7 +1813,7 @@ def can_play(*args): # Assert the media player attempt to play media through the cast platform cast_platform_mock.async_play_media.assert_called_once_with( - hass, entity_id, chromecast, "audio", "best.mp3" + hass, entity_id, chromecast, "audio", "http://example.com/best.mp3" ) # Assert pychromecast is used to play media diff --git a/tests/components/climacell/conftest.py b/tests/components/climacell/conftest.py index 88640c69c14ff..f762dc8d6f95f 100644 --- a/tests/components/climacell/conftest.py +++ b/tests/components/climacell/conftest.py @@ -7,36 +7,20 @@ from tests.common import load_fixture -@pytest.fixture(name="climacell_config_flow_connect", autouse=True) -def climacell_config_flow_connect(): - """Mock valid climacell config flow setup.""" - with patch( - "homeassistant.components.climacell.config_flow.ClimaCellV3.realtime", - return_value={}, - ), patch( - "homeassistant.components.climacell.config_flow.ClimaCellV4.realtime", - return_value={}, - ): - yield - - @pytest.fixture(name="climacell_config_entry_update") def climacell_config_entry_update_fixture(): """Mock valid climacell config entry setup.""" with patch( "homeassistant.components.climacell.ClimaCellV3.realtime", - return_value=json.loads(load_fixture("climacell/v3_realtime.json")), + return_value=json.loads(load_fixture("v3_realtime.json", "climacell")), ), patch( "homeassistant.components.climacell.ClimaCellV3.forecast_hourly", - return_value=json.loads(load_fixture("climacell/v3_forecast_hourly.json")), + return_value=json.loads(load_fixture("v3_forecast_hourly.json", "climacell")), ), patch( "homeassistant.components.climacell.ClimaCellV3.forecast_daily", - return_value=json.loads(load_fixture("climacell/v3_forecast_daily.json")), + return_value=json.loads(load_fixture("v3_forecast_daily.json", "climacell")), ), patch( "homeassistant.components.climacell.ClimaCellV3.forecast_nowcast", - return_value=json.loads(load_fixture("climacell/v3_forecast_nowcast.json")), - ), patch( - "homeassistant.components.climacell.ClimaCellV4.realtime_and_all_forecasts", - return_value=json.loads(load_fixture("climacell/v4.json")), + return_value=json.loads(load_fixture("v3_forecast_nowcast.json", "climacell")), ): yield diff --git a/tests/components/climacell/const.py b/tests/components/climacell/const.py index be933ecde290f..88a9cbd54cd6f 100644 --- a/tests/components/climacell/const.py +++ b/tests/components/climacell/const.py @@ -10,17 +10,6 @@ API_KEY = "aa" -MIN_CONFIG = { - CONF_API_KEY: API_KEY, -} - -V1_ENTRY_DATA = { - CONF_NAME: "ClimaCell", - CONF_API_KEY: API_KEY, - CONF_LATITUDE: 80, - CONF_LONGITUDE: 80, -} - API_V3_ENTRY_DATA = { CONF_NAME: "ClimaCell", CONF_API_KEY: API_KEY, @@ -28,11 +17,3 @@ CONF_LONGITUDE: 80, CONF_API_VERSION: 3, } - -API_V4_ENTRY_DATA = { - CONF_NAME: "ClimaCell", - CONF_API_KEY: API_KEY, - CONF_LATITUDE: 80, - CONF_LONGITUDE: 80, - CONF_API_VERSION: 4, -} diff --git a/tests/components/climacell/fixtures/v4.json b/tests/components/climacell/fixtures/v4.json deleted file mode 100644 index 02f76ab7d2715..0000000000000 --- a/tests/components/climacell/fixtures/v4.json +++ /dev/null @@ -1,2384 +0,0 @@ -{ - "current": { - "temperature": 44.13, - "humidity": 22.71, - "pressureSeaLevel": 30.35, - "windSpeed": 9.33, - "windDirection": 315.14, - "weatherCode": 1000, - "visibility": 8.15, - "pollutantO3": 46.53, - "windGust": 12.64, - "cloudCover": 100, - "precipitationType": 1, - "particulateMatter25": 0.15, - "particulateMatter10": 0.57, - "pollutantNO2": 10.67, - "pollutantCO": 0.63, - "pollutantSO2": 1.65, - "epaIndex": 24, - "epaPrimaryPollutant": 0, - "epaHealthConcern": 0, - "mepIndex": 23, - "mepPrimaryPollutant": 1, - "mepHealthConcern": 0, - "treeIndex": 0, - "weedIndex": 0, - "grassIndex": 0, - "fireIndex": 10, - "temperatureApparent": 101.3, - "dewPoint": 72.82, - "pressureSurfaceLevel": 29.47, - "solarGHI": 0, - "cloudBase": 0.74, - "cloudCeiling": 0.74 - }, - "forecasts": { - "nowcast": [ - { - "startTime": "2021-03-07T17:48:00Z", - "values": { - "temperatureMin": 44.13, - "temperatureMax": 44.13, - "windSpeed": 9.33, - "windDirection": 315.14, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T17:53:00Z", - "values": { - "temperatureMin": 43.9, - "temperatureMax": 43.9, - "windSpeed": 9.31, - "windDirection": 315.14, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T17:58:00Z", - "values": { - "temperatureMin": 43.68, - "temperatureMax": 43.68, - "windSpeed": 9.28, - "windDirection": 315.14, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T18:03:00Z", - "values": { - "temperatureMin": 43.66, - "temperatureMax": 43.66, - "windSpeed": 9.26, - "windDirection": 315.14, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T18:08:00Z", - "values": { - "temperatureMin": 43.79, - "temperatureMax": 43.79, - "windSpeed": 9.22, - "windDirection": 315.14, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T18:13:00Z", - "values": { - "temperatureMin": 43.92, - "temperatureMax": 43.92, - "windSpeed": 9.17, - "windDirection": 315.14, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T18:18:00Z", - "values": { - "temperatureMin": 44.04, - "temperatureMax": 44.04, - "windSpeed": 9.13, - "windDirection": 315.14, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T18:23:00Z", - "values": { - "temperatureMin": 44.17, - "temperatureMax": 44.17, - "windSpeed": 9.06, - "windDirection": 315.14, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T18:28:00Z", - "values": { - "temperatureMin": 44.31, - "temperatureMax": 44.31, - "windSpeed": 9.02, - "windDirection": 315.14, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T18:33:00Z", - "values": { - "temperatureMin": 44.44, - "temperatureMax": 44.44, - "windSpeed": 8.97, - "windDirection": 321.71, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T18:38:00Z", - "values": { - "temperatureMin": 44.56, - "temperatureMax": 44.56, - "windSpeed": 8.93, - "windDirection": 321.71, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T18:43:00Z", - "values": { - "temperatureMin": 44.69, - "temperatureMax": 44.69, - "windSpeed": 8.88, - "windDirection": 321.71, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T18:48:00Z", - "values": { - "temperatureMin": 44.82, - "temperatureMax": 44.82, - "windSpeed": 8.84, - "windDirection": 321.71, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T18:53:00Z", - "values": { - "temperatureMin": 44.94, - "temperatureMax": 44.94, - "windSpeed": 8.79, - "windDirection": 321.71, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T18:58:00Z", - "values": { - "temperatureMin": 45.07, - "temperatureMax": 45.07, - "windSpeed": 8.75, - "windDirection": 321.71, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T19:03:00Z", - "values": { - "temperatureMin": 45.16, - "temperatureMax": 45.16, - "windSpeed": 8.75, - "windDirection": 321.71, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T19:08:00Z", - "values": { - "temperatureMin": 45.23, - "temperatureMax": 45.23, - "windSpeed": 8.75, - "windDirection": 321.71, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T19:13:00Z", - "values": { - "temperatureMin": 45.28, - "temperatureMax": 45.28, - "windSpeed": 8.77, - "windDirection": 321.71, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T19:18:00Z", - "values": { - "temperatureMin": 45.36, - "temperatureMax": 45.36, - "windSpeed": 8.79, - "windDirection": 321.71, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T19:23:00Z", - "values": { - "temperatureMin": 45.43, - "temperatureMax": 45.43, - "windSpeed": 8.81, - "windDirection": 321.71, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T19:28:00Z", - "values": { - "temperatureMin": 45.5, - "temperatureMax": 45.5, - "windSpeed": 8.81, - "windDirection": 321.71, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T19:33:00Z", - "values": { - "temperatureMin": 45.55, - "temperatureMax": 45.55, - "windSpeed": 8.84, - "windDirection": 323.38, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T19:38:00Z", - "values": { - "temperatureMin": 45.63, - "temperatureMax": 45.63, - "windSpeed": 8.86, - "windDirection": 323.38, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T19:43:00Z", - "values": { - "temperatureMin": 45.7, - "temperatureMax": 45.7, - "windSpeed": 8.88, - "windDirection": 323.38, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T19:48:00Z", - "values": { - "temperatureMin": 45.75, - "temperatureMax": 45.75, - "windSpeed": 8.9, - "windDirection": 323.38, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T19:53:00Z", - "values": { - "temperatureMin": 45.82, - "temperatureMax": 45.82, - "windSpeed": 8.9, - "windDirection": 323.38, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T19:58:00Z", - "values": { - "temperatureMin": 45.9, - "temperatureMax": 45.9, - "windSpeed": 8.93, - "windDirection": 323.38, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T20:03:00Z", - "values": { - "temperatureMin": 45.88, - "temperatureMax": 45.88, - "windSpeed": 8.97, - "windDirection": 323.38, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T20:08:00Z", - "values": { - "temperatureMin": 45.82, - "temperatureMax": 45.82, - "windSpeed": 9.02, - "windDirection": 323.38, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T20:13:00Z", - "values": { - "temperatureMin": 45.75, - "temperatureMax": 45.75, - "windSpeed": 9.06, - "windDirection": 323.38, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T20:18:00Z", - "values": { - "temperatureMin": 45.7, - "temperatureMax": 45.7, - "windSpeed": 9.1, - "windDirection": 323.38, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T20:23:00Z", - "values": { - "temperatureMin": 45.63, - "temperatureMax": 45.63, - "windSpeed": 9.15, - "windDirection": 323.38, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T20:28:00Z", - "values": { - "temperatureMin": 45.57, - "temperatureMax": 45.57, - "windSpeed": 9.19, - "windDirection": 323.38, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T20:33:00Z", - "values": { - "temperatureMin": 45.5, - "temperatureMax": 45.5, - "windSpeed": 9.24, - "windDirection": 318.43, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T20:38:00Z", - "values": { - "temperatureMin": 45.45, - "temperatureMax": 45.45, - "windSpeed": 9.28, - "windDirection": 318.43, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T20:43:00Z", - "values": { - "temperatureMin": 45.39, - "temperatureMax": 45.39, - "windSpeed": 9.33, - "windDirection": 318.43, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T20:48:00Z", - "values": { - "temperatureMin": 45.32, - "temperatureMax": 45.32, - "windSpeed": 9.37, - "windDirection": 318.43, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T20:53:00Z", - "values": { - "temperatureMin": 45.27, - "temperatureMax": 45.27, - "windSpeed": 9.42, - "windDirection": 318.43, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T20:58:00Z", - "values": { - "temperatureMin": 45.19, - "temperatureMax": 45.19, - "windSpeed": 9.46, - "windDirection": 318.43, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T21:03:00Z", - "values": { - "temperatureMin": 45.14, - "temperatureMax": 45.14, - "windSpeed": 9.4, - "windDirection": 318.43, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T21:08:00Z", - "values": { - "temperatureMin": 45.07, - "temperatureMax": 45.07, - "windSpeed": 9.24, - "windDirection": 318.43, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T21:13:00Z", - "values": { - "temperatureMin": 45.01, - "temperatureMax": 45.01, - "windSpeed": 9.08, - "windDirection": 318.43, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T21:18:00Z", - "values": { - "temperatureMin": 44.94, - "temperatureMax": 44.94, - "windSpeed": 8.95, - "windDirection": 318.43, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T21:23:00Z", - "values": { - "temperatureMin": 44.89, - "temperatureMax": 44.89, - "windSpeed": 8.79, - "windDirection": 318.43, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T21:28:00Z", - "values": { - "temperatureMin": 44.82, - "temperatureMax": 44.82, - "windSpeed": 8.63, - "windDirection": 318.43, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T21:33:00Z", - "values": { - "temperatureMin": 44.76, - "temperatureMax": 44.76, - "windSpeed": 8.5, - "windDirection": 320.9, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T21:38:00Z", - "values": { - "temperatureMin": 44.69, - "temperatureMax": 44.69, - "windSpeed": 8.34, - "windDirection": 320.9, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T21:43:00Z", - "values": { - "temperatureMin": 44.64, - "temperatureMax": 44.64, - "windSpeed": 8.19, - "windDirection": 320.9, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T21:48:00Z", - "values": { - "temperatureMin": 44.56, - "temperatureMax": 44.56, - "windSpeed": 8.05, - "windDirection": 320.9, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T21:53:00Z", - "values": { - "temperatureMin": 44.51, - "temperatureMax": 44.51, - "windSpeed": 7.9, - "windDirection": 320.9, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T21:58:00Z", - "values": { - "temperatureMin": 44.44, - "temperatureMax": 44.44, - "windSpeed": 7.74, - "windDirection": 320.9, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T22:03:00Z", - "values": { - "temperatureMin": 44.26, - "temperatureMax": 44.26, - "windSpeed": 7.47, - "windDirection": 320.9, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T22:08:00Z", - "values": { - "temperatureMin": 44.01, - "temperatureMax": 44.01, - "windSpeed": 7.14, - "windDirection": 320.9, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T22:13:00Z", - "values": { - "temperatureMin": 43.74, - "temperatureMax": 43.74, - "windSpeed": 6.78, - "windDirection": 320.9, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T22:18:00Z", - "values": { - "temperatureMin": 43.48, - "temperatureMax": 43.48, - "windSpeed": 6.44, - "windDirection": 320.9, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T22:23:00Z", - "values": { - "temperatureMin": 43.23, - "temperatureMax": 43.23, - "windSpeed": 6.08, - "windDirection": 320.9, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T22:28:00Z", - "values": { - "temperatureMin": 42.98, - "temperatureMax": 42.98, - "windSpeed": 5.75, - "windDirection": 320.9, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T22:33:00Z", - "values": { - "temperatureMin": 42.71, - "temperatureMax": 42.71, - "windSpeed": 5.39, - "windDirection": 322.11, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T22:38:00Z", - "values": { - "temperatureMin": 42.46, - "temperatureMax": 42.46, - "windSpeed": 5.06, - "windDirection": 322.11, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T22:43:00Z", - "values": { - "temperatureMin": 42.21, - "temperatureMax": 42.21, - "windSpeed": 4.7, - "windDirection": 322.11, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T22:48:00Z", - "values": { - "temperatureMin": 41.94, - "temperatureMax": 41.94, - "windSpeed": 4.36, - "windDirection": 322.11, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T22:53:00Z", - "values": { - "temperatureMin": 41.68, - "temperatureMax": 41.68, - "windSpeed": 4, - "windDirection": 322.11, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T22:58:00Z", - "values": { - "temperatureMin": 41.43, - "temperatureMax": 41.43, - "windSpeed": 3.67, - "windDirection": 322.11, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T23:03:00Z", - "values": { - "temperatureMin": 41.16, - "temperatureMax": 41.16, - "windSpeed": 3.6, - "windDirection": 322.11, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T23:08:00Z", - "values": { - "temperatureMin": 40.91, - "temperatureMax": 40.91, - "windSpeed": 3.76, - "windDirection": 322.11, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T23:13:00Z", - "values": { - "temperatureMin": 40.66, - "temperatureMax": 40.66, - "windSpeed": 3.91, - "windDirection": 322.11, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T23:18:00Z", - "values": { - "temperatureMin": 40.41, - "temperatureMax": 40.41, - "windSpeed": 4.05, - "windDirection": 322.11, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T23:23:00Z", - "values": { - "temperatureMin": 40.14, - "temperatureMax": 40.14, - "windSpeed": 4.21, - "windDirection": 322.11, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T23:28:00Z", - "values": { - "temperatureMin": 39.88, - "temperatureMax": 39.88, - "windSpeed": 4.36, - "windDirection": 322.11, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T23:33:00Z", - "values": { - "temperatureMin": 39.63, - "temperatureMax": 39.63, - "windSpeed": 4.5, - "windDirection": 295.94, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T23:38:00Z", - "values": { - "temperatureMin": 39.38, - "temperatureMax": 39.38, - "windSpeed": 4.65, - "windDirection": 295.94, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T23:43:00Z", - "values": { - "temperatureMin": 39.11, - "temperatureMax": 39.11, - "windSpeed": 4.79, - "windDirection": 295.94, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - } - ], - "hourly": [ - { - "startTime": "2021-03-07T17:48:00Z", - "values": { - "temperatureMin": 44.13, - "temperatureMax": 44.13, - "windSpeed": 9.33, - "windDirection": 315.14, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T18:48:00Z", - "values": { - "temperatureMin": 44.82, - "temperatureMax": 44.82, - "windSpeed": 8.84, - "windDirection": 321.71, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T19:48:00Z", - "values": { - "temperatureMin": 45.75, - "temperatureMax": 45.75, - "windSpeed": 8.9, - "windDirection": 323.38, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T20:48:00Z", - "values": { - "temperatureMin": 45.32, - "temperatureMax": 45.32, - "windSpeed": 9.37, - "windDirection": 318.43, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T21:48:00Z", - "values": { - "temperatureMin": 44.56, - "temperatureMax": 44.56, - "windSpeed": 8.05, - "windDirection": 320.9, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T22:48:00Z", - "values": { - "temperatureMin": 41.94, - "temperatureMax": 41.94, - "windSpeed": 4.36, - "windDirection": 322.11, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-07T23:48:00Z", - "values": { - "temperatureMin": 38.86, - "temperatureMax": 38.86, - "windSpeed": 4.94, - "windDirection": 295.94, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T00:48:00Z", - "values": { - "temperatureMin": 36.18, - "temperatureMax": 36.18, - "windSpeed": 5.59, - "windDirection": 11.94, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T01:48:00Z", - "values": { - "temperatureMin": 34.3, - "temperatureMax": 34.3, - "windSpeed": 5.57, - "windDirection": 13.68, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T02:48:00Z", - "values": { - "temperatureMin": 32.88, - "temperatureMax": 32.88, - "windSpeed": 5.41, - "windDirection": 14.93, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T03:48:00Z", - "values": { - "temperatureMin": 31.91, - "temperatureMax": 31.91, - "windSpeed": 4.61, - "windDirection": 26.07, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T04:48:00Z", - "values": { - "temperatureMin": 29.17, - "temperatureMax": 29.17, - "windSpeed": 2.59, - "windDirection": 51.27, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T05:48:00Z", - "values": { - "temperatureMin": 27.37, - "temperatureMax": 27.37, - "windSpeed": 3.31, - "windDirection": 343.25, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T06:48:00Z", - "values": { - "temperatureMin": 26.73, - "temperatureMax": 26.73, - "windSpeed": 4.27, - "windDirection": 341.46, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T07:48:00Z", - "values": { - "temperatureMin": 26.38, - "temperatureMax": 26.38, - "windSpeed": 3.53, - "windDirection": 322.34, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T08:48:00Z", - "values": { - "temperatureMin": 26.15, - "temperatureMax": 26.15, - "windSpeed": 3.65, - "windDirection": 294.69, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T09:48:00Z", - "values": { - "temperatureMin": 30.07, - "temperatureMax": 30.07, - "windSpeed": 3.2, - "windDirection": 325.32, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T10:48:00Z", - "values": { - "temperatureMin": 31.03, - "temperatureMax": 31.03, - "windSpeed": 2.84, - "windDirection": 322.27, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T11:48:00Z", - "values": { - "temperatureMin": 27.23, - "temperatureMax": 27.23, - "windSpeed": 5.59, - "windDirection": 310.14, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T12:48:00Z", - "values": { - "temperatureMin": 29.21, - "temperatureMax": 29.21, - "windSpeed": 7.05, - "windDirection": 324.8, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T13:48:00Z", - "values": { - "temperatureMin": 33.19, - "temperatureMax": 33.19, - "windSpeed": 6.46, - "windDirection": 335.16, - "weatherCode": 1101, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T14:48:00Z", - "values": { - "temperatureMin": 37.02, - "temperatureMax": 37.02, - "windSpeed": 5.88, - "windDirection": 324.49, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T15:48:00Z", - "values": { - "temperatureMin": 40.01, - "temperatureMax": 40.01, - "windSpeed": 5.55, - "windDirection": 310.68, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T16:48:00Z", - "values": { - "temperatureMin": 42.37, - "temperatureMax": 42.37, - "windSpeed": 5.46, - "windDirection": 304.18, - "weatherCode": 1101, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T17:48:00Z", - "values": { - "temperatureMin": 44.62, - "temperatureMax": 44.62, - "windSpeed": 4.99, - "windDirection": 301.19, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T18:48:00Z", - "values": { - "temperatureMin": 46.78, - "temperatureMax": 46.78, - "windSpeed": 4.72, - "windDirection": 295.05, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T19:48:00Z", - "values": { - "temperatureMin": 48.42, - "temperatureMax": 48.42, - "windSpeed": 4.81, - "windDirection": 287.4, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T20:48:00Z", - "values": { - "temperatureMin": 49.28, - "temperatureMax": 49.28, - "windSpeed": 4.74, - "windDirection": 282.48, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T21:48:00Z", - "values": { - "temperatureMin": 48.72, - "temperatureMax": 48.72, - "windSpeed": 2.51, - "windDirection": 268.74, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T22:48:00Z", - "values": { - "temperatureMin": 44.37, - "temperatureMax": 44.37, - "windSpeed": 3.56, - "windDirection": 180.04, - "weatherCode": 1101, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T23:48:00Z", - "values": { - "temperatureMin": 39.9, - "temperatureMax": 39.9, - "windSpeed": 4.68, - "windDirection": 177.89, - "weatherCode": 1101, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T00:48:00Z", - "values": { - "temperatureMin": 37.87, - "temperatureMax": 37.87, - "windSpeed": 5.21, - "windDirection": 197.47, - "weatherCode": 1101, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T01:48:00Z", - "values": { - "temperatureMin": 36.91, - "temperatureMax": 36.91, - "windSpeed": 5.46, - "windDirection": 209.77, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T02:48:00Z", - "values": { - "temperatureMin": 36.64, - "temperatureMax": 36.64, - "windSpeed": 6.11, - "windDirection": 210.14, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T03:48:00Z", - "values": { - "temperatureMin": 36.63, - "temperatureMax": 36.63, - "windSpeed": 6.4, - "windDirection": 216, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T04:48:00Z", - "values": { - "temperatureMin": 36.23, - "temperatureMax": 36.23, - "windSpeed": 6.22, - "windDirection": 223.92, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T05:48:00Z", - "values": { - "temperatureMin": 35.58, - "temperatureMax": 35.58, - "windSpeed": 5.75, - "windDirection": 229.68, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T06:48:00Z", - "values": { - "temperatureMin": 34.68, - "temperatureMax": 34.68, - "windSpeed": 5.21, - "windDirection": 235.24, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T07:48:00Z", - "values": { - "temperatureMin": 33.69, - "temperatureMax": 33.69, - "windSpeed": 4.81, - "windDirection": 237.24, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T08:48:00Z", - "values": { - "temperatureMin": 32.74, - "temperatureMax": 32.74, - "windSpeed": 4.52, - "windDirection": 239.35, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T09:48:00Z", - "values": { - "temperatureMin": 32.05, - "temperatureMax": 32.05, - "windSpeed": 4.32, - "windDirection": 245.68, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T10:48:00Z", - "values": { - "temperatureMin": 31.57, - "temperatureMax": 31.57, - "windSpeed": 4.14, - "windDirection": 248.11, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T11:48:00Z", - "values": { - "temperatureMin": 32.92, - "temperatureMax": 32.92, - "windSpeed": 4.32, - "windDirection": 249.54, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T12:48:00Z", - "values": { - "temperatureMin": 38.5, - "temperatureMax": 38.5, - "windSpeed": 4.7, - "windDirection": 253.3, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T13:48:00Z", - "values": { - "temperatureMin": 46.08, - "temperatureMax": 46.08, - "windSpeed": 4.41, - "windDirection": 258.49, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T14:48:00Z", - "values": { - "temperatureMin": 53.26, - "temperatureMax": 53.26, - "windSpeed": 4.9, - "windDirection": 260.49, - "weatherCode": 1101, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T15:48:00Z", - "values": { - "temperatureMin": 58.15, - "temperatureMax": 58.15, - "windSpeed": 5.55, - "windDirection": 261.29, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T16:48:00Z", - "values": { - "temperatureMin": 61.56, - "temperatureMax": 61.56, - "windSpeed": 6.35, - "windDirection": 264.3, - "weatherCode": 1101, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T17:48:00Z", - "values": { - "temperatureMin": 64, - "temperatureMax": 64, - "windSpeed": 6.6, - "windDirection": 257.54, - "weatherCode": 1101, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T18:48:00Z", - "values": { - "temperatureMin": 65.79, - "temperatureMax": 65.79, - "windSpeed": 6.96, - "windDirection": 253.12, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T19:48:00Z", - "values": { - "temperatureMin": 66.74, - "temperatureMax": 66.74, - "windSpeed": 6.8, - "windDirection": 259.46, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T20:48:00Z", - "values": { - "temperatureMin": 66.96, - "temperatureMax": 66.96, - "windSpeed": 6.33, - "windDirection": 294.25, - "weatherCode": 1101, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T21:48:00Z", - "values": { - "temperatureMin": 64.35, - "temperatureMax": 64.35, - "windSpeed": 3.91, - "windDirection": 279.37, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T22:48:00Z", - "values": { - "temperatureMin": 61.07, - "temperatureMax": 61.07, - "windSpeed": 3.65, - "windDirection": 218.19, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T23:48:00Z", - "values": { - "temperatureMin": 56.3, - "temperatureMax": 56.3, - "windSpeed": 4.09, - "windDirection": 208.3, - "weatherCode": 1101, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T00:48:00Z", - "values": { - "temperatureMin": 53.19, - "temperatureMax": 53.19, - "windSpeed": 4.21, - "windDirection": 216.42, - "weatherCode": 1102, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T01:48:00Z", - "values": { - "temperatureMin": 51.94, - "temperatureMax": 51.94, - "windSpeed": 3.38, - "windDirection": 257.19, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T02:48:00Z", - "values": { - "temperatureMin": 49.82, - "temperatureMax": 49.82, - "windSpeed": 2.71, - "windDirection": 288.85, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T03:48:00Z", - "values": { - "temperatureMin": 48.24, - "temperatureMax": 48.24, - "windSpeed": 2.8, - "windDirection": 334.41, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T04:48:00Z", - "values": { - "temperatureMin": 47.44, - "temperatureMax": 47.44, - "windSpeed": 2.26, - "windDirection": 342.01, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T05:48:00Z", - "values": { - "temperatureMin": 45.59, - "temperatureMax": 45.59, - "windSpeed": 2.35, - "windDirection": 2.43, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T06:48:00Z", - "values": { - "temperatureMin": 43.43, - "temperatureMax": 43.43, - "windSpeed": 2.3, - "windDirection": 336.56, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T07:48:00Z", - "values": { - "temperatureMin": 41.11, - "temperatureMax": 41.11, - "windSpeed": 2.71, - "windDirection": 4.41, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T08:48:00Z", - "values": { - "temperatureMin": 39.58, - "temperatureMax": 39.58, - "windSpeed": 3.4, - "windDirection": 21.26, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T09:48:00Z", - "values": { - "temperatureMin": 39.85, - "temperatureMax": 39.85, - "windSpeed": 3.31, - "windDirection": 22.76, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T10:48:00Z", - "values": { - "temperatureMin": 37.85, - "temperatureMax": 37.85, - "windSpeed": 4.03, - "windDirection": 29.3, - "weatherCode": 1101, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T11:48:00Z", - "values": { - "temperatureMin": 38.97, - "temperatureMax": 38.97, - "windSpeed": 3.15, - "windDirection": 21.82, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T12:48:00Z", - "values": { - "temperatureMin": 44.31, - "temperatureMax": 44.31, - "windSpeed": 3.53, - "windDirection": 14.25, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T13:48:00Z", - "values": { - "temperatureMin": 50.25, - "temperatureMax": 50.25, - "windSpeed": 2.82, - "windDirection": 42.41, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T14:48:00Z", - "values": { - "temperatureMin": 54.97, - "temperatureMax": 54.97, - "windSpeed": 2.53, - "windDirection": 87.81, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T15:48:00Z", - "values": { - "temperatureMin": 58.46, - "temperatureMax": 58.46, - "windSpeed": 3.09, - "windDirection": 125.82, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T16:48:00Z", - "values": { - "temperatureMin": 61.21, - "temperatureMax": 61.21, - "windSpeed": 4.03, - "windDirection": 157.54, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T17:48:00Z", - "values": { - "temperatureMin": 63.36, - "temperatureMax": 63.36, - "windSpeed": 5.21, - "windDirection": 166.66, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T18:48:00Z", - "values": { - "temperatureMin": 64.83, - "temperatureMax": 64.83, - "windSpeed": 6.93, - "windDirection": 189.24, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T19:48:00Z", - "values": { - "temperatureMin": 65.23, - "temperatureMax": 65.23, - "windSpeed": 8.95, - "windDirection": 194.58, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T20:48:00Z", - "values": { - "temperatureMin": 64.98, - "temperatureMax": 64.98, - "windSpeed": 9.4, - "windDirection": 193.22, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T21:48:00Z", - "values": { - "temperatureMin": 64.06, - "temperatureMax": 64.06, - "windSpeed": 8.55, - "windDirection": 186.39, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T22:48:00Z", - "values": { - "temperatureMin": 61.9, - "temperatureMax": 61.9, - "windSpeed": 7.49, - "windDirection": 171.81, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T23:48:00Z", - "values": { - "temperatureMin": 59.4, - "temperatureMax": 59.4, - "windSpeed": 7.54, - "windDirection": 165.51, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T00:48:00Z", - "values": { - "temperatureMin": 57.63, - "temperatureMax": 57.63, - "windSpeed": 8.12, - "windDirection": 171.94, - "weatherCode": 1102, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T01:48:00Z", - "values": { - "temperatureMin": 56.17, - "temperatureMax": 56.17, - "windSpeed": 8.7, - "windDirection": 176.84, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T02:48:00Z", - "values": { - "temperatureMin": 55.36, - "temperatureMax": 55.36, - "windSpeed": 9.42, - "windDirection": 184.14, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T03:48:00Z", - "values": { - "temperatureMin": 54.88, - "temperatureMax": 54.88, - "windSpeed": 10, - "windDirection": 195.54, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T04:48:00Z", - "values": { - "temperatureMin": 54.14, - "temperatureMax": 54.14, - "windSpeed": 10.4, - "windDirection": 200.56, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T05:48:00Z", - "values": { - "temperatureMin": 53.46, - "temperatureMax": 53.46, - "windSpeed": 10.04, - "windDirection": 198.08, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T06:48:00Z", - "values": { - "temperatureMin": 52.11, - "temperatureMax": 52.11, - "windSpeed": 10.02, - "windDirection": 199.54, - "weatherCode": 1101, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T07:48:00Z", - "values": { - "temperatureMin": 51.64, - "temperatureMax": 51.64, - "windSpeed": 10.51, - "windDirection": 202.73, - "weatherCode": 1102, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T08:48:00Z", - "values": { - "temperatureMin": 50.79, - "temperatureMax": 50.79, - "windSpeed": 10.38, - "windDirection": 203.35, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T09:48:00Z", - "values": { - "temperatureMin": 49.93, - "temperatureMax": 49.93, - "windSpeed": 9.51, - "windDirection": 210.36, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T10:48:00Z", - "values": { - "temperatureMin": 49.1, - "temperatureMax": 49.1, - "windSpeed": 8.61, - "windDirection": 210.6, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T11:48:00Z", - "values": { - "temperatureMin": 48.42, - "temperatureMax": 48.42, - "windSpeed": 9.15, - "windDirection": 211.29, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T12:48:00Z", - "values": { - "temperatureMin": 48.9, - "temperatureMax": 48.9, - "windSpeed": 10.25, - "windDirection": 215.59, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T13:48:00Z", - "values": { - "temperatureMin": 50.54, - "temperatureMax": 50.54, - "windSpeed": 10.18, - "windDirection": 215.48, - "weatherCode": 1102, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T14:48:00Z", - "values": { - "temperatureMin": 53.19, - "temperatureMax": 53.19, - "windSpeed": 9.4, - "windDirection": 208.76, - "weatherCode": 1101, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T15:48:00Z", - "values": { - "temperatureMin": 56.19, - "temperatureMax": 56.19, - "windSpeed": 9.73, - "windDirection": 197.59, - "weatherCode": 1101, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T16:48:00Z", - "values": { - "temperatureMin": 59.34, - "temperatureMax": 59.34, - "windSpeed": 10.69, - "windDirection": 204.29, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T17:48:00Z", - "values": { - "temperatureMin": 62.35, - "temperatureMax": 62.35, - "windSpeed": 11.81, - "windDirection": 204.56, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T18:48:00Z", - "values": { - "temperatureMin": 64.6, - "temperatureMax": 64.6, - "windSpeed": 13.09, - "windDirection": 206.85, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T19:48:00Z", - "values": { - "temperatureMin": 65.91, - "temperatureMax": 65.91, - "windSpeed": 13.82, - "windDirection": 204.82, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T20:48:00Z", - "values": { - "temperatureMin": 66.22, - "temperatureMax": 66.22, - "windSpeed": 14.54, - "windDirection": 208.43, - "weatherCode": 1100, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T21:48:00Z", - "values": { - "temperatureMin": 65.46, - "temperatureMax": 65.46, - "windSpeed": 13.2, - "windDirection": 208.3, - "weatherCode": 1101, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T22:48:00Z", - "values": { - "temperatureMin": 64.35, - "temperatureMax": 64.35, - "windSpeed": 12.35, - "windDirection": 208.58, - "weatherCode": 1101, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T23:48:00Z", - "values": { - "temperatureMin": 62.85, - "temperatureMax": 62.85, - "windSpeed": 12.86, - "windDirection": 205.39, - "weatherCode": 1101, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-12T00:48:00Z", - "values": { - "temperatureMin": 61.75, - "temperatureMax": 61.75, - "windSpeed": 14.7, - "windDirection": 209.51, - "weatherCode": 1102, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-12T01:48:00Z", - "values": { - "temperatureMin": 61.2, - "temperatureMax": 61.2, - "windSpeed": 15.57, - "windDirection": 211.47, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-12T02:48:00Z", - "values": { - "temperatureMin": 60.46, - "temperatureMax": 60.46, - "windSpeed": 14.94, - "windDirection": 211.57, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-12T03:48:00Z", - "values": { - "temperatureMin": 59.94, - "temperatureMax": 59.94, - "windSpeed": 14.29, - "windDirection": 208.93, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-12T04:48:00Z", - "values": { - "temperatureMin": 59.52, - "temperatureMax": 59.52, - "windSpeed": 14.36, - "windDirection": 217.91, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - } - ], - "daily": [ - { - "startTime": "2021-03-07T11:00:00Z", - "values": { - "temperatureMin": 26.11, - "temperatureMax": 45.93, - "windSpeed": 9.49, - "windDirection": 239.6, - "weatherCode": 1000, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-08T11:00:00Z", - "values": { - "temperatureMin": 26.28, - "temperatureMax": 49.42, - "windSpeed": 7.24, - "windDirection": 262.82, - "weatherCode": 1102, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-09T11:00:00Z", - "values": { - "temperatureMin": 31.48, - "temperatureMax": 66.98, - "windSpeed": 7.05, - "windDirection": 229.3, - "weatherCode": 1102, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-10T11:00:00Z", - "values": { - "temperatureMin": 37.32, - "temperatureMax": 65.28, - "windSpeed": 10.64, - "windDirection": 149.91, - "weatherCode": 1102, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-11T11:00:00Z", - "values": { - "temperatureMin": 48.29, - "temperatureMax": 66.25, - "windSpeed": 15.69, - "windDirection": 210.45, - "weatherCode": 1102, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-12T11:00:00Z", - "values": { - "temperatureMin": 53.83, - "temperatureMax": 67.91, - "windSpeed": 12.3, - "windDirection": 217.98, - "weatherCode": 4000, - "precipitationIntensityAvg": 0.0002, - "precipitationProbability": 25 - } - }, - { - "startTime": "2021-03-13T11:00:00Z", - "values": { - "temperatureMin": 42.91, - "temperatureMax": 54.48, - "windSpeed": 9.72, - "windDirection": 58.79, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 25 - } - }, - { - "startTime": "2021-03-14T10:00:00Z", - "values": { - "temperatureMin": 33.35, - "temperatureMax": 42.91, - "windSpeed": 16.25, - "windDirection": 70.25, - "weatherCode": 5101, - "precipitationIntensityAvg": 0.0393, - "precipitationProbability": 95 - } - }, - { - "startTime": "2021-03-15T10:00:00Z", - "values": { - "temperatureMin": 29.35, - "temperatureMax": 43.67, - "windSpeed": 15.89, - "windDirection": 84.47, - "weatherCode": 5001, - "precipitationIntensityAvg": 0.0024, - "precipitationProbability": 55 - } - }, - { - "startTime": "2021-03-16T10:00:00Z", - "values": { - "temperatureMin": 29.1, - "temperatureMax": 43, - "windSpeed": 6.71, - "windDirection": 103.85, - "weatherCode": 1102, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-17T10:00:00Z", - "values": { - "temperatureMin": 34.32, - "temperatureMax": 52.4, - "windSpeed": 7.27, - "windDirection": 145.41, - "weatherCode": 1102, - "precipitationIntensityAvg": 0, - "precipitationProbability": 0 - } - }, - { - "startTime": "2021-03-18T10:00:00Z", - "values": { - "temperatureMin": 41.32, - "temperatureMax": 54.07, - "windSpeed": 6.58, - "windDirection": 62.99, - "weatherCode": 1001, - "precipitationIntensityAvg": 0, - "precipitationProbability": 10 - } - }, - { - "startTime": "2021-03-19T10:00:00Z", - "values": { - "temperatureMin": 39.4, - "temperatureMax": 48.94, - "windSpeed": 13.91, - "windDirection": 68.54, - "weatherCode": 4000, - "precipitationIntensityAvg": 0.0048, - "precipitationProbability": 55 - } - }, - { - "startTime": "2021-03-20T10:00:00Z", - "values": { - "temperatureMin": 35.06, - "temperatureMax": 40.12, - "windSpeed": 17.35, - "windDirection": 56.98, - "weatherCode": 5001, - "precipitationIntensityAvg": 0.002, - "precipitationProbability": 33.3 - } - }, - { - "startTime": "2021-03-21T10:00:00Z", - "values": { - "temperatureMin": 33.66, - "temperatureMax": 66.54, - "windSpeed": 15.93, - "windDirection": 82.57, - "weatherCode": 5001, - "precipitationIntensityAvg": 0.0004, - "precipitationProbability": 45 - } - } - ] - } -} \ No newline at end of file diff --git a/tests/components/climacell/test_config_flow.py b/tests/components/climacell/test_config_flow.py index 476f2ba3bee0e..9aa16b8a7c5a3 100644 --- a/tests/components/climacell/test_config_flow.py +++ b/tests/components/climacell/test_config_flow.py @@ -1,179 +1,27 @@ """Test the ClimaCell config flow.""" -from unittest.mock import patch - -from pyclimacell.exceptions import ( - CantConnectException, - InvalidAPIKeyException, - RateLimitedException, - UnknownException, -) - from homeassistant import data_entry_flow -from homeassistant.components.climacell.config_flow import ( - _get_config_schema, - _get_unique_id, -) from homeassistant.components.climacell.const import ( CONF_TIMESTEP, - DEFAULT_NAME, DEFAULT_TIMESTEP, DOMAIN, ) from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import ( - CONF_API_KEY, - CONF_API_VERSION, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_NAME, -) from homeassistant.core import HomeAssistant -from .const import API_KEY, MIN_CONFIG +from .const import API_V3_ENTRY_DATA from tests.common import MockConfigEntry -async def test_user_flow_minimum_fields(hass: HomeAssistant) -> None: - """Test user config flow with minimum fields.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=_get_config_schema(hass, MIN_CONFIG)(MIN_CONFIG), - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == DEFAULT_NAME - assert result["data"][CONF_NAME] == DEFAULT_NAME - assert result["data"][CONF_API_KEY] == API_KEY - assert result["data"][CONF_API_VERSION] == 4 - assert result["data"][CONF_LATITUDE] == hass.config.latitude - assert result["data"][CONF_LONGITUDE] == hass.config.longitude - - -async def test_user_flow_v3(hass: HomeAssistant) -> None: - """Test user config flow with v3 API.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - - data = _get_config_schema(hass, MIN_CONFIG)(MIN_CONFIG) - data[CONF_API_VERSION] = 3 - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=data, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == DEFAULT_NAME - assert result["data"][CONF_NAME] == DEFAULT_NAME - assert result["data"][CONF_API_KEY] == API_KEY - assert result["data"][CONF_API_VERSION] == 3 - assert result["data"][CONF_LATITUDE] == hass.config.latitude - assert result["data"][CONF_LONGITUDE] == hass.config.longitude - - -async def test_user_flow_same_unique_ids(hass: HomeAssistant) -> None: - """Test user config flow with the same unique ID as an existing entry.""" - user_input = _get_config_schema(hass, MIN_CONFIG)(MIN_CONFIG) - MockConfigEntry( - domain=DOMAIN, - data=user_input, - source=SOURCE_USER, - unique_id=_get_unique_id(hass, user_input), - version=2, - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=user_input, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -async def test_user_flow_cannot_connect(hass: HomeAssistant) -> None: - """Test user config flow when ClimaCell can't connect.""" - with patch( - "homeassistant.components.climacell.config_flow.ClimaCellV4.realtime", - side_effect=CantConnectException, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=_get_config_schema(hass, MIN_CONFIG)(MIN_CONFIG), - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_user_flow_invalid_api(hass: HomeAssistant) -> None: - """Test user config flow when API key is invalid.""" - with patch( - "homeassistant.components.climacell.config_flow.ClimaCellV4.realtime", - side_effect=InvalidAPIKeyException, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=_get_config_schema(hass, MIN_CONFIG)(MIN_CONFIG), - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} - - -async def test_user_flow_rate_limited(hass: HomeAssistant) -> None: - """Test user config flow when API key is rate limited.""" - with patch( - "homeassistant.components.climacell.config_flow.ClimaCellV4.realtime", - side_effect=RateLimitedException, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=_get_config_schema(hass, MIN_CONFIG)(MIN_CONFIG), - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {CONF_API_KEY: "rate_limited"} - - -async def test_user_flow_unknown_exception(hass: HomeAssistant) -> None: - """Test user config flow when unknown error occurs.""" - with patch( - "homeassistant.components.climacell.config_flow.ClimaCellV4.realtime", - side_effect=UnknownException, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=_get_config_schema(hass, MIN_CONFIG)(MIN_CONFIG), - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "unknown"} - - -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, climacell_config_entry_update: None +) -> None: """Test options config flow for climacell.""" - user_config = _get_config_schema(hass)(MIN_CONFIG) entry = MockConfigEntry( domain=DOMAIN, - data=user_config, + data=API_V3_ENTRY_DATA, source=SOURCE_USER, - unique_id=_get_unique_id(hass, user_config), + unique_id="test", version=1, ) entry.add_to_hass(hass) diff --git a/tests/components/climacell/test_init.py b/tests/components/climacell/test_init.py index 5ee50c6d0ec23..baddd46c19d6d 100644 --- a/tests/components/climacell/test_init.py +++ b/tests/components/climacell/test_init.py @@ -1,16 +1,14 @@ """Tests for Climacell init.""" +from unittest.mock import patch + import pytest -from homeassistant.components.climacell.config_flow import ( - _get_config_schema, - _get_unique_id, -) from homeassistant.components.climacell.const import CONF_TIMESTEP, DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.const import CONF_API_VERSION from homeassistant.core import HomeAssistant -from .const import API_V3_ENTRY_DATA, MIN_CONFIG, V1_ENTRY_DATA +from .const import API_V3_ENTRY_DATA from tests.common import MockConfigEntry @@ -20,11 +18,10 @@ async def test_load_and_unload( climacell_config_entry_update: pytest.fixture, ) -> None: """Test loading and unloading entry.""" - data = _get_config_schema(hass)(MIN_CONFIG) config_entry = MockConfigEntry( domain=DOMAIN, - data=data, - unique_id=_get_unique_id(hass, data), + data=API_V3_ENTRY_DATA, + unique_id="test", version=1, ) config_entry.add_to_hass(hass) @@ -42,11 +39,10 @@ async def test_v3_load_and_unload( climacell_config_entry_update: pytest.fixture, ) -> None: """Test loading and unloading v3 entry.""" - data = _get_config_schema(hass)(API_V3_ENTRY_DATA) config_entry = MockConfigEntry( domain=DOMAIN, - data=data, - unique_id=_get_unique_id(hass, data), + data={k: v for k, v in API_V3_ENTRY_DATA.items() if k != CONF_API_VERSION}, + unique_id="test", version=1, ) config_entry.add_to_hass(hass) @@ -59,6 +55,29 @@ async def test_v3_load_and_unload( assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 0 +async def test_v4_load_and_unload( + hass: HomeAssistant, + climacell_config_entry_update: pytest.fixture, +) -> None: + """Test loading and unloading v3 entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_VERSION: 4, + **{k: v for k, v in API_V3_ENTRY_DATA.items() if k != CONF_API_VERSION}, + }, + unique_id="test", + version=1, + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.tomorrowio.async_setup_entry", return_value=True + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 0 + + @pytest.mark.parametrize( "old_timestep, new_timestep", [(2, 1), (7, 5), (20, 15), (21, 30)] ) @@ -71,9 +90,9 @@ async def test_migrate_timestep( """Test migration to standardized timestep.""" config_entry = MockConfigEntry( domain=DOMAIN, - data=V1_ENTRY_DATA, + data=API_V3_ENTRY_DATA, options={CONF_TIMESTEP: old_timestep}, - unique_id=_get_unique_id(hass, V1_ENTRY_DATA), + unique_id="test", version=1, ) config_entry.add_to_hass(hass) diff --git a/tests/components/climacell/test_sensor.py b/tests/components/climacell/test_sensor.py index 8c075942cea9b..3412a5c35f0af 100644 --- a/tests/components/climacell/test_sensor.py +++ b/tests/components/climacell/test_sensor.py @@ -7,10 +7,6 @@ import pytest -from homeassistant.components.climacell.config_flow import ( - _get_config_schema, - _get_unique_id, -) from homeassistant.components.climacell.const import ATTRIBUTION, DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ATTR_ATTRIBUTION @@ -18,7 +14,7 @@ from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util -from .const import API_V3_ENTRY_DATA, API_V4_ENTRY_DATA +from .const import API_V3_ENTRY_DATA from tests.common import MockConfigEntry @@ -105,11 +101,10 @@ async def _setup( "homeassistant.util.dt.utcnow", return_value=datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC), ): - data = _get_config_schema(hass)(config) config_entry = MockConfigEntry( domain=DOMAIN, - data=data, - unique_id=_get_unique_id(hass, data), + data=config, + unique_id="test", version=1, ) config_entry.add_to_hass(hass) @@ -151,36 +146,3 @@ async def test_v3_sensor( check_sensor_state(hass, GRASS_POLLEN, "minimal_to_none") check_sensor_state(hass, WEED_POLLEN, "minimal_to_none") check_sensor_state(hass, TREE_POLLEN, "minimal_to_none") - - -async def test_v4_sensor( - hass: HomeAssistant, - climacell_config_entry_update: pytest.fixture, -) -> None: - """Test v4 sensor data.""" - await _setup(hass, V4_FIELDS, API_V4_ENTRY_DATA) - check_sensor_state(hass, O3, "46.53") - check_sensor_state(hass, CO, "0.63") - check_sensor_state(hass, NO2, "10.67") - check_sensor_state(hass, SO2, "1.65") - check_sensor_state(hass, PM25, "5.2972") - check_sensor_state(hass, PM10, "20.1294") - check_sensor_state(hass, MEP_AQI, "23") - check_sensor_state(hass, MEP_HEALTH_CONCERN, "good") - check_sensor_state(hass, MEP_PRIMARY_POLLUTANT, "pm10") - check_sensor_state(hass, EPA_AQI, "24") - check_sensor_state(hass, EPA_HEALTH_CONCERN, "good") - check_sensor_state(hass, EPA_PRIMARY_POLLUTANT, "pm25") - check_sensor_state(hass, FIRE_INDEX, "10") - check_sensor_state(hass, GRASS_POLLEN, "none") - check_sensor_state(hass, WEED_POLLEN, "none") - check_sensor_state(hass, TREE_POLLEN, "none") - check_sensor_state(hass, FEELS_LIKE, "38.5") - check_sensor_state(hass, DEW_POINT, "22.6778") - check_sensor_state(hass, PRESSURE_SURFACE_LEVEL, "997.9688") - check_sensor_state(hass, GHI, "0.0") - check_sensor_state(hass, CLOUD_BASE, "1.1909") - check_sensor_state(hass, CLOUD_COVER, "100") - check_sensor_state(hass, CLOUD_CEILING, "1.1909") - check_sensor_state(hass, WIND_GUST, "5.6506") - check_sensor_state(hass, PRECIPITATION_TYPE, "rain") diff --git a/tests/components/climacell/test_weather.py b/tests/components/climacell/test_weather.py index e3e15889e4474..d5385b6cfd588 100644 --- a/tests/components/climacell/test_weather.py +++ b/tests/components/climacell/test_weather.py @@ -7,10 +7,6 @@ import pytest -from homeassistant.components.climacell.config_flow import ( - _get_config_schema, - _get_unique_id, -) from homeassistant.components.climacell.const import ( ATTR_CLOUD_COVER, ATTR_PRECIPITATION_TYPE, @@ -30,8 +26,6 @@ ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_OZONE, ATTR_WEATHER_PRESSURE, @@ -46,7 +40,7 @@ from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util -from .const import API_V3_ENTRY_DATA, API_V4_ENTRY_DATA +from .const import API_V3_ENTRY_DATA from tests.common import MockConfigEntry @@ -69,11 +63,10 @@ async def _setup(hass: HomeAssistant, config: dict[str, Any]) -> State: "homeassistant.util.dt.utcnow", return_value=datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC), ): - data = _get_config_schema(hass)(config) config_entry = MockConfigEntry( domain=DOMAIN, - data=data, - unique_id=_get_unique_id(hass, data), + data=config, + unique_id="test", version=1, ) config_entry.add_to_hass(hass) @@ -98,7 +91,7 @@ async def test_v3_weather( assert weather_state.attributes[ATTR_FORECAST] == [ { ATTR_FORECAST_CONDITION: ATTR_CONDITION_SUNNY, - ATTR_FORECAST_TIME: "2021-03-07T00:00:00+00:00", + ATTR_FORECAST_TIME: "2021-03-07T00:00:00-08:00", ATTR_FORECAST_PRECIPITATION: 0, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, ATTR_FORECAST_TEMP: 7, @@ -106,7 +99,7 @@ async def test_v3_weather( }, { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, - ATTR_FORECAST_TIME: "2021-03-08T00:00:00+00:00", + ATTR_FORECAST_TIME: "2021-03-08T00:00:00-08:00", ATTR_FORECAST_PRECIPITATION: 0, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, ATTR_FORECAST_TEMP: 10, @@ -114,7 +107,7 @@ async def test_v3_weather( }, { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, - ATTR_FORECAST_TIME: "2021-03-09T00:00:00+00:00", + ATTR_FORECAST_TIME: "2021-03-09T00:00:00-08:00", ATTR_FORECAST_PRECIPITATION: 0, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, ATTR_FORECAST_TEMP: 19, @@ -122,7 +115,7 @@ async def test_v3_weather( }, { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, - ATTR_FORECAST_TIME: "2021-03-10T00:00:00+00:00", + ATTR_FORECAST_TIME: "2021-03-10T00:00:00-08:00", ATTR_FORECAST_PRECIPITATION: 0, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, ATTR_FORECAST_TEMP: 18, @@ -130,7 +123,7 @@ async def test_v3_weather( }, { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, - ATTR_FORECAST_TIME: "2021-03-11T00:00:00+00:00", + ATTR_FORECAST_TIME: "2021-03-11T00:00:00-08:00", ATTR_FORECAST_PRECIPITATION: 0, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 5, ATTR_FORECAST_TEMP: 20, @@ -138,7 +131,7 @@ async def test_v3_weather( }, { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, - ATTR_FORECAST_TIME: "2021-03-12T00:00:00+00:00", + ATTR_FORECAST_TIME: "2021-03-12T00:00:00-08:00", ATTR_FORECAST_PRECIPITATION: 0.0457, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 25, ATTR_FORECAST_TEMP: 20, @@ -146,7 +139,7 @@ async def test_v3_weather( }, { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, - ATTR_FORECAST_TIME: "2021-03-13T00:00:00+00:00", + ATTR_FORECAST_TIME: "2021-03-13T00:00:00-08:00", ATTR_FORECAST_PRECIPITATION: 0, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 25, ATTR_FORECAST_TEMP: 16, @@ -154,7 +147,7 @@ async def test_v3_weather( }, { ATTR_FORECAST_CONDITION: ATTR_CONDITION_RAINY, - ATTR_FORECAST_TIME: "2021-03-14T00:00:00+00:00", + ATTR_FORECAST_TIME: "2021-03-14T00:00:00-08:00", ATTR_FORECAST_PRECIPITATION: 1.0744, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 75, ATTR_FORECAST_TEMP: 6, @@ -162,7 +155,7 @@ async def test_v3_weather( }, { ATTR_FORECAST_CONDITION: ATTR_CONDITION_SNOWY, - ATTR_FORECAST_TIME: "2021-03-15T00:00:00+00:00", + ATTR_FORECAST_TIME: "2021-03-15T00:00:00-07:00", # DST starts ATTR_FORECAST_PRECIPITATION: 7.3050, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 95, ATTR_FORECAST_TEMP: 1, @@ -170,7 +163,7 @@ async def test_v3_weather( }, { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, - ATTR_FORECAST_TIME: "2021-03-16T00:00:00+00:00", + ATTR_FORECAST_TIME: "2021-03-16T00:00:00-07:00", ATTR_FORECAST_PRECIPITATION: 0.0051, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 5, ATTR_FORECAST_TEMP: 6, @@ -178,7 +171,7 @@ async def test_v3_weather( }, { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, - ATTR_FORECAST_TIME: "2021-03-17T00:00:00+00:00", + ATTR_FORECAST_TIME: "2021-03-17T00:00:00-07:00", ATTR_FORECAST_PRECIPITATION: 0, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, ATTR_FORECAST_TEMP: 11, @@ -186,7 +179,7 @@ async def test_v3_weather( }, { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, - ATTR_FORECAST_TIME: "2021-03-18T00:00:00+00:00", + ATTR_FORECAST_TIME: "2021-03-18T00:00:00-07:00", ATTR_FORECAST_PRECIPITATION: 0, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 5, ATTR_FORECAST_TEMP: 12, @@ -194,7 +187,7 @@ async def test_v3_weather( }, { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, - ATTR_FORECAST_TIME: "2021-03-19T00:00:00+00:00", + ATTR_FORECAST_TIME: "2021-03-19T00:00:00-07:00", ATTR_FORECAST_PRECIPITATION: 0.1778, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 45, ATTR_FORECAST_TEMP: 9, @@ -202,7 +195,7 @@ async def test_v3_weather( }, { ATTR_FORECAST_CONDITION: ATTR_CONDITION_RAINY, - ATTR_FORECAST_TIME: "2021-03-20T00:00:00+00:00", + ATTR_FORECAST_TIME: "2021-03-20T00:00:00-07:00", ATTR_FORECAST_PRECIPITATION: 1.2319, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 55, ATTR_FORECAST_TEMP: 5, @@ -210,7 +203,7 @@ async def test_v3_weather( }, { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, - ATTR_FORECAST_TIME: "2021-03-21T00:00:00+00:00", + ATTR_FORECAST_TIME: "2021-03-21T00:00:00-07:00", ATTR_FORECAST_PRECIPITATION: 0.0432, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 20, ATTR_FORECAST_TEMP: 7, @@ -228,166 +221,3 @@ async def test_v3_weather( assert weather_state.attributes[ATTR_CLOUD_COVER] == 100 assert weather_state.attributes[ATTR_WIND_GUST] == 24.0758 assert weather_state.attributes[ATTR_PRECIPITATION_TYPE] == "rain" - - -async def test_v4_weather( - hass: HomeAssistant, - climacell_config_entry_update: pytest.fixture, -) -> None: - """Test v4 weather data.""" - weather_state = await _setup(hass, API_V4_ENTRY_DATA) - assert weather_state.state == ATTR_CONDITION_SUNNY - assert weather_state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION - assert weather_state.attributes[ATTR_FORECAST] == [ - { - ATTR_FORECAST_CONDITION: ATTR_CONDITION_SUNNY, - ATTR_FORECAST_TIME: "2021-03-07T11:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 0, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, - ATTR_FORECAST_TEMP: 8, - ATTR_FORECAST_TEMP_LOW: -3, - ATTR_FORECAST_WIND_BEARING: 239.6, - ATTR_FORECAST_WIND_SPEED: 15.2727, - }, - { - ATTR_FORECAST_CONDITION: "cloudy", - ATTR_FORECAST_TIME: "2021-03-08T11:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 0, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, - ATTR_FORECAST_TEMP: 10, - ATTR_FORECAST_TEMP_LOW: -3, - ATTR_FORECAST_WIND_BEARING: 262.82, - ATTR_FORECAST_WIND_SPEED: 11.6517, - }, - { - ATTR_FORECAST_CONDITION: "cloudy", - ATTR_FORECAST_TIME: "2021-03-09T11:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 0, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, - ATTR_FORECAST_TEMP: 19, - ATTR_FORECAST_TEMP_LOW: 0, - ATTR_FORECAST_WIND_BEARING: 229.3, - ATTR_FORECAST_WIND_SPEED: 11.3459, - }, - { - ATTR_FORECAST_CONDITION: "cloudy", - ATTR_FORECAST_TIME: "2021-03-10T11:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 0, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, - ATTR_FORECAST_TEMP: 18, - ATTR_FORECAST_TEMP_LOW: 3, - ATTR_FORECAST_WIND_BEARING: 149.91, - ATTR_FORECAST_WIND_SPEED: 17.1234, - }, - { - ATTR_FORECAST_CONDITION: "cloudy", - ATTR_FORECAST_TIME: "2021-03-11T11:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 0, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, - ATTR_FORECAST_TEMP: 19, - ATTR_FORECAST_TEMP_LOW: 9, - ATTR_FORECAST_WIND_BEARING: 210.45, - ATTR_FORECAST_WIND_SPEED: 25.2506, - }, - { - ATTR_FORECAST_CONDITION: "rainy", - ATTR_FORECAST_TIME: "2021-03-12T11:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 0.1219, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: 25, - ATTR_FORECAST_TEMP: 20, - ATTR_FORECAST_TEMP_LOW: 12, - ATTR_FORECAST_WIND_BEARING: 217.98, - ATTR_FORECAST_WIND_SPEED: 19.7949, - }, - { - ATTR_FORECAST_CONDITION: "cloudy", - ATTR_FORECAST_TIME: "2021-03-13T11:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 0, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: 25, - ATTR_FORECAST_TEMP: 12, - ATTR_FORECAST_TEMP_LOW: 6, - ATTR_FORECAST_WIND_BEARING: 58.79, - ATTR_FORECAST_WIND_SPEED: 15.6428, - }, - { - ATTR_FORECAST_CONDITION: "snowy", - ATTR_FORECAST_TIME: "2021-03-14T10:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 23.9573, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: 95, - ATTR_FORECAST_TEMP: 6, - ATTR_FORECAST_TEMP_LOW: 1, - ATTR_FORECAST_WIND_BEARING: 70.25, - ATTR_FORECAST_WIND_SPEED: 26.1518, - }, - { - ATTR_FORECAST_CONDITION: "snowy", - ATTR_FORECAST_TIME: "2021-03-15T10:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 1.4630, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: 55, - ATTR_FORECAST_TEMP: 6, - ATTR_FORECAST_TEMP_LOW: -1, - ATTR_FORECAST_WIND_BEARING: 84.47, - ATTR_FORECAST_WIND_SPEED: 25.5725, - }, - { - ATTR_FORECAST_CONDITION: "cloudy", - ATTR_FORECAST_TIME: "2021-03-16T10:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 0, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, - ATTR_FORECAST_TEMP: 6, - ATTR_FORECAST_TEMP_LOW: -2, - ATTR_FORECAST_WIND_BEARING: 103.85, - ATTR_FORECAST_WIND_SPEED: 10.7987, - }, - { - ATTR_FORECAST_CONDITION: "cloudy", - ATTR_FORECAST_TIME: "2021-03-17T10:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 0, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, - ATTR_FORECAST_TEMP: 11, - ATTR_FORECAST_TEMP_LOW: 1, - ATTR_FORECAST_WIND_BEARING: 145.41, - ATTR_FORECAST_WIND_SPEED: 11.6999, - }, - { - ATTR_FORECAST_CONDITION: "cloudy", - ATTR_FORECAST_TIME: "2021-03-18T10:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 0, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: 10, - ATTR_FORECAST_TEMP: 12, - ATTR_FORECAST_TEMP_LOW: 5, - ATTR_FORECAST_WIND_BEARING: 62.99, - ATTR_FORECAST_WIND_SPEED: 10.5895, - }, - { - ATTR_FORECAST_CONDITION: "rainy", - ATTR_FORECAST_TIME: "2021-03-19T10:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 2.9261, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: 55, - ATTR_FORECAST_TEMP: 9, - ATTR_FORECAST_TEMP_LOW: 4, - ATTR_FORECAST_WIND_BEARING: 68.54, - ATTR_FORECAST_WIND_SPEED: 22.3860, - }, - { - ATTR_FORECAST_CONDITION: "snowy", - ATTR_FORECAST_TIME: "2021-03-20T10:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 1.2192, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: 33.3, - ATTR_FORECAST_TEMP: 5, - ATTR_FORECAST_TEMP_LOW: 2, - ATTR_FORECAST_WIND_BEARING: 56.98, - ATTR_FORECAST_WIND_SPEED: 27.9221, - }, - ] - assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "ClimaCell - Daily" - assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 23 - assert weather_state.attributes[ATTR_WEATHER_OZONE] == 46.53 - assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 1027.7691 - assert weather_state.attributes[ATTR_WEATHER_TEMPERATURE] == 7 - assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 13.1162 - assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 315.14 - assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 15.0152 - assert weather_state.attributes[ATTR_CLOUD_COVER] == 100 - assert weather_state.attributes[ATTR_WIND_GUST] == 20.3421 - assert weather_state.attributes[ATTR_PRECIPITATION_TYPE] == "rain" diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index a24e0961f9cc7..6366eca4c6d6c 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -23,6 +23,13 @@ ) +@pytest.fixture +def clear_handlers(): + """Clear config entry handlers.""" + with patch.dict(HANDLERS, clear=True): + yield + + @pytest.fixture(autouse=True) def mock_test_component(hass): """Ensure a component called 'test' exists.""" @@ -30,104 +37,133 @@ def mock_test_component(hass): @pytest.fixture -def client(hass, hass_client): +async def client(hass, hass_client): """Fixture that can interact with the config manager API.""" - hass.loop.run_until_complete(async_setup_component(hass, "http", {})) - hass.loop.run_until_complete(config_entries.async_setup(hass)) - yield hass.loop.run_until_complete(hass_client()) + await async_setup_component(hass, "http", {}) + await config_entries.async_setup(hass) + return await hass_client() -async def test_get_entries(hass, client): +async def test_get_entries(hass, client, clear_handlers): """Test get entries.""" - with patch.dict(HANDLERS, clear=True): + mock_integration(hass, MockModule("comp1")) + mock_integration( + hass, MockModule("comp2", partial_manifest={"integration_type": "helper"}) + ) + mock_integration(hass, MockModule("comp3")) - @HANDLERS.register("comp1") - class Comp1ConfigFlow: - """Config flow with options flow.""" + @HANDLERS.register("comp1") + class Comp1ConfigFlow: + """Config flow with options flow.""" - @staticmethod - @callback - def async_get_options_flow(config_entry): - """Get options flow.""" - pass + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get options flow.""" + pass - @classmethod - @callback - def async_supports_options_flow(cls, config_entry): - """Return options flow support for this handler.""" - return True + @classmethod + @callback + def async_supports_options_flow(cls, config_entry): + """Return options flow support for this handler.""" + return True - hass.helpers.config_entry_flow.register_discovery_flow( - "comp2", "Comp 2", lambda: None - ) + hass.helpers.config_entry_flow.register_discovery_flow( + "comp2", "Comp 2", lambda: None + ) - entry = MockConfigEntry( - domain="comp1", - title="Test 1", - source="bla", - ) - entry.supports_unload = True - entry.add_to_hass(hass) - MockConfigEntry( - domain="comp2", - title="Test 2", - source="bla2", - state=core_ce.ConfigEntryState.SETUP_ERROR, - reason="Unsupported API", - ).add_to_hass(hass) - MockConfigEntry( - domain="comp3", - title="Test 3", - source="bla3", - disabled_by=core_ce.ConfigEntryDisabler.USER, - ).add_to_hass(hass) - - resp = await client.get("/api/config/config_entries/entry") - assert resp.status == HTTPStatus.OK - data = await resp.json() - for entry in data: - entry.pop("entry_id") - assert data == [ - { - "domain": "comp1", - "title": "Test 1", - "source": "bla", - "state": core_ce.ConfigEntryState.NOT_LOADED.value, - "supports_options": True, - "supports_remove_device": False, - "supports_unload": True, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "disabled_by": None, - "reason": None, - }, - { - "domain": "comp2", - "title": "Test 2", - "source": "bla2", - "state": core_ce.ConfigEntryState.SETUP_ERROR.value, - "supports_options": False, - "supports_remove_device": False, - "supports_unload": False, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "disabled_by": None, - "reason": "Unsupported API", - }, - { - "domain": "comp3", - "title": "Test 3", - "source": "bla3", - "state": core_ce.ConfigEntryState.NOT_LOADED.value, - "supports_options": False, - "supports_remove_device": False, - "supports_unload": False, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "disabled_by": core_ce.ConfigEntryDisabler.USER, - "reason": None, - }, - ] + entry = MockConfigEntry( + domain="comp1", + title="Test 1", + source="bla", + ) + entry.supports_unload = True + entry.add_to_hass(hass) + MockConfigEntry( + domain="comp2", + title="Test 2", + source="bla2", + state=core_ce.ConfigEntryState.SETUP_ERROR, + reason="Unsupported API", + ).add_to_hass(hass) + MockConfigEntry( + domain="comp3", + title="Test 3", + source="bla3", + disabled_by=core_ce.ConfigEntryDisabler.USER, + ).add_to_hass(hass) + + resp = await client.get("/api/config/config_entries/entry") + assert resp.status == HTTPStatus.OK + data = await resp.json() + for entry in data: + entry.pop("entry_id") + assert data == [ + { + "domain": "comp1", + "title": "Test 1", + "source": "bla", + "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supports_options": True, + "supports_remove_device": False, + "supports_unload": True, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "disabled_by": None, + "reason": None, + }, + { + "domain": "comp2", + "title": "Test 2", + "source": "bla2", + "state": core_ce.ConfigEntryState.SETUP_ERROR.value, + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "disabled_by": None, + "reason": "Unsupported API", + }, + { + "domain": "comp3", + "title": "Test 3", + "source": "bla3", + "state": core_ce.ConfigEntryState.NOT_LOADED.value, + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "disabled_by": core_ce.ConfigEntryDisabler.USER, + "reason": None, + }, + ] + + resp = await client.get("/api/config/config_entries/entry?domain=comp3") + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert len(data) == 1 + assert data[0]["domain"] == "comp3" + + resp = await client.get("/api/config/config_entries/entry?domain=comp3&type=helper") + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert len(data) == 0 + + resp = await client.get( + "/api/config/config_entries/entry?domain=comp3&type=integration" + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert len(data) == 1 + + resp = await client.get("/api/config/config_entries/entry?type=integration") + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert len(data) == 2 + assert data[0]["domain"] == "comp1" + assert data[1]["domain"] == "comp3" async def test_remove_entry(hass, client): @@ -224,13 +260,28 @@ async def test_reload_entry_in_setup_retry(hass, client, hass_admin_user): assert len(hass.config_entries.async_entries()) == 1 -async def test_available_flows(hass, client): +@pytest.mark.parametrize( + "type_filter,result", + ( + (None, {"hello", "another", "world"}), + ("integration", {"hello", "another"}), + ("helper", {"world"}), + ), +) +async def test_available_flows(hass, client, type_filter, result): """Test querying the available flows.""" - with patch.object(config_flows, "FLOWS", ["hello", "world"]): - resp = await client.get("/api/config/config_entries/flow_handlers") + with patch.object( + config_flows, + "FLOWS", + {"integration": ["hello", "another"], "helper": ["world"]}, + ): + resp = await client.get( + "/api/config/config_entries/flow_handlers", + params={"type": type_filter} if type_filter else {}, + ) assert resp.status == HTTPStatus.OK data = await resp.json() - assert set(data) == {"hello", "world"} + assert set(data) == result ############################ diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index b78ed50cdf299..33309f6b6c6b9 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -10,8 +10,6 @@ from homeassistant.const import CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_IMPERIAL from homeassistant.util import dt as dt_util, location -ORIG_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE - @pytest.fixture async def client(hass, hass_ws_client): diff --git a/tests/components/demo/test_update.py b/tests/components/demo/test_update.py new file mode 100644 index 0000000000000..37e0ea903d1da --- /dev/null +++ b/tests/components/demo/test_update.py @@ -0,0 +1,157 @@ +"""The tests for the demo update platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.update import DOMAIN, SERVICE_INSTALL, UpdateDeviceClass +from homeassistant.components.update.const import ( + ATTR_CURRENT_VERSION, + ATTR_IN_PROGRESS, + ATTR_LATEST_VERSION, + ATTR_RELEASE_SUMMARY, + ATTR_RELEASE_URL, + ATTR_TITLE, +) +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +async def setup_demo_update(hass: HomeAssistant) -> None: + """Initialize setup demo update entity.""" + assert await async_setup_component(hass, DOMAIN, {"update": {"platform": "demo"}}) + await hass.async_block_till_done() + + +def test_setup_params(hass: HomeAssistant) -> None: + """Test the initial parameters.""" + state = hass.states.get("update.demo_update_no_install") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_TITLE] == "Awesomesoft Inc." + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert ( + state.attributes[ATTR_RELEASE_SUMMARY] == "Awesome update, fixing everything!" + ) + assert state.attributes[ATTR_RELEASE_URL] == "https://www.example.com/release/1.0.1" + + state = hass.states.get("update.demo_no_update") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_TITLE] == "AdGuard Home" + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.0" + assert state.attributes[ATTR_RELEASE_SUMMARY] is None + assert state.attributes[ATTR_RELEASE_URL] is None + + state = hass.states.get("update.demo_add_on") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_TITLE] == "AdGuard Home" + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert ( + state.attributes[ATTR_RELEASE_SUMMARY] == "Awesome update, fixing everything!" + ) + assert state.attributes[ATTR_RELEASE_URL] == "https://www.example.com/release/1.0.1" + + state = hass.states.get("update.demo_living_room_bulb_update") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_TITLE] == "Philips Lamps Firmware" + assert state.attributes[ATTR_CURRENT_VERSION] == "1.93.3" + assert state.attributes[ATTR_LATEST_VERSION] == "1.94.2" + assert state.attributes[ATTR_RELEASE_SUMMARY] == "Added support for effects" + assert ( + state.attributes[ATTR_RELEASE_URL] == "https://www.example.com/release/1.93.3" + ) + assert state.attributes[ATTR_DEVICE_CLASS] == UpdateDeviceClass.FIRMWARE + + state = hass.states.get("update.demo_update_with_progress") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_TITLE] == "Philips Lamps Firmware" + assert state.attributes[ATTR_CURRENT_VERSION] == "1.93.3" + assert state.attributes[ATTR_LATEST_VERSION] == "1.94.2" + assert state.attributes[ATTR_RELEASE_SUMMARY] == "Added support for effects" + assert ( + state.attributes[ATTR_RELEASE_URL] == "https://www.example.com/release/1.93.3" + ) + assert state.attributes[ATTR_DEVICE_CLASS] == UpdateDeviceClass.FIRMWARE + + +async def test_update_with_progress(hass: HomeAssistant) -> None: + """Test update with progress.""" + state = hass.states.get("update.demo_update_with_progress") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_IN_PROGRESS] is False + + events = [] + async_track_state_change_event( + hass, + "update.demo_update_with_progress", + callback(lambda event: events.append(event)), + ) + + with patch("homeassistant.components.demo.update.FAKE_INSTALL_SLEEP_TIME", new=0): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.demo_update_with_progress"}, + blocking=True, + ) + + assert len(events) == 10 + assert events[0].data["new_state"].state == STATE_ON + assert events[0].data["new_state"].attributes[ATTR_IN_PROGRESS] == 10 + assert events[1].data["new_state"].attributes[ATTR_IN_PROGRESS] == 20 + assert events[2].data["new_state"].attributes[ATTR_IN_PROGRESS] == 30 + assert events[3].data["new_state"].attributes[ATTR_IN_PROGRESS] == 40 + assert events[4].data["new_state"].attributes[ATTR_IN_PROGRESS] == 50 + assert events[5].data["new_state"].attributes[ATTR_IN_PROGRESS] == 60 + assert events[6].data["new_state"].attributes[ATTR_IN_PROGRESS] == 70 + assert events[7].data["new_state"].attributes[ATTR_IN_PROGRESS] == 80 + assert events[8].data["new_state"].attributes[ATTR_IN_PROGRESS] == 90 + assert events[9].data["new_state"].attributes[ATTR_IN_PROGRESS] is False + assert events[9].data["new_state"].state == STATE_OFF + + +async def test_update_with_progress_raising(hass: HomeAssistant) -> None: + """Test update with progress failing to install.""" + state = hass.states.get("update.demo_update_with_progress") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_IN_PROGRESS] is False + + events = [] + async_track_state_change_event( + hass, + "update.demo_update_with_progress", + callback(lambda event: events.append(event)), + ) + + with patch( + "homeassistant.components.demo.update._fake_install", + side_effect=[None, None, None, None, RuntimeError], + ) as fake_sleep, pytest.raises(RuntimeError): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.demo_update_with_progress"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert fake_sleep.call_count == 5 + assert len(events) == 5 + assert events[0].data["new_state"].state == STATE_ON + assert events[0].data["new_state"].attributes[ATTR_IN_PROGRESS] == 10 + assert events[1].data["new_state"].attributes[ATTR_IN_PROGRESS] == 20 + assert events[2].data["new_state"].attributes[ATTR_IN_PROGRESS] == 30 + assert events[3].data["new_state"].attributes[ATTR_IN_PROGRESS] == 40 + assert events[4].data["new_state"].attributes[ATTR_IN_PROGRESS] is False + assert events[4].data["new_state"].state == STATE_ON diff --git a/tests/components/dynalite/test_cover.py b/tests/components/dynalite/test_cover.py index 4f696d905d357..fd671365ba117 100644 --- a/tests/components/dynalite/test_cover.py +++ b/tests/components/dynalite/test_cover.py @@ -3,6 +3,7 @@ import pytest from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME +from homeassistant.exceptions import HomeAssistantError from .common import ( ATTR_ARGS, @@ -65,9 +66,10 @@ async def test_cover_without_tilt(hass, mock_device): """Test a cover with no tilt.""" mock_device.has_tilt = False await create_entity_from_device(hass, mock_device) - await hass.services.async_call( - "cover", "open_cover_tilt", {"entity_id": "cover.name"}, blocking=True - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "cover", "open_cover_tilt", {"entity_id": "cover.name"}, blocking=True + ) await hass.async_block_till_done() mock_device.async_open_cover_tilt.assert_not_called() diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index 76179c02e22fe..caba229692774 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -6,6 +6,7 @@ from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.components.enphase_envoy.const import DOMAIN +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -312,8 +313,8 @@ async def test_zeroconf_serial_already_exists(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + host="4.4.4.4", + addresses=["4.4.4.4"], hostname="mock_hostname", name="mock_name", port=None, @@ -324,6 +325,42 @@ async def test_zeroconf_serial_already_exists(hass: HomeAssistant) -> None: assert result["type"] == "abort" assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] == "4.4.4.4" + + +async def test_zeroconf_serial_already_exists_ignores_ipv6(hass: HomeAssistant) -> None: + """Test serial number already exists from zeroconf but the discovery is ipv6.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "host": "1.1.1.1", + "name": "Envoy", + "username": "test-username", + "password": "test-password", + }, + unique_id="1234", + title="Envoy", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="fd00::b27c:63bb:cc85:4ea0", + addresses=["fd00::b27c:63bb:cc85:4ea0"], + hostname="mock_hostname", + name="mock_name", + port=None, + properties={"serialnum": "1234"}, + type="mock_type", + ), + ) + + assert result["type"] == "abort" + assert result["reason"] == "not_ipv4_address" + assert config_entry.data[CONF_HOST] == "1.1.1.1" async def test_zeroconf_host_already_exists(hass: HomeAssistant) -> None: diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index 275c10c5592aa..19d5c064e82b3 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -23,6 +23,12 @@ ) +@pytest.fixture(autouse=True) +def set_utc(hass): + """Set timezone to UTC.""" + hass.config.set_time_zone("UTC") + + async def test_valid_config(hass): """Test configuration.""" assert await async_setup_component( diff --git a/tests/components/forecast_solar/conftest.py b/tests/components/forecast_solar/conftest.py index 6e1adc14b7fc5..408c5423861a8 100644 --- a/tests/components/forecast_solar/conftest.py +++ b/tests/components/forecast_solar/conftest.py @@ -43,8 +43,11 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_forecast_solar() -> Generator[None, MagicMock, None]: - """Return a mocked Forecast.Solar client.""" +def mock_forecast_solar(hass) -> Generator[None, MagicMock, None]: + """Return a mocked Forecast.Solar client. + + hass fixture included because it sets the time zone. + """ with patch( "homeassistant.components.forecast_solar.ForecastSolar", autospec=True ) as forecast_solar_mock: diff --git a/tests/components/forecast_solar/test_sensor.py b/tests/components/forecast_solar/test_sensor.py index ee8afe5794b01..a05acb5bc167d 100644 --- a/tests/components/forecast_solar/test_sensor.py +++ b/tests/components/forecast_solar/test_sensor.py @@ -68,7 +68,7 @@ async def test_sensors( assert entry assert state assert entry.unique_id == f"{entry_id}_power_highest_peak_time_today" - assert state.state == "2021-06-27T13:00:00+00:00" + assert state.state == "2021-06-27T20:00:00+00:00" # Timestamp sensor is UTC assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Highest Power Peak Time - Today" assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP @@ -80,7 +80,7 @@ async def test_sensors( assert entry assert state assert entry.unique_id == f"{entry_id}_power_highest_peak_time_tomorrow" - assert state.state == "2021-06-27T14:00:00+00:00" + assert state.state == "2021-06-27T21:00:00+00:00" # Timestamp sensor is UTC assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Highest Power Peak Time - Tomorrow" ) diff --git a/tests/components/forked_daapd/test_media_player.py b/tests/components/forked_daapd/test_media_player.py index a2e0050c3d965..c18b8df3f1cee 100644 --- a/tests/components/forked_daapd/test_media_player.py +++ b/tests/components/forked_daapd/test_media_player.py @@ -557,7 +557,7 @@ async def test_async_play_media_from_paused(hass, mock_api_object): SERVICE_PLAY_MEDIA, { ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, - ATTR_MEDIA_CONTENT_ID: "somefile.mp3", + ATTR_MEDIA_CONTENT_ID: "http://example.com/somefile.mp3", }, ) state = hass.states.get(TEST_MASTER_ENTITY_NAME) @@ -581,7 +581,7 @@ async def test_async_play_media_from_stopped( SERVICE_PLAY_MEDIA, { ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, - ATTR_MEDIA_CONTENT_ID: "somefile.mp3", + ATTR_MEDIA_CONTENT_ID: "http://example.com/somefile.mp3", }, ) state = hass.states.get(TEST_MASTER_ENTITY_NAME) @@ -616,7 +616,7 @@ async def test_async_play_media_tts_timeout(hass, mock_api_object): SERVICE_PLAY_MEDIA, { ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, - ATTR_MEDIA_CONTENT_ID: "somefile.mp3", + ATTR_MEDIA_CONTENT_ID: "http://example.com/somefile.mp3", }, ) state = hass.states.get(TEST_MASTER_ENTITY_NAME) @@ -725,7 +725,7 @@ async def test_librespot_java_play_media(hass, pipe_control_api_object): SERVICE_PLAY_MEDIA, { ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, - ATTR_MEDIA_CONTENT_ID: "somefile.mp3", + ATTR_MEDIA_CONTENT_ID: "http://example.com/somefile.mp3", }, ) state = hass.states.get(TEST_MASTER_ENTITY_NAME) @@ -747,7 +747,7 @@ async def test_librespot_java_play_media_pause_timeout(hass, pipe_control_api_ob SERVICE_PLAY_MEDIA, { ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, - ATTR_MEDIA_CONTENT_ID: "somefile.mp3", + ATTR_MEDIA_CONTENT_ID: "http://example.com/somefile.mp3", }, ) state = hass.states.get(TEST_MASTER_ENTITY_NAME) diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index c3b8ae4e17282..102b8a1ccc6d9 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -15,13 +15,10 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry -ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE - ApiResult = Callable[[dict[str, Any]], None] ComponentSetup = Callable[[], Awaitable[bool]] _T = TypeVar("_T") @@ -252,10 +249,7 @@ def set_time_zone(hass): """Set the time zone for the tests.""" # Set our timezone to CST/Regina so we can check calculations # This keeps UTC-6 all year round - hass.config.time_zone = "CST" - dt_util.set_default_time_zone(dt_util.get_time_zone("America/Regina")) - yield - dt_util.set_default_time_zone(ORIG_TIMEZONE) + hass.config.set_time_zone("America/Regina") @pytest.fixture diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 6fc575ba03d01..49e10d137b6d8 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -417,11 +417,11 @@ async def test_add_event_date_time( "description": "Description", "start": { "dateTime": start_datetime.isoformat(timespec="seconds"), - "timeZone": "CST", + "timeZone": "America/Regina", }, "end": { "dateTime": end_datetime.isoformat(timespec="seconds"), - "timeZone": "CST", + "timeZone": "America/Regina", }, }, ) diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 18fa3dc7625d5..1590dcf1ed0d5 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -17,8 +17,12 @@ import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM -from tests.common import init_recorder_component -from tests.components.recorder.common import trigger_db_commit, wait_recording_done +from tests.common import async_init_recorder_component, init_recorder_component +from tests.components.recorder.common import ( + async_wait_recording_done_without_instance, + trigger_db_commit, + wait_recording_done, +) @pytest.mark.usefixtures("hass_history") @@ -604,14 +608,36 @@ async def test_fetch_period_api_with_use_include_order(hass, hass_client): async def test_fetch_period_api_with_minimal_response(hass, hass_client): """Test the fetch period view for history with minimal_response.""" - await hass.async_add_executor_job(init_recorder_component, hass) + await async_init_recorder_component(hass) + now = dt_util.utcnow() await async_setup_component(hass, "history", {}) - await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + hass.states.async_set("sensor.power", 0, {"attr": "any"}) + await async_wait_recording_done_without_instance(hass) + hass.states.async_set("sensor.power", 50, {"attr": "any"}) + await async_wait_recording_done_without_instance(hass) + hass.states.async_set("sensor.power", 23, {"attr": "any"}) + await async_wait_recording_done_without_instance(hass) client = await hass_client() response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}?minimal_response" + f"/api/history/period/{now.isoformat()}?filter_entity_id=sensor.power&minimal_response&no_attributes" ) assert response.status == HTTPStatus.OK + response_json = await response.json() + assert len(response_json[0]) == 3 + state_list = response_json[0] + + assert state_list[0]["entity_id"] == "sensor.power" + assert state_list[0]["attributes"] == {} + assert state_list[0]["state"] == "0" + + assert "attributes" not in state_list[1] + assert "entity_id" not in state_list[1] + assert state_list[1]["state"] == "50" + + assert state_list[2]["entity_id"] == "sensor.power" + assert state_list[2]["attributes"] == {} + assert state_list[2]["state"] == "23" async def test_fetch_period_api_with_no_timestamp(hass, hass_client): diff --git a/tests/components/input_datetime/test_init.py b/tests/components/input_datetime/test_init.py index 0a968caf67f75..28ca2ab02bd42 100644 --- a/tests/components/input_datetime/test_init.py +++ b/tests/components/input_datetime/test_init.py @@ -38,8 +38,6 @@ INITIAL_TIME = "23:45:56" INITIAL_DATETIME = f"{INITIAL_DATE} {INITIAL_TIME}" -ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE - @pytest.fixture def storage_setup(hass, hass_storage): @@ -131,7 +129,9 @@ async def test_set_datetime(hass): entity_id = "input_datetime.test_datetime" - dt_obj = datetime.datetime(2017, 9, 7, 19, 46, 30, tzinfo=datetime.timezone.utc) + dt_obj = datetime.datetime( + 2017, 9, 7, 19, 46, 30, tzinfo=dt_util.get_time_zone(hass.config.time_zone) + ) await async_set_date_and_time(hass, entity_id, dt_obj) @@ -157,7 +157,9 @@ async def test_set_datetime_2(hass): entity_id = "input_datetime.test_datetime" - dt_obj = datetime.datetime(2017, 9, 7, 19, 46, 30, tzinfo=datetime.timezone.utc) + dt_obj = datetime.datetime( + 2017, 9, 7, 19, 46, 30, tzinfo=dt_util.get_time_zone(hass.config.time_zone) + ) await async_set_datetime(hass, entity_id, dt_obj) @@ -183,7 +185,9 @@ async def test_set_datetime_3(hass): entity_id = "input_datetime.test_datetime" - dt_obj = datetime.datetime(2017, 9, 7, 19, 46, 30, tzinfo=datetime.timezone.utc) + dt_obj = datetime.datetime( + 2017, 9, 7, 19, 46, 30, tzinfo=dt_util.get_time_zone(hass.config.time_zone) + ) await async_set_timestamp(hass, entity_id, dt_util.as_utc(dt_obj).timestamp()) @@ -649,101 +653,97 @@ async def test_setup_no_config(hass, hass_admin_user): async def test_timestamp(hass): """Test timestamp.""" - try: - dt_util.set_default_time_zone(dt_util.get_time_zone("America/Los_Angeles")) + hass.config.set_time_zone("America/Los_Angeles") - assert await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - "test_datetime_initial_with_tz": { - "has_time": True, - "has_date": True, - "initial": "2020-12-13 10:00:00+01:00", - }, - "test_datetime_initial_without_tz": { - "has_time": True, - "has_date": True, - "initial": "2020-12-13 10:00:00", - }, - "test_time_initial": { - "has_time": True, - "has_date": False, - "initial": "10:00:00", - }, - } - }, - ) - - # initial has been converted to the set timezone - state_with_tz = hass.states.get("input_datetime.test_datetime_initial_with_tz") - assert state_with_tz is not None - # Timezone LA is UTC-8 => timestamp carries +01:00 => delta is -9 => 10:00 - 09:00 => 01:00 - assert state_with_tz.state == "2020-12-13 01:00:00" - assert ( - dt_util.as_local( - dt_util.utc_from_timestamp(state_with_tz.attributes[ATTR_TIMESTAMP]) - ).strftime(FMT_DATETIME) - == "2020-12-13 01:00:00" - ) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "test_datetime_initial_with_tz": { + "has_time": True, + "has_date": True, + "initial": "2020-12-13 10:00:00+01:00", + }, + "test_datetime_initial_without_tz": { + "has_time": True, + "has_date": True, + "initial": "2020-12-13 10:00:00", + }, + "test_time_initial": { + "has_time": True, + "has_date": False, + "initial": "10:00:00", + }, + } + }, + ) - # initial has been interpreted as being part of set timezone - state_without_tz = hass.states.get( - "input_datetime.test_datetime_initial_without_tz" - ) - assert state_without_tz is not None - assert state_without_tz.state == "2020-12-13 10:00:00" - # Timezone LA is UTC-8 => timestamp has no zone (= assumed local) => delta to UTC is +8 => 10:00 + 08:00 => 18:00 - assert ( - dt_util.utc_from_timestamp( - state_without_tz.attributes[ATTR_TIMESTAMP] - ).strftime(FMT_DATETIME) - == "2020-12-13 18:00:00" - ) - assert ( - dt_util.as_local( - dt_util.utc_from_timestamp(state_without_tz.attributes[ATTR_TIMESTAMP]) - ).strftime(FMT_DATETIME) - == "2020-12-13 10:00:00" - ) - # Use datetime.datetime.fromtimestamp - assert ( - dt_util.as_local( - datetime.datetime.fromtimestamp( - state_without_tz.attributes[ATTR_TIMESTAMP], datetime.timezone.utc - ) - ).strftime(FMT_DATETIME) - == "2020-12-13 10:00:00" - ) + # initial has been converted to the set timezone + state_with_tz = hass.states.get("input_datetime.test_datetime_initial_with_tz") + assert state_with_tz is not None + # Timezone LA is UTC-8 => timestamp carries +01:00 => delta is -9 => 10:00 - 09:00 => 01:00 + assert state_with_tz.state == "2020-12-13 01:00:00" + assert ( + dt_util.as_local( + dt_util.utc_from_timestamp(state_with_tz.attributes[ATTR_TIMESTAMP]) + ).strftime(FMT_DATETIME) + == "2020-12-13 01:00:00" + ) - # Test initial time sets timestamp correctly. - state_time = hass.states.get("input_datetime.test_time_initial") - assert state_time is not None - assert state_time.state == "10:00:00" - assert state_time.attributes[ATTR_TIMESTAMP] == 10 * 60 * 60 + # initial has been interpreted as being part of set timezone + state_without_tz = hass.states.get( + "input_datetime.test_datetime_initial_without_tz" + ) + assert state_without_tz is not None + assert state_without_tz.state == "2020-12-13 10:00:00" + # Timezone LA is UTC-8 => timestamp has no zone (= assumed local) => delta to UTC is +8 => 10:00 + 08:00 => 18:00 + assert ( + dt_util.utc_from_timestamp( + state_without_tz.attributes[ATTR_TIMESTAMP] + ).strftime(FMT_DATETIME) + == "2020-12-13 18:00:00" + ) + assert ( + dt_util.as_local( + dt_util.utc_from_timestamp(state_without_tz.attributes[ATTR_TIMESTAMP]) + ).strftime(FMT_DATETIME) + == "2020-12-13 10:00:00" + ) + # Use datetime.datetime.fromtimestamp + assert ( + dt_util.as_local( + datetime.datetime.fromtimestamp( + state_without_tz.attributes[ATTR_TIMESTAMP], datetime.timezone.utc + ) + ).strftime(FMT_DATETIME) + == "2020-12-13 10:00:00" + ) - # Test that setting the timestamp of an entity works. - await hass.services.async_call( - DOMAIN, - "set_datetime", - { - ATTR_ENTITY_ID: "input_datetime.test_datetime_initial_with_tz", - ATTR_TIMESTAMP: state_without_tz.attributes[ATTR_TIMESTAMP], - }, - blocking=True, - ) - state_with_tz_updated = hass.states.get( - "input_datetime.test_datetime_initial_with_tz" - ) - assert state_with_tz_updated.state == "2020-12-13 10:00:00" - assert ( - state_with_tz_updated.attributes[ATTR_TIMESTAMP] - == state_without_tz.attributes[ATTR_TIMESTAMP] - ) + # Test initial time sets timestamp correctly. + state_time = hass.states.get("input_datetime.test_time_initial") + assert state_time is not None + assert state_time.state == "10:00:00" + assert state_time.attributes[ATTR_TIMESTAMP] == 10 * 60 * 60 - finally: - dt_util.set_default_time_zone(ORIG_TIMEZONE) + # Test that setting the timestamp of an entity works. + await hass.services.async_call( + DOMAIN, + "set_datetime", + { + ATTR_ENTITY_ID: "input_datetime.test_datetime_initial_with_tz", + ATTR_TIMESTAMP: state_without_tz.attributes[ATTR_TIMESTAMP], + }, + blocking=True, + ) + state_with_tz_updated = hass.states.get( + "input_datetime.test_datetime_initial_with_tz" + ) + assert state_with_tz_updated.state == "2020-12-13 10:00:00" + assert ( + state_with_tz_updated.attributes[ATTR_TIMESTAMP] + == state_without_tz.attributes[ATTR_TIMESTAMP] + ) @pytest.mark.parametrize( diff --git a/tests/components/intellifire/test_config_flow.py b/tests/components/intellifire/test_config_flow.py index 2e130bfa14ea6..1283c9db0b2ab 100644 --- a/tests/components/intellifire/test_config_flow.py +++ b/tests/components/intellifire/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from homeassistant import config_entries +from homeassistant.components import dhcp from homeassistant.components.intellifire.config_flow import MANUAL_ENTRY_STRING from homeassistant.components.intellifire.const import DOMAIN from homeassistant.const import CONF_HOST @@ -203,3 +204,53 @@ async def test_picker_already_discovered( assert result2["title"] == "Fireplace 12345" assert result2["data"] == {CONF_HOST: "192.168.1.4"} assert len(mock_setup_entry.mock_calls) == 2 + + +async def test_dhcp_discovery_intellifire_device( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_intellifire_config_flow: MagicMock, +) -> None: + """Test successful DHCP Discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + ip="1.1.1.1", + macaddress="AA:BB:CC:DD:EE:FF", + hostname="zentrios-Test", + ), + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "dhcp_confirm" + result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result2["type"] == RESULT_TYPE_FORM + assert result2["step_id"] == "dhcp_confirm" + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], user_input={} + ) + assert result3["title"] == "Fireplace 12345" + assert result3["data"] == {"host": "1.1.1.1"} + + +async def test_dhcp_discovery_non_intellifire_device( + hass: HomeAssistant, + mock_intellifire_config_flow: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test failed DHCP Discovery.""" + + mock_intellifire_config_flow.poll.side_effect = ConnectionError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + ip="1.1.1.1", + macaddress="AA:BB:CC:DD:EE:FF", + hostname="zentrios-Evil", + ), + ) + + assert result["type"] == "abort" + assert result["reason"] == "not_intellifire_device" diff --git a/tests/components/jewish_calendar/__init__.py b/tests/components/jewish_calendar/__init__.py index b0279fd27488a..fbcd6c5325f9c 100644 --- a/tests/components/jewish_calendar/__init__.py +++ b/tests/components/jewish_calendar/__init__.py @@ -13,13 +13,6 @@ NYC_LATLNG = _LatLng(40.7128, -74.0060) JERUSALEM_LATLNG = _LatLng(31.778, 35.235) -ORIG_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE - - -def teardown_module(): - """Reset time zone.""" - dt_util.set_default_time_zone(ORIG_TIME_ZONE) - def make_nyc_test_params(dtime, results, havdalah_offset=0): """Make test params for NYC.""" diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index b34dfdb28e4a4..15052243baa58 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -181,7 +181,7 @@ async def test_issur_melacha_sensor( time_zone = dt_util.get_time_zone(tzname) test_time = now.replace(tzinfo=time_zone) - hass.config.time_zone = tzname + hass.config.set_time_zone(tzname) hass.config.latitude = latitude hass.config.longitude = longitude @@ -272,7 +272,7 @@ async def test_issur_melacha_sensor_update( time_zone = dt_util.get_time_zone(tzname) test_time = now.replace(tzinfo=time_zone) - hass.config.time_zone = tzname + hass.config.set_time_zone(tzname) hass.config.latitude = latitude hass.config.longitude = longitude diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index 879b5edb120e2..e2d24bcef049e 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -165,7 +165,7 @@ async def test_jewish_calendar_sensor( time_zone = dt_util.get_time_zone(tzname) test_time = now.replace(tzinfo=time_zone) - hass.config.time_zone = tzname + hass.config.set_time_zone(tzname) hass.config.latitude = latitude hass.config.longitude = longitude @@ -510,7 +510,7 @@ async def test_shabbat_times_sensor( time_zone = dt_util.get_time_zone(tzname) test_time = now.replace(tzinfo=time_zone) - hass.config.time_zone = tzname + hass.config.set_time_zone(tzname) hass.config.latitude = latitude hass.config.longitude = longitude diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 29dea01592185..998efc1c167fc 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -60,6 +60,12 @@ async def hass_(hass): return hass +@pytest.fixture() +def set_utc(hass): + """Set timezone to UTC.""" + hass.config.set_time_zone("UTC") + + async def test_service_call_create_logbook_entry(hass_): """Test if service call create log book entry.""" calls = async_capture_events(hass_, logbook.EVENT_LOGBOOK_ENTRY) @@ -314,7 +320,7 @@ async def test_logbook_view(hass, hass_client): assert response.status == HTTPStatus.OK -async def test_logbook_view_period_entity(hass, hass_client): +async def test_logbook_view_period_entity(hass, hass_client, set_utc): """Test the logbook view with period and entity.""" await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) @@ -680,7 +686,7 @@ async def test_logbook_entity_no_longer_in_state_machine(hass, hass_client): assert json_dict[0]["name"] == "Alarm Control Panel" -async def test_filter_continuous_sensor_values(hass, hass_client): +async def test_filter_continuous_sensor_values(hass, hass_client, set_utc): """Test remove continuous sensor events from logbook.""" await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) @@ -716,7 +722,7 @@ async def test_filter_continuous_sensor_values(hass, hass_client): assert response_json[1]["entity_id"] == entity_id_third -async def test_exclude_new_entities(hass, hass_client): +async def test_exclude_new_entities(hass, hass_client, set_utc): """Test if events are excluded on first update.""" await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) @@ -751,7 +757,7 @@ async def test_exclude_new_entities(hass, hass_client): assert response_json[1]["message"] == "started" -async def test_exclude_removed_entities(hass, hass_client): +async def test_exclude_removed_entities(hass, hass_client, set_utc): """Test if events are excluded on last update.""" await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) @@ -793,7 +799,7 @@ async def test_exclude_removed_entities(hass, hass_client): assert response_json[2]["entity_id"] == entity_id2 -async def test_exclude_attribute_changes(hass, hass_client): +async def test_exclude_attribute_changes(hass, hass_client, set_utc): """Test if events of attribute changes are filtered.""" await async_init_recorder_component(hass) await async_setup_component(hass, "logbook", {}) diff --git a/tests/components/lovelace/test_cast.py b/tests/components/lovelace/test_cast.py index d5b8e43d2bb6f..6f6035b54b61a 100644 --- a/tests/components/lovelace/test_cast.py +++ b/tests/components/lovelace/test_cast.py @@ -70,7 +70,7 @@ async def test_root_object(hass): ) assert len(root) == 1 item = root[0] - assert item.title == "Lovelace" + assert item.title == "Dashboards" assert item.media_class == lovelace_cast.MEDIA_CLASS_APP assert item.media_content_id == "" assert item.media_content_type == lovelace_cast.DOMAIN diff --git a/tests/components/media_player/test_browse_media.py b/tests/components/media_player/test_browse_media.py index 5e4bac2c635c3..6741432024ea9 100644 --- a/tests/components/media_player/test_browse_media.py +++ b/tests/components/media_player/test_browse_media.py @@ -7,6 +7,8 @@ async_process_play_media_url, ) from homeassistant.config import async_process_ha_core_config +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.network import NoURLAvailableError from tests.common import mock_component @@ -48,6 +50,11 @@ async def test_process_play_media_url(hass, mock_sign_path): async_process_play_media_url(hass, "http://192.168.123.123:8123/path") == "http://192.168.123.123:8123/path?authSig=bla" ) + with pytest.raises(HomeAssistantError), patch( + "homeassistant.components.media_player.browse_media.get_url", + side_effect=NoURLAvailableError, + ): + async_process_play_media_url(hass, "/path") # Test skip signing URLs that have a query param assert ( @@ -61,6 +68,9 @@ async def test_process_play_media_url(hass, mock_sign_path): == "http://192.168.123.123:8123/path?hello=world" ) + with pytest.raises(ValueError): + async_process_play_media_url(hass, "hello") + async def test_process_play_media_url_for_addon(hass, mock_sign_path): """Test it uses the hostname for an addon if available.""" diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index 9ff5bd19e39ef..c40d88fb25247 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -2,7 +2,14 @@ from unittest.mock import patch from homeassistant.components.powerwall.const import DOMAIN -from homeassistant.const import CONF_IP_ADDRESS, PERCENTAGE +from homeassistant.components.sensor import ATTR_STATE_CLASS +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, + CONF_IP_ADDRESS, + PERCENTAGE, +) from homeassistant.helpers import device_registry as dr from .mocks import _mock_powerwall_with_fixtures @@ -10,7 +17,7 @@ from tests.common import MockConfigEntry -async def test_sensors(hass): +async def test_sensors(hass, entity_registry_enabled_by_default): """Test creation of the sensors.""" mock_powerwall = await _mock_powerwall_with_fixtures(hass) @@ -35,77 +42,49 @@ async def test_sensors(hass): assert reg_device.manufacturer == "Tesla" assert reg_device.name == "MySite" - state = hass.states.get("sensor.powerwall_site_now") - assert state.state == "0.032" - expected_attributes = { - "frequency": 60, - "instant_average_voltage": 120.7, - "unit_of_measurement": "kW", - "friendly_name": "Powerwall Site Now", - "device_class": "power", - "is_active": False, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - for key, value in expected_attributes.items(): - assert state.attributes[key] == value - - assert float(hass.states.get("sensor.powerwall_site_export").state) == 10429.5 - assert float(hass.states.get("sensor.powerwall_site_import").state) == 4824.2 - - export_attributes = hass.states.get("sensor.powerwall_site_export").attributes - assert export_attributes["unit_of_measurement"] == "kWh" - state = hass.states.get("sensor.powerwall_load_now") assert state.state == "1.971" - expected_attributes = { - "frequency": 60, - "instant_average_voltage": 120.7, - "unit_of_measurement": "kW", - "friendly_name": "Powerwall Load Now", - "device_class": "power", - "is_active": True, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - for key, value in expected_attributes.items(): - assert state.attributes[key] == value + attributes = state.attributes + assert attributes[ATTR_DEVICE_CLASS] == "power" + assert attributes[ATTR_UNIT_OF_MEASUREMENT] == "kW" + assert attributes[ATTR_STATE_CLASS] == "measurement" + assert attributes[ATTR_FRIENDLY_NAME] == "Powerwall Load Now" + + state = hass.states.get("sensor.powerwall_load_frequency_now") + assert state.state == "60" + attributes = state.attributes + assert attributes[ATTR_DEVICE_CLASS] == "frequency" + assert attributes[ATTR_UNIT_OF_MEASUREMENT] == "Hz" + assert attributes[ATTR_STATE_CLASS] == "measurement" + assert attributes[ATTR_FRIENDLY_NAME] == "Powerwall Load Frequency Now" + + state = hass.states.get("sensor.powerwall_load_average_voltage_now") + assert state.state == "120.7" + attributes = state.attributes + assert attributes[ATTR_DEVICE_CLASS] == "voltage" + assert attributes[ATTR_UNIT_OF_MEASUREMENT] == "V" + assert attributes[ATTR_STATE_CLASS] == "measurement" + assert attributes[ATTR_FRIENDLY_NAME] == "Powerwall Load Average Voltage Now" + + state = hass.states.get("sensor.powerwall_load_average_current_now") + assert state.state == "0" + attributes = state.attributes + assert attributes[ATTR_DEVICE_CLASS] == "current" + assert attributes[ATTR_UNIT_OF_MEASUREMENT] == "A" + assert attributes[ATTR_STATE_CLASS] == "measurement" + assert attributes[ATTR_FRIENDLY_NAME] == "Powerwall Load Average Current Now" assert float(hass.states.get("sensor.powerwall_load_export").state) == 1056.8 assert float(hass.states.get("sensor.powerwall_load_import").state) == 4693.0 state = hass.states.get("sensor.powerwall_battery_now") assert state.state == "-8.55" - expected_attributes = { - "frequency": 60.0, - "instant_average_voltage": 240.6, - "unit_of_measurement": "kW", - "friendly_name": "Powerwall Battery Now", - "device_class": "power", - "is_active": True, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - for key, value in expected_attributes.items(): - assert state.attributes[key] == value assert float(hass.states.get("sensor.powerwall_battery_export").state) == 3620.0 assert float(hass.states.get("sensor.powerwall_battery_import").state) == 4216.2 state = hass.states.get("sensor.powerwall_solar_now") assert state.state == "10.49" - expected_attributes = { - "frequency": 60, - "instant_average_voltage": 120.7, - "unit_of_measurement": "kW", - "friendly_name": "Powerwall Solar Now", - "device_class": "power", - "is_active": True, - } - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - for key, value in expected_attributes.items(): - assert state.attributes[key] == value assert float(hass.states.get("sensor.powerwall_solar_export").state) == 9864.2 assert float(hass.states.get("sensor.powerwall_solar_import").state) == 28.2 diff --git a/tests/components/pvpc_hourly_pricing/test_config_flow.py b/tests/components/pvpc_hourly_pricing/test_config_flow.py index ba0dc80200722..4a9530768a6f1 100644 --- a/tests/components/pvpc_hourly_pricing/test_config_flow.py +++ b/tests/components/pvpc_hourly_pricing/test_config_flow.py @@ -12,7 +12,6 @@ ) from homeassistant.const import CONF_NAME from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util from .conftest import check_valid_state @@ -32,7 +31,7 @@ async def test_config_flow( - Check removal and add again to check state restoration - Configure options to change power and tariff to "2.0TD" """ - hass.config.time_zone = dt_util.get_time_zone("Europe/Madrid") + hass.config.set_time_zone("Europe/Madrid") tst_config = { CONF_NAME: "test", ATTR_TARIFF: TARIFFS[1], diff --git a/tests/components/pvpc_hourly_pricing/test_sensor.py b/tests/components/pvpc_hourly_pricing/test_sensor.py index 727a144e75de2..bbea27477cf0c 100644 --- a/tests/components/pvpc_hourly_pricing/test_sensor.py +++ b/tests/components/pvpc_hourly_pricing/test_sensor.py @@ -11,7 +11,6 @@ TARIFFS, ) from homeassistant.const import CONF_NAME -from homeassistant.util import dt as dt_util from .conftest import check_valid_state @@ -29,7 +28,7 @@ async def test_multi_sensor_migration( ): """Test tariff migration when there are >1 old sensors.""" entity_reg = mock_registry(hass) - hass.config.time_zone = dt_util.get_time_zone("Europe/Madrid") + hass.config.set_time_zone("Europe/Madrid") uid_1 = "discrimination" uid_2 = "normal" old_conf_1 = {CONF_NAME: "test_pvpc_1", ATTR_TARIFF: uid_1} diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index 67a666c934f0a..5d1d72ca65013 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -5,6 +5,8 @@ import json from unittest.mock import patch, sentinel +import pytest + from homeassistant.components.recorder import history from homeassistant.components.recorder.models import process_timestamp import homeassistant.core as ha @@ -15,11 +17,9 @@ from tests.components.recorder.common import wait_recording_done -def test_get_states(hass_recorder): - """Test getting states at a specific point in time.""" - hass = hass_recorder() +def _setup_get_states(hass): + """Set up for testing get_states.""" states = [] - now = dt_util.utcnow() with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=now): for i in range(5): @@ -48,6 +48,13 @@ def test_get_states(hass_recorder): wait_recording_done(hass) + return now, future, states + + +def test_get_states(hass_recorder): + """Test getting states at a specific point in time.""" + hass = hass_recorder() + now, future, states = _setup_get_states(hass) # Get states returns everything before POINT for all entities for state1, state2 in zip( states, @@ -75,14 +82,65 @@ def test_get_states(hass_recorder): assert history.get_state(hass, time_before_recorder_ran, "demo.id") is None -def test_state_changes_during_period(hass_recorder): +def test_get_states_no_attributes(hass_recorder): + """Test getting states without attributes at a specific point in time.""" + hass = hass_recorder() + now, future, states = _setup_get_states(hass) + for state in states: + state.attributes = {} + + # Get states returns everything before POINT for all entities + for state1, state2 in zip( + states, + sorted( + history.get_states(hass, future, no_attributes=True), + key=lambda state: state.entity_id, + ), + ): + assert state1 == state2 + + # Get states returns everything before POINT for tested entities + entities = [f"test.point_in_time_{i % 5}" for i in range(5)] + for state1, state2 in zip( + states, + sorted( + history.get_states(hass, future, entities, no_attributes=True), + key=lambda state: state.entity_id, + ), + ): + assert state1 == state2 + + # Test get_state here because we have a DB setup + assert states[0] == history.get_state( + hass, future, states[0].entity_id, no_attributes=True + ) + + time_before_recorder_ran = now - timedelta(days=1000) + assert history.get_states(hass, time_before_recorder_ran, no_attributes=True) == [] + + assert ( + history.get_state(hass, time_before_recorder_ran, "demo.id", no_attributes=True) + is None + ) + + +@pytest.mark.parametrize( + "attributes, no_attributes, limit", + [ + ({"attr": True}, False, 5000), + ({}, True, 5000), + ({"attr": True}, False, 3), + ({}, True, 3), + ], +) +def test_state_changes_during_period(hass_recorder, attributes, no_attributes, limit): """Test state change during period.""" hass = hass_recorder() entity_id = "media_player.test" def set_state(state): """Set the state.""" - hass.states.set(entity_id, state) + hass.states.set(entity_id, state, attributes) wait_recording_done(hass) return hass.states.get(entity_id) @@ -106,9 +164,11 @@ def set_state(state): set_state("Netflix") set_state("Plex") - hist = history.state_changes_during_period(hass, start, end, entity_id) + hist = history.state_changes_during_period( + hass, start, end, entity_id, no_attributes, limit=limit + ) - assert states == hist[entity_id] + assert states[:limit] == hist[entity_id] def test_get_last_state_changes(hass_recorder): diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index c3de4161ea1a5..8382afe435318 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -76,7 +76,6 @@ def test_from_event_to_delete_state(): db_state = States.from_event(event) assert db_state.entity_id == "sensor.temperature" - assert db_state.domain == "sensor" assert db_state.state == "" assert db_state.last_changed == event.time_fired assert db_state.last_updated == event.time_fired diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 1bdb391541ecd..c591f6e5242ee 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -375,7 +375,6 @@ async def _add_db_entries(hass: HomeAssistant, timestamp: datetime) -> None: session.add( States( entity_id="test.recorder2", - domain="sensor", state="purgeme", attributes="{}", last_changed=timestamp, @@ -444,7 +443,6 @@ async def _add_db_entries(hass: HomeAssistant, cutoff: datetime, rows: int) -> N session.add( States( entity_id="test.cutoff", - domain="sensor", state="keep", attributes="{}", last_changed=timestamp_keep, @@ -473,7 +471,6 @@ async def _add_db_entries(hass: HomeAssistant, cutoff: datetime, rows: int) -> N session.add( States( entity_id="test.cutoff", - domain="sensor", state="purge", attributes="{}", last_changed=timestamp_purge, @@ -592,7 +589,6 @@ def _add_db_entries(hass: HomeAssistant) -> None: session.add( States( entity_id="sensor.excluded", - domain="sensor", state="purgeme", attributes="{}", last_changed=timestamp, @@ -619,7 +615,6 @@ def _add_db_entries(hass: HomeAssistant) -> None: ) state_1 = States( entity_id="sensor.linked_old_state_id", - domain="sensor", state="keep", attributes="{}", last_changed=timestamp, @@ -630,7 +625,6 @@ def _add_db_entries(hass: HomeAssistant) -> None: timestamp = dt_util.utcnow() - timedelta(days=4) state_2 = States( entity_id="sensor.linked_old_state_id", - domain="sensor", state="keep", attributes="{}", last_changed=timestamp, @@ -640,7 +634,6 @@ def _add_db_entries(hass: HomeAssistant) -> None: ) state_3 = States( entity_id="sensor.linked_old_state_id", - domain="sensor", state="keep", attributes="{}", last_changed=timestamp, @@ -710,9 +703,9 @@ def _add_db_entries(hass: HomeAssistant) -> None: assert states_sensor_excluded.count() == 0 assert session.query(States).get(72).old_state_id is None - assert session.query(States).get(72).attributes_id is None + assert session.query(States).get(72).attributes_id == 71 assert session.query(States).get(73).old_state_id is None - assert session.query(States).get(73).attributes_id is None + assert session.query(States).get(73).attributes_id == 71 final_keep_state = session.query(States).get(74) assert final_keep_state.old_state_id == 62 # should have been kept @@ -814,7 +807,6 @@ def _add_db_entries(hass: HomeAssistant) -> None: session.add( States( entity_id="sensor.old_format", - domain="sensor", state=STATE_ON, attributes=json.dumps({"old": "not_using_state_attributes"}), last_changed=timestamp, @@ -979,7 +971,6 @@ def _add_db_entries(hass: HomeAssistant) -> None: timestamp = dt_util.utcnow() - timedelta(days=0) state_1 = States( entity_id="sensor.linked_old_state_id", - domain="sensor", state="keep", attributes="{}", last_changed=timestamp, @@ -989,7 +980,6 @@ def _add_db_entries(hass: HomeAssistant) -> None: timestamp = dt_util.utcnow() - timedelta(days=4) state_2 = States( entity_id="sensor.linked_old_state_id", - domain="sensor", state="keep", attributes="{}", last_changed=timestamp, @@ -998,7 +988,6 @@ def _add_db_entries(hass: HomeAssistant) -> None: ) state_3 = States( entity_id="sensor.linked_old_state_id", - domain="sensor", state="keep", attributes="{}", last_changed=timestamp, @@ -1318,7 +1307,6 @@ def _add_state_and_state_changed_event( session.add( States( entity_id=entity_id, - domain="sensor", state=state, attributes=None, last_changed=timestamp, diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index c96465a671f6a..fe05dbc25ab7f 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -37,6 +37,8 @@ from tests.common import get_test_home_assistant, mock_registry from tests.components.recorder.common import wait_recording_done +ORIG_TZ = dt_util.DEFAULT_TIME_ZONE + def test_compile_hourly_statistics(hass_recorder): """Test compiling hourly statistics.""" @@ -841,6 +843,7 @@ def test_delete_duplicates(caplog, tmpdir): session.add(recorder.models.Statistics.from_stats(3, stat)) hass.stop() + dt_util.DEFAULT_TIME_ZONE = ORIG_TZ # Test that the duplicates are removed during migration from schema 23 hass = get_test_home_assistant() @@ -849,6 +852,7 @@ def test_delete_duplicates(caplog, tmpdir): wait_recording_done(hass) wait_recording_done(hass) hass.stop() + dt_util.DEFAULT_TIME_ZONE = ORIG_TZ assert "Deleted 2 duplicated statistics rows" in caplog.text assert "Found non identical" not in caplog.text @@ -1014,6 +1018,7 @@ def test_delete_duplicates_many(caplog, tmpdir): session.add(recorder.models.Statistics.from_stats(3, stat)) hass.stop() + dt_util.DEFAULT_TIME_ZONE = ORIG_TZ # Test that the duplicates are removed during migration from schema 23 hass = get_test_home_assistant() @@ -1022,6 +1027,7 @@ def test_delete_duplicates_many(caplog, tmpdir): wait_recording_done(hass) wait_recording_done(hass) hass.stop() + dt_util.DEFAULT_TIME_ZONE = ORIG_TZ assert "Deleted 3002 duplicated statistics rows" in caplog.text assert "Found non identical" not in caplog.text @@ -1149,6 +1155,7 @@ def test_delete_duplicates_non_identical(caplog, tmpdir): session.add(recorder.models.Statistics.from_stats(2, stat)) hass.stop() + dt_util.DEFAULT_TIME_ZONE = ORIG_TZ # Test that the duplicates are removed during migration from schema 23 hass = get_test_home_assistant() @@ -1158,6 +1165,7 @@ def test_delete_duplicates_non_identical(caplog, tmpdir): wait_recording_done(hass) wait_recording_done(hass) hass.stop() + dt_util.DEFAULT_TIME_ZONE = ORIG_TZ assert "Deleted 2 duplicated statistics rows" in caplog.text assert "Deleted 1 non identical" in caplog.text @@ -1249,6 +1257,7 @@ def test_delete_duplicates_short_term(caplog, tmpdir): ) hass.stop() + dt_util.DEFAULT_TIME_ZONE = ORIG_TZ # Test that the duplicates are removed during migration from schema 23 hass = get_test_home_assistant() @@ -1258,6 +1267,7 @@ def test_delete_duplicates_short_term(caplog, tmpdir): wait_recording_done(hass) wait_recording_done(hass) hass.stop() + dt_util.DEFAULT_TIME_ZONE = ORIG_TZ assert "duplicated statistics rows" not in caplog.text assert "Found non identical" not in caplog.text diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 2a9f737e9a5b8..33478b76bcbd4 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -9,6 +9,7 @@ from homeassistant.components import recorder from homeassistant.components.recorder.const import DATA_INSTANCE +from homeassistant.components.recorder.statistics import async_add_external_statistics from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM @@ -35,6 +36,16 @@ "state_class": "measurement", "unit_of_measurement": "°C", } +ENERGY_SENSOR_ATTRIBUTES = { + "device_class": "energy", + "state_class": "total", + "unit_of_measurement": "kWh", +} +GAS_SENSOR_ATTRIBUTES = { + "device_class": "gas", + "state_class": "total", + "unit_of_measurement": "m³", +} async def test_validate_statistics(hass, hass_ws_client): @@ -421,3 +432,125 @@ async def test_backup_end_without_start( response = await client.receive_json() assert not response["success"] assert response["error"]["code"] == "database_unlock_failed" + + +@pytest.mark.parametrize( + "units, attributes, unit", + [ + (METRIC_SYSTEM, GAS_SENSOR_ATTRIBUTES, "m³"), + (METRIC_SYSTEM, ENERGY_SENSOR_ATTRIBUTES, "kWh"), + ], +) +async def test_get_statistics_metadata(hass, hass_ws_client, units, attributes, unit): + """Test get_statistics_metadata.""" + now = dt_util.utcnow() + + hass.config.units = units + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "history", {"history": {}}) + await async_setup_component(hass, "sensor", {}) + await async_init_recorder_component(hass) + await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + client = await hass_ws_client() + await client.send_json({"id": 1, "type": "recorder/get_statistics_metadata"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [] + + period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) + external_energy_statistics_1 = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 5, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 8, + }, + ) + external_energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_gas", + "unit_of_measurement": unit, + } + + async_add_external_statistics( + hass, external_energy_metadata_1, external_energy_statistics_1 + ) + + hass.states.async_set("sensor.test", 10, attributes=attributes) + await hass.async_block_till_done() + + await hass.async_add_executor_job(trigger_db_commit, hass) + await hass.async_block_till_done() + + hass.states.async_set("sensor.test2", 10, attributes=attributes) + await hass.async_block_till_done() + + await hass.async_add_executor_job(trigger_db_commit, hass) + await hass.async_block_till_done() + + await client.send_json( + { + "id": 2, + "type": "recorder/get_statistics_metadata", + "statistic_ids": ["sensor.test"], + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [ + { + "statistic_id": "sensor.test", + "name": None, + "source": "recorder", + "unit_of_measurement": unit, + } + ] + + hass.data[recorder.DATA_INSTANCE].do_adhoc_statistics(start=now) + await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + # Remove the state, statistics will now be fetched from the database + hass.states.async_remove("sensor.test") + await hass.async_block_till_done() + + await client.send_json( + { + "id": 3, + "type": "recorder/get_statistics_metadata", + "statistic_ids": ["sensor.test"], + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [ + { + "statistic_id": "sensor.test", + "name": None, + "source": "recorder", + "unit_of_measurement": unit, + } + ] diff --git a/tests/components/rfxtrx/test_cover.py b/tests/components/rfxtrx/test_cover.py index a05a456d221b7..e3d44edda826f 100644 --- a/tests/components/rfxtrx/test_cover.py +++ b/tests/components/rfxtrx/test_cover.py @@ -5,6 +5,7 @@ from homeassistant.components.rfxtrx import DOMAIN from homeassistant.core import State +from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry, mock_restore_cache from tests.components.rfxtrx.conftest import create_rfx_test_cfg @@ -181,19 +182,21 @@ async def test_rfy_cover(hass, rfxtrx): blocking=True, ) - await hass.services.async_call( - "cover", - "open_cover_tilt", - {"entity_id": "cover.rfy_010203_1"}, - blocking=True, - ) - - await hass.services.async_call( - "cover", - "close_cover_tilt", - {"entity_id": "cover.rfy_010203_1"}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "cover", + "open_cover_tilt", + {"entity_id": "cover.rfy_010203_1"}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "cover", + "close_cover_tilt", + {"entity_id": "cover.rfy_010203_1"}, + blocking=True, + ) assert rfxtrx.transport.send.mock_calls == [ call(bytearray(b"\x08\x1a\x00\x00\x01\x02\x03\x01\x00")), diff --git a/tests/components/risco/test_alarm_control_panel.py b/tests/components/risco/test_alarm_control_panel.py index 23c4de4c7aafb..70ec78446240e 100644 --- a/tests/components/risco/test_alarm_control_panel.py +++ b/tests/components/risco/test_alarm_control_panel.py @@ -27,6 +27,7 @@ STATE_ALARM_TRIGGERED, STATE_UNKNOWN, ) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity @@ -337,17 +338,24 @@ async def test_sets_with_correct_code(hass, two_part_alarm): await _test_service_call( hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", SECOND_ENTITY_ID, 1, "C", **code ) - await _test_no_service_call( - hass, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "partial_arm", FIRST_ENTITY_ID, 0, **code - ) - await _test_no_service_call( - hass, - SERVICE_ALARM_ARM_CUSTOM_BYPASS, - "partial_arm", - SECOND_ENTITY_ID, - 1, - **code, - ) + with pytest.raises(HomeAssistantError): + await _test_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + FIRST_ENTITY_ID, + 0, + **code, + ) + with pytest.raises(HomeAssistantError): + await _test_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + SECOND_ENTITY_ID, + 1, + **code, + ) async def test_sets_with_incorrect_code(hass, two_part_alarm): @@ -379,14 +387,21 @@ async def test_sets_with_incorrect_code(hass, two_part_alarm): await _test_no_service_call( hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", SECOND_ENTITY_ID, 1, **code ) - await _test_no_service_call( - hass, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "partial_arm", FIRST_ENTITY_ID, 0, **code - ) - await _test_no_service_call( - hass, - SERVICE_ALARM_ARM_CUSTOM_BYPASS, - "partial_arm", - SECOND_ENTITY_ID, - 1, - **code, - ) + with pytest.raises(HomeAssistantError): + await _test_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + FIRST_ENTITY_ID, + 0, + **code, + ) + with pytest.raises(HomeAssistantError): + await _test_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + SECOND_ENTITY_ID, + 1, + **code, + ) diff --git a/tests/components/risco/test_sensor.py b/tests/components/risco/test_sensor.py index 4286a7d09c96f..24efbabf087ee 100644 --- a/tests/components/risco/test_sensor.py +++ b/tests/components/risco/test_sensor.py @@ -168,6 +168,7 @@ def _check_state(hass, category, entity_id): async def test_setup(hass, two_zone_alarm): # noqa: F811 """Test entity setup.""" + hass.config.set_time_zone("UTC") registry = er.async_get(hass) for id in ENTITY_IDS.values(): diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index c76b9e9efb9e1..74849fb9feea3 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -60,6 +60,7 @@ STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -896,9 +897,10 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: async def test_turn_on_without_turnon(hass: HomeAssistant, remote: Mock) -> None: """Test turn on.""" await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) - assert await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True + ) # nothing called as not supported feature assert remote.control.call_count == 0 diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 155060222c807..af1494b381e48 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -2176,6 +2176,8 @@ def test_compile_statistics_hourly_daily_monthly_summary( "homeassistant.components.recorder.models.dt_util.utcnow", return_value=zero ): hass = hass_recorder() + # Remove this after dropping the use of the hass_recorder fixture + hass.config.set_time_zone("America/Regina") recorder = hass.data[DATA_INSTANCE] recorder._db_supports_row_number = db_supports_row_number setup_component(hass, "sensor", {}) diff --git a/tests/components/smarthab/__init__.py b/tests/components/smarthab/__init__.py deleted file mode 100644 index 0e393ee0f9efe..0000000000000 --- a/tests/components/smarthab/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the SmartHab integration.""" diff --git a/tests/components/smarthab/test_config_flow.py b/tests/components/smarthab/test_config_flow.py deleted file mode 100644 index e6e9dc8610188..0000000000000 --- a/tests/components/smarthab/test_config_flow.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Test the SmartHab config flow.""" -from unittest.mock import patch - -import pysmarthab - -from homeassistant import config_entries -from homeassistant.components.smarthab import DOMAIN -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD - - -async def test_form(hass): - """Test we get the form.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "form" - assert result["errors"] == {} - - with patch("pysmarthab.SmartHab.async_login"), patch( - "pysmarthab.SmartHab.is_logged_in", return_value=True - ), patch( - "homeassistant.components.smarthab.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.smarthab.async_setup_entry", return_value=True - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_EMAIL: "mock@example.com", CONF_PASSWORD: "test-password"}, - ) - await hass.async_block_till_done() - - assert result2["type"] == "create_entry" - assert result2["title"] == "mock@example.com" - assert result2["data"] == { - CONF_EMAIL: "mock@example.com", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_invalid_auth(hass): - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch("pysmarthab.SmartHab.async_login"), patch( - "pysmarthab.SmartHab.is_logged_in", return_value=False - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_EMAIL: "mock@example.com", CONF_PASSWORD: "test-password"}, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_service_error(hass): - """Test we handle service errors.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "pysmarthab.SmartHab.async_login", - side_effect=pysmarthab.RequestFailedException(42), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_EMAIL: "mock@example.com", CONF_PASSWORD: "test-password"}, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "service"} - - -async def test_form_unknown_error(hass): - """Test we handle unknown errors.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "pysmarthab.SmartHab.async_login", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_EMAIL: "mock@example.com", CONF_PASSWORD: "test-password"}, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "unknown"} - - -async def test_import(hass): - """Test import.""" - - imported_conf = { - CONF_EMAIL: "mock@example.com", - CONF_PASSWORD: "test-password", - } - - with patch("pysmarthab.SmartHab.async_login"), patch( - "pysmarthab.SmartHab.is_logged_in", return_value=True - ), patch( - "homeassistant.components.smarthab.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.smarthab.async_setup_entry", return_value=True - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=imported_conf - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - assert result["title"] == "mock@example.com" - assert result["data"] == { - CONF_EMAIL: "mock@example.com", - CONF_PASSWORD: "test-password", - } - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py index c499dc0112f17..1b17fc2726e88 100644 --- a/tests/components/sonarr/test_sensor.py +++ b/tests/components/sonarr/test_sensor.py @@ -96,10 +96,10 @@ async def test_sensors( assert state assert state.attributes.get(ATTR_ICON) == "mdi:television" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Episodes" - assert state.attributes.get("Bob's Burgers S04E11") == "2014-01-27T01:30:00+00:00" + assert state.attributes.get("Bob's Burgers S04E11") == "2014-01-26T17:30:00-08:00" assert ( state.attributes.get("The Andy Griffith Show S01E01") - == "1960-10-03T01:00:00+00:00" + == "1960-10-02T17:00:00-08:00" ) assert state.state == "2" diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 8e133f76ac1c3..ebff338b39aef 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -114,6 +114,7 @@ def soco_fixture( mock_soco.treble = -1 mock_soco.mic_enabled = False mock_soco.sub_enabled = False + mock_soco.sub_gain = 5 mock_soco.surround_enabled = True mock_soco.soundbar_audio_input_format = "Dolby 5.1" mock_soco.get_battery_info.return_value = battery_info diff --git a/tests/components/sonos/test_number.py b/tests/components/sonos/test_number.py index 91c00e053908f..5829a7a67247c 100644 --- a/tests/components/sonos/test_number.py +++ b/tests/components/sonos/test_number.py @@ -6,8 +6,8 @@ from homeassistant.helpers import entity_registry as ent_reg -async def test_audio_input_sensor(hass, async_autosetup_sonos, soco): - """Test audio input sensor.""" +async def test_number_entities(hass, async_autosetup_sonos, soco): + """Test number entities.""" entity_registry = ent_reg.async_get(hass) bass_number = entity_registry.entities["number.zone_a_bass"] @@ -30,3 +30,16 @@ async def test_audio_input_sensor(hass, async_autosetup_sonos, soco): blocking=True, ) assert mock_audio_delay.called_with(3) + + sub_gain_number = entity_registry.entities["number.zone_a_sub_gain"] + sub_gain_state = hass.states.get(sub_gain_number.entity_id) + assert sub_gain_state.state == "5" + + with patch("soco.SoCo.sub_gain") as mock_sub_gain: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: sub_gain_number.entity_id, "value": -8}, + blocking=True, + ) + assert mock_sub_gain.called_with(-8) diff --git a/tests/components/subaru/test_lock.py b/tests/components/subaru/test_lock.py new file mode 100644 index 0000000000000..19918ba205c22 --- /dev/null +++ b/tests/components/subaru/test_lock.py @@ -0,0 +1,86 @@ +"""Test Subaru locks.""" +from unittest.mock import patch + +from pytest import raises +from voluptuous.error import MultipleInvalid + +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.subaru.const import ( + ATTR_DOOR, + DOMAIN as SUBARU_DOMAIN, + SERVICE_UNLOCK_SPECIFIC_DOOR, + UNLOCK_DOOR_DRIVERS, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK +from homeassistant.exceptions import HomeAssistantError + +from .conftest import MOCK_API + +MOCK_API_LOCK = f"{MOCK_API}lock" +MOCK_API_UNLOCK = f"{MOCK_API}unlock" +DEVICE_ID = "lock.test_vehicle_2_door_locks" + + +async def test_device_exists(hass, ev_entry): + """Test subaru lock entity exists.""" + entity_registry = await hass.helpers.entity_registry.async_get_registry() + entry = entity_registry.async_get(DEVICE_ID) + assert entry + + +async def test_lock_cmd(hass, ev_entry): + """Test subaru lock function.""" + with patch(MOCK_API_LOCK) as mock_lock: + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: DEVICE_ID}, blocking=True + ) + await hass.async_block_till_done() + mock_lock.assert_called_once() + + +async def test_unlock_cmd(hass, ev_entry): + """Test subaru unlock function.""" + with patch(MOCK_API_UNLOCK) as mock_unlock: + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: DEVICE_ID}, blocking=True + ) + await hass.async_block_till_done() + mock_unlock.assert_called_once() + + +async def test_lock_cmd_fails(hass, ev_entry): + """Test subaru lock request that initiates but fails.""" + with patch(MOCK_API_LOCK, return_value=False) as mock_lock, raises( + HomeAssistantError + ): + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: DEVICE_ID}, blocking=True + ) + await hass.async_block_till_done() + mock_lock.assert_called_once() + + +async def test_unlock_specific_door(hass, ev_entry): + """Test subaru unlock specific door function.""" + with patch(MOCK_API_UNLOCK) as mock_unlock: + await hass.services.async_call( + SUBARU_DOMAIN, + SERVICE_UNLOCK_SPECIFIC_DOOR, + {ATTR_ENTITY_ID: DEVICE_ID, ATTR_DOOR: UNLOCK_DOOR_DRIVERS}, + blocking=True, + ) + await hass.async_block_till_done() + mock_unlock.assert_called_once() + + +async def test_unlock_specific_door_invalid(hass, ev_entry): + """Test subaru unlock specific door function.""" + with patch(MOCK_API_UNLOCK) as mock_unlock, raises(MultipleInvalid): + await hass.services.async_call( + SUBARU_DOMAIN, + SERVICE_UNLOCK_SPECIFIC_DOOR, + {ATTR_ENTITY_ID: DEVICE_ID, ATTR_DOOR: "bad_value"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_unlock.assert_not_called() diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index 55c31da6c89a6..3b5f3af76ece0 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -20,8 +20,6 @@ from tests.common import async_fire_time_changed, async_mock_service, mock_component from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 -ORIG_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE - @pytest.fixture def calls(hass): @@ -33,20 +31,11 @@ def calls(hass): def setup_comp(hass): """Initialize components.""" mock_component(hass, "group") - hass.config.set_time_zone(hass.config.time_zone) hass.loop.run_until_complete( async_setup_component(hass, sun.DOMAIN, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}}) ) -@pytest.fixture(autouse=True) -def teardown(): - """Restore.""" - yield - - dt_util.set_default_time_zone(ORIG_TIME_ZONE) - - async def test_sunset_trigger(hass, calls, legacy_patchable_time): """Test the sunset trigger.""" now = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index a7502576de16a..cd29794db8da4 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -129,38 +129,6 @@ async def test_optimistic_states(hass, start_ha): assert hass.states.get(TEMPLATE_NAME).state == set_state -@pytest.mark.parametrize("count,domain", [(1, "alarm_control_panel")]) -@pytest.mark.parametrize( - "config", - [ - { - "alarm_control_panel": { - "platform": "template", - "panels": { - "test_template_panel": { - "value_template": "{{ states('alarm_control_panel.test') }}", - } - }, - } - }, - ], -) -async def test_no_action_scripts(hass, start_ha): - """Test no action scripts per state.""" - hass.states.async_set("alarm_control_panel.test", STATE_ALARM_ARMED_AWAY) - await hass.async_block_till_done() - - for func, set_state in [ - (common.async_alarm_arm_away, STATE_ALARM_ARMED_AWAY), - (common.async_alarm_arm_home, STATE_ALARM_ARMED_AWAY), - (common.async_alarm_arm_night, STATE_ALARM_ARMED_AWAY), - (common.async_alarm_disarm, STATE_ALARM_ARMED_AWAY), - ]: - await func(hass, entity_id=TEMPLATE_NAME) - await hass.async_block_till_done() - assert hass.states.get(TEMPLATE_NAME).state == set_state - - @pytest.mark.parametrize("count,domain", [(0, "alarm_control_panel")]) @pytest.mark.parametrize( "config,msg", diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 0352080bed830..b9b1a0cd93b69 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1124,7 +1124,8 @@ async def test_trigger_entity_device_class_parsing_works(hass): await hass.async_block_till_done() - now = dt_util.now() + # State of timestamp sensors are always in UTC + now = dt_util.utcnow() with patch("homeassistant.util.dt.now", return_value=now): hass.bus.async_fire("test_event") @@ -1184,7 +1185,8 @@ async def test_trigger_entity_device_class_errors_works(hass): async def test_entity_device_class_parsing_works(hass): """Test entity device class parsing works.""" - now = dt_util.now() + # State of timestamp sensors are always in UTC + now = dt_util.utcnow() with patch("homeassistant.util.dt.now", return_value=now): assert await async_setup_component( diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index a9b5ea83c0589..56f58221529b1 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -1,20 +1,9 @@ """The tests for time_date sensor platform.""" from unittest.mock import patch -import pytest - import homeassistant.components.time_date.sensor as time_date import homeassistant.util.dt as dt_util -ORIG_TZ = dt_util.DEFAULT_TIME_ZONE - - -@pytest.fixture(autouse=True) -def restore_ts(): - """Restore default TZ.""" - yield - dt_util.DEFAULT_TIME_ZONE = ORIG_TZ - # pylint: disable=protected-access async def test_intervals(hass): @@ -45,6 +34,8 @@ async def test_intervals(hass): async def test_states(hass): """Test states of sensors.""" + hass.config.set_time_zone("UTC") + now = dt_util.utc_from_timestamp(1495068856) device = time_date.TimeDateSensor(hass, "time") device._update_internal_state(now) @@ -79,9 +70,7 @@ async def test_states(hass): async def test_states_non_default_timezone(hass): """Test states of sensors in a timezone other than UTC.""" - new_tz = dt_util.get_time_zone("America/New_York") - assert new_tz is not None - dt_util.set_default_time_zone(new_tz) + hass.config.set_time_zone("America/New_York") now = dt_util.utc_from_timestamp(1495068856) device = time_date.TimeDateSensor(hass, "time") @@ -116,9 +105,7 @@ async def test_states_non_default_timezone(hass): # pylint: disable=no-member async def test_timezone_intervals(hass): """Test date sensor behavior in a timezone besides UTC.""" - new_tz = dt_util.get_time_zone("America/New_York") - assert new_tz is not None - dt_util.set_default_time_zone(new_tz) + hass.config.set_time_zone("America/New_York") device = time_date.TimeDateSensor(hass, "date") now = dt_util.utc_from_timestamp(50000) @@ -128,9 +115,7 @@ async def test_timezone_intervals(hass): # so the second day was 18000 + 86400 assert next_time.timestamp() == 104400 - new_tz = dt_util.get_time_zone("America/Edmonton") - assert new_tz is not None - dt_util.set_default_time_zone(new_tz) + hass.config.set_time_zone("America/Edmonton") now = dt_util.parse_datetime("2017-11-13 19:47:19-07:00") device = time_date.TimeDateSensor(hass, "date") with patch("homeassistant.util.dt.utcnow", return_value=now): @@ -138,9 +123,7 @@ async def test_timezone_intervals(hass): assert next_time.timestamp() == dt_util.as_timestamp("2017-11-14 00:00:00-07:00") # Entering DST - new_tz = dt_util.get_time_zone("Europe/Prague") - assert new_tz is not None - dt_util.set_default_time_zone(new_tz) + hass.config.set_time_zone("Europe/Prague") now = dt_util.parse_datetime("2020-03-29 00:00+01:00") with patch("homeassistant.util.dt.utcnow", return_value=now): @@ -168,11 +151,9 @@ async def test_timezone_intervals(hass): "homeassistant.util.dt.utcnow", return_value=dt_util.parse_datetime("2017-11-14 02:47:19-00:00"), ) -async def test_timezone_intervals_empty_parameter(hass): +async def test_timezone_intervals_empty_parameter(utcnow_mock, hass): """Test get_interval() without parameters.""" - new_tz = dt_util.get_time_zone("America/Edmonton") - assert new_tz is not None - dt_util.set_default_time_zone(new_tz) + hass.config.set_time_zone("America/Edmonton") device = time_date.TimeDateSensor(hass, "date") next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2017-11-14 00:00:00-07:00") diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py index 06f29436d6e05..ecdabdb910d45 100644 --- a/tests/components/tod/test_binary_sensor.py +++ b/tests/components/tod/test_binary_sensor.py @@ -12,8 +12,6 @@ from tests.common import assert_setup_component -ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE - @pytest.fixture(autouse=True) def mock_legacy_time(legacy_patchable_time): @@ -28,13 +26,6 @@ def setup_fixture(hass): hass.config.longitude = 18.98583 -@pytest.fixture(autouse=True) -def restore_timezone(hass): - """Make sure we change timezone.""" - yield - dt_util.set_default_time_zone(ORIG_TIMEZONE) - - async def test_setup(hass): """Test the setup.""" config = { @@ -69,7 +60,9 @@ async def test_setup_no_sensors(hass): async def test_in_period_on_start(hass): """Test simple setting.""" - test_time = datetime(2019, 1, 10, 18, 43, 0, tzinfo=dt_util.UTC) + test_time = datetime( + 2019, 1, 10, 18, 43, 0, tzinfo=dt_util.get_time_zone(hass.config.time_zone) + ) config = { "binary_sensor": [ { @@ -93,7 +86,9 @@ async def test_in_period_on_start(hass): async def test_midnight_turnover_before_midnight_inside_period(hass): """Test midnight turnover setting before midnight inside period .""" - test_time = datetime(2019, 1, 10, 22, 30, 0, tzinfo=dt_util.UTC) + test_time = datetime( + 2019, 1, 10, 22, 30, 0, tzinfo=dt_util.get_time_zone(hass.config.time_zone) + ) config = { "binary_sensor": [ {"platform": "tod", "name": "Night", "after": "22:00", "before": "5:00"} @@ -112,7 +107,9 @@ async def test_midnight_turnover_before_midnight_inside_period(hass): async def test_midnight_turnover_after_midnight_inside_period(hass): """Test midnight turnover setting before midnight inside period .""" - test_time = datetime(2019, 1, 10, 21, 0, 0, tzinfo=dt_util.UTC) + test_time = datetime( + 2019, 1, 10, 21, 0, 0, tzinfo=dt_util.get_time_zone(hass.config.time_zone) + ) config = { "binary_sensor": [ {"platform": "tod", "name": "Night", "after": "22:00", "before": "5:00"} @@ -184,7 +181,9 @@ async def test_after_happens_tomorrow(hass): async def test_midnight_turnover_after_midnight_outside_period(hass): """Test midnight turnover setting before midnight inside period .""" - test_time = datetime(2019, 1, 10, 20, 0, 0, tzinfo=dt_util.UTC) + test_time = datetime( + 2019, 1, 10, 20, 0, 0, tzinfo=dt_util.get_time_zone(hass.config.time_zone) + ) config = { "binary_sensor": [ @@ -201,7 +200,9 @@ async def test_midnight_turnover_after_midnight_outside_period(hass): state = hass.states.get("binary_sensor.night") assert state.state == STATE_OFF - switchover_time = datetime(2019, 1, 11, 4, 59, 0, tzinfo=dt_util.UTC) + switchover_time = datetime( + 2019, 1, 11, 4, 59, 0, tzinfo=dt_util.get_time_zone(hass.config.time_zone) + ) with patch( "homeassistant.components.tod.binary_sensor.dt_util.utcnow", return_value=switchover_time, @@ -420,13 +421,13 @@ async def test_from_sunset_to_sunrise(hass): async def test_offset(hass): """Test offset.""" - after = datetime(2019, 1, 10, 18, 0, 0, tzinfo=dt_util.UTC) + timedelta( - hours=1, minutes=34 - ) + after = datetime( + 2019, 1, 10, 18, 0, 0, tzinfo=dt_util.get_time_zone(hass.config.time_zone) + ) + timedelta(hours=1, minutes=34) - before = datetime(2019, 1, 10, 22, 0, 0, tzinfo=dt_util.UTC) + timedelta( - hours=1, minutes=45 - ) + before = datetime( + 2019, 1, 10, 22, 0, 0, tzinfo=dt_util.get_time_zone(hass.config.time_zone) + ) + timedelta(hours=1, minutes=45) entity_id = "binary_sensor.evening" config = { @@ -499,9 +500,9 @@ async def test_offset(hass): async def test_offset_overnight(hass): """Test offset overnight.""" - after = datetime(2019, 1, 10, 18, 0, 0, tzinfo=dt_util.UTC) + timedelta( - hours=1, minutes=34 - ) + after = datetime( + 2019, 1, 10, 18, 0, 0, tzinfo=dt_util.get_time_zone(hass.config.time_zone) + ) + timedelta(hours=1, minutes=34) entity_id = "binary_sensor.evening" config = { "binary_sensor": [ @@ -890,8 +891,7 @@ async def test_sun_offset(hass): async def test_dst(hass): """Test sun event with offset.""" - hass.config.time_zone = "CET" - dt_util.set_default_time_zone(dt_util.get_time_zone("CET")) + hass.config.set_time_zone("CET") test_time = datetime(2019, 3, 30, 3, 0, 0, tzinfo=dt_util.UTC) config = { "binary_sensor": [ @@ -919,8 +919,7 @@ async def test_dst(hass): async def test_simple_before_after_does_not_loop_utc_not_in_range(hass): """Test simple before after.""" - hass.config.time_zone = "UTC" - dt_util.set_default_time_zone(dt_util.UTC) + hass.config.set_time_zone("UTC") test_time = datetime(2019, 1, 10, 18, 43, 0, tzinfo=dt_util.UTC) config = { "binary_sensor": [ @@ -948,8 +947,7 @@ async def test_simple_before_after_does_not_loop_utc_not_in_range(hass): async def test_simple_before_after_does_not_loop_utc_in_range(hass): """Test simple before after.""" - hass.config.time_zone = "UTC" - dt_util.set_default_time_zone(dt_util.UTC) + hass.config.set_time_zone("UTC") test_time = datetime(2019, 1, 10, 22, 43, 0, tzinfo=dt_util.UTC) config = { "binary_sensor": [ @@ -977,8 +975,7 @@ async def test_simple_before_after_does_not_loop_utc_in_range(hass): async def test_simple_before_after_does_not_loop_utc_fire_at_before(hass): """Test simple before after.""" - hass.config.time_zone = "UTC" - dt_util.set_default_time_zone(dt_util.UTC) + hass.config.set_time_zone("UTC") test_time = datetime(2019, 1, 11, 6, 0, 0, tzinfo=dt_util.UTC) config = { "binary_sensor": [ @@ -1006,8 +1003,7 @@ async def test_simple_before_after_does_not_loop_utc_fire_at_before(hass): async def test_simple_before_after_does_not_loop_utc_fire_at_after(hass): """Test simple before after.""" - hass.config.time_zone = "UTC" - dt_util.set_default_time_zone(dt_util.UTC) + hass.config.set_time_zone("UTC") test_time = datetime(2019, 1, 10, 22, 0, 0, tzinfo=dt_util.UTC) config = { "binary_sensor": [ @@ -1035,8 +1031,7 @@ async def test_simple_before_after_does_not_loop_utc_fire_at_after(hass): async def test_simple_before_after_does_not_loop_utc_both_before_now(hass): """Test simple before after.""" - hass.config.time_zone = "UTC" - dt_util.set_default_time_zone(dt_util.UTC) + hass.config.set_time_zone("UTC") test_time = datetime(2019, 1, 10, 22, 0, 0, tzinfo=dt_util.UTC) config = { "binary_sensor": [ @@ -1064,8 +1059,7 @@ async def test_simple_before_after_does_not_loop_utc_both_before_now(hass): async def test_simple_before_after_does_not_loop_berlin_not_in_range(hass): """Test simple before after.""" - hass.config.time_zone = "Europe/Berlin" - dt_util.set_default_time_zone(dt_util.get_time_zone("Europe/Berlin")) + hass.config.set_time_zone("Europe/Berlin") test_time = datetime(2019, 1, 10, 18, 43, 0, tzinfo=dt_util.UTC) config = { "binary_sensor": [ @@ -1093,8 +1087,7 @@ async def test_simple_before_after_does_not_loop_berlin_not_in_range(hass): async def test_simple_before_after_does_not_loop_berlin_in_range(hass): """Test simple before after.""" - hass.config.time_zone = "Europe/Berlin" - dt_util.set_default_time_zone(dt_util.get_time_zone("Europe/Berlin")) + hass.config.set_time_zone("Europe/Berlin") test_time = datetime(2019, 1, 10, 23, 43, 0, tzinfo=dt_util.UTC) config = { "binary_sensor": [ diff --git a/tests/components/tomorrowio/__init__.py b/tests/components/tomorrowio/__init__.py new file mode 100644 index 0000000000000..de0ec5d135a03 --- /dev/null +++ b/tests/components/tomorrowio/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tomorrow.io Weather API integration.""" diff --git a/tests/components/tomorrowio/conftest.py b/tests/components/tomorrowio/conftest.py new file mode 100644 index 0000000000000..65c69209f0e3e --- /dev/null +++ b/tests/components/tomorrowio/conftest.py @@ -0,0 +1,46 @@ +"""Configure py.test.""" +import json +from unittest.mock import patch + +import pytest + +from tests.common import load_fixture + + +@pytest.fixture(name="tomorrowio_config_flow_connect", autouse=True) +def tomorrowio_config_flow_connect(): + """Mock valid tomorrowio config flow setup.""" + with patch( + "homeassistant.components.tomorrowio.config_flow.TomorrowioV4.realtime", + return_value={}, + ): + yield + + +@pytest.fixture(name="tomorrowio_config_entry_update", autouse=True) +def tomorrowio_config_entry_update_fixture(): + """Mock valid tomorrowio config entry setup.""" + with patch( + "homeassistant.components.tomorrowio.TomorrowioV4.realtime_and_all_forecasts", + return_value=json.loads(load_fixture("v4.json", "tomorrowio")), + ): + yield + + +@pytest.fixture(name="climacell_config_entry_update") +def climacell_config_entry_update_fixture(): + """Mock valid climacell config entry setup.""" + with patch( + "homeassistant.components.climacell.ClimaCellV3.realtime", + return_value={}, + ), patch( + "homeassistant.components.climacell.ClimaCellV3.forecast_hourly", + return_value={}, + ), patch( + "homeassistant.components.climacell.ClimaCellV3.forecast_daily", + return_value={}, + ), patch( + "homeassistant.components.climacell.ClimaCellV3.forecast_nowcast", + return_value={}, + ): + yield diff --git a/tests/components/tomorrowio/const.py b/tests/components/tomorrowio/const.py new file mode 100644 index 0000000000000..8cb00c6707ee1 --- /dev/null +++ b/tests/components/tomorrowio/const.py @@ -0,0 +1,21 @@ +"""Constants for tomorrowio tests.""" + +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE + +API_KEY = "aa" + +MIN_CONFIG = { + CONF_API_KEY: API_KEY, +} + +V1_ENTRY_DATA = { + CONF_API_KEY: API_KEY, + CONF_LATITUDE: 80, + CONF_LONGITUDE: 80, +} + +API_V4_ENTRY_DATA = { + CONF_API_KEY: API_KEY, + CONF_LATITUDE: 80, + CONF_LONGITUDE: 80, +} diff --git a/tests/components/tomorrowio/fixtures/v4.json b/tests/components/tomorrowio/fixtures/v4.json new file mode 100644 index 0000000000000..5cd86b5b60ebf --- /dev/null +++ b/tests/components/tomorrowio/fixtures/v4.json @@ -0,0 +1,2384 @@ +{ + "current": { + "temperature": 44.13, + "humidity": 22.71, + "pressureSeaLevel": 30.35, + "windSpeed": 9.33, + "windDirection": 315.14, + "weatherCode": 1000, + "visibility": 8.15, + "pollutantO3": 46.53, + "windGust": 12.64, + "cloudCover": 100, + "precipitationType": 1, + "particulateMatter25": 0.15, + "particulateMatter10": 0.57, + "pollutantNO2": 10.67, + "pollutantCO": 0.63, + "pollutantSO2": 1.65, + "epaIndex": 24, + "epaPrimaryPollutant": 0, + "epaHealthConcern": 0, + "mepIndex": 23, + "mepPrimaryPollutant": 1, + "mepHealthConcern": 0, + "treeIndex": 0, + "weedIndex": 0, + "grassIndex": 0, + "fireIndex": 10, + "temperatureApparent": 101.3, + "dewPoint": 72.82, + "pressureSurfaceLevel": 29.47, + "solarGHI": 0, + "cloudBase": 0.74, + "cloudCeiling": 0.74 + }, + "forecasts": { + "nowcast": [ + { + "startTime": "2021-03-07T17:48:00Z", + "values": { + "temperatureMin": 44.13, + "temperatureMax": 44.13, + "windSpeed": 9.33, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T17:53:00Z", + "values": { + "temperatureMin": 43.9, + "temperatureMax": 43.9, + "windSpeed": 9.31, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T17:58:00Z", + "values": { + "temperatureMin": 43.68, + "temperatureMax": 43.68, + "windSpeed": 9.28, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:03:00Z", + "values": { + "temperatureMin": 43.66, + "temperatureMax": 43.66, + "windSpeed": 9.26, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:08:00Z", + "values": { + "temperatureMin": 43.79, + "temperatureMax": 43.79, + "windSpeed": 9.22, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:13:00Z", + "values": { + "temperatureMin": 43.92, + "temperatureMax": 43.92, + "windSpeed": 9.17, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:18:00Z", + "values": { + "temperatureMin": 44.04, + "temperatureMax": 44.04, + "windSpeed": 9.13, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:23:00Z", + "values": { + "temperatureMin": 44.17, + "temperatureMax": 44.17, + "windSpeed": 9.06, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:28:00Z", + "values": { + "temperatureMin": 44.31, + "temperatureMax": 44.31, + "windSpeed": 9.02, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:33:00Z", + "values": { + "temperatureMin": 44.44, + "temperatureMax": 44.44, + "windSpeed": 8.97, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:38:00Z", + "values": { + "temperatureMin": 44.56, + "temperatureMax": 44.56, + "windSpeed": 8.93, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:43:00Z", + "values": { + "temperatureMin": 44.69, + "temperatureMax": 44.69, + "windSpeed": 8.88, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:48:00Z", + "values": { + "temperatureMin": 44.82, + "temperatureMax": 44.82, + "windSpeed": 8.84, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:53:00Z", + "values": { + "temperatureMin": 44.94, + "temperatureMax": 44.94, + "windSpeed": 8.79, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:58:00Z", + "values": { + "temperatureMin": 45.07, + "temperatureMax": 45.07, + "windSpeed": 8.75, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:03:00Z", + "values": { + "temperatureMin": 45.16, + "temperatureMax": 45.16, + "windSpeed": 8.75, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:08:00Z", + "values": { + "temperatureMin": 45.23, + "temperatureMax": 45.23, + "windSpeed": 8.75, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:13:00Z", + "values": { + "temperatureMin": 45.28, + "temperatureMax": 45.28, + "windSpeed": 8.77, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:18:00Z", + "values": { + "temperatureMin": 45.36, + "temperatureMax": 45.36, + "windSpeed": 8.79, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:23:00Z", + "values": { + "temperatureMin": 45.43, + "temperatureMax": 45.43, + "windSpeed": 8.81, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:28:00Z", + "values": { + "temperatureMin": 45.5, + "temperatureMax": 45.5, + "windSpeed": 8.81, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:33:00Z", + "values": { + "temperatureMin": 45.55, + "temperatureMax": 45.55, + "windSpeed": 8.84, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:38:00Z", + "values": { + "temperatureMin": 45.63, + "temperatureMax": 45.63, + "windSpeed": 8.86, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:43:00Z", + "values": { + "temperatureMin": 45.7, + "temperatureMax": 45.7, + "windSpeed": 8.88, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:48:00Z", + "values": { + "temperatureMin": 45.75, + "temperatureMax": 45.75, + "windSpeed": 8.9, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:53:00Z", + "values": { + "temperatureMin": 45.82, + "temperatureMax": 45.82, + "windSpeed": 8.9, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:58:00Z", + "values": { + "temperatureMin": 45.9, + "temperatureMax": 45.9, + "windSpeed": 8.93, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:03:00Z", + "values": { + "temperatureMin": 45.88, + "temperatureMax": 45.88, + "windSpeed": 8.97, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:08:00Z", + "values": { + "temperatureMin": 45.82, + "temperatureMax": 45.82, + "windSpeed": 9.02, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:13:00Z", + "values": { + "temperatureMin": 45.75, + "temperatureMax": 45.75, + "windSpeed": 9.06, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:18:00Z", + "values": { + "temperatureMin": 45.7, + "temperatureMax": 45.7, + "windSpeed": 9.1, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:23:00Z", + "values": { + "temperatureMin": 45.63, + "temperatureMax": 45.63, + "windSpeed": 9.15, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:28:00Z", + "values": { + "temperatureMin": 45.57, + "temperatureMax": 45.57, + "windSpeed": 9.19, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:33:00Z", + "values": { + "temperatureMin": 45.5, + "temperatureMax": 45.5, + "windSpeed": 9.24, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:38:00Z", + "values": { + "temperatureMin": 45.45, + "temperatureMax": 45.45, + "windSpeed": 9.28, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:43:00Z", + "values": { + "temperatureMin": 45.39, + "temperatureMax": 45.39, + "windSpeed": 9.33, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:48:00Z", + "values": { + "temperatureMin": 45.32, + "temperatureMax": 45.32, + "windSpeed": 9.37, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:53:00Z", + "values": { + "temperatureMin": 45.27, + "temperatureMax": 45.27, + "windSpeed": 9.42, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:58:00Z", + "values": { + "temperatureMin": 45.19, + "temperatureMax": 45.19, + "windSpeed": 9.46, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:03:00Z", + "values": { + "temperatureMin": 45.14, + "temperatureMax": 45.14, + "windSpeed": 9.4, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:08:00Z", + "values": { + "temperatureMin": 45.07, + "temperatureMax": 45.07, + "windSpeed": 9.24, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:13:00Z", + "values": { + "temperatureMin": 45.01, + "temperatureMax": 45.01, + "windSpeed": 9.08, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:18:00Z", + "values": { + "temperatureMin": 44.94, + "temperatureMax": 44.94, + "windSpeed": 8.95, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:23:00Z", + "values": { + "temperatureMin": 44.89, + "temperatureMax": 44.89, + "windSpeed": 8.79, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:28:00Z", + "values": { + "temperatureMin": 44.82, + "temperatureMax": 44.82, + "windSpeed": 8.63, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:33:00Z", + "values": { + "temperatureMin": 44.76, + "temperatureMax": 44.76, + "windSpeed": 8.5, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:38:00Z", + "values": { + "temperatureMin": 44.69, + "temperatureMax": 44.69, + "windSpeed": 8.34, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:43:00Z", + "values": { + "temperatureMin": 44.64, + "temperatureMax": 44.64, + "windSpeed": 8.19, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:48:00Z", + "values": { + "temperatureMin": 44.56, + "temperatureMax": 44.56, + "windSpeed": 8.05, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:53:00Z", + "values": { + "temperatureMin": 44.51, + "temperatureMax": 44.51, + "windSpeed": 7.9, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:58:00Z", + "values": { + "temperatureMin": 44.44, + "temperatureMax": 44.44, + "windSpeed": 7.74, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:03:00Z", + "values": { + "temperatureMin": 44.26, + "temperatureMax": 44.26, + "windSpeed": 7.47, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:08:00Z", + "values": { + "temperatureMin": 44.01, + "temperatureMax": 44.01, + "windSpeed": 7.14, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:13:00Z", + "values": { + "temperatureMin": 43.74, + "temperatureMax": 43.74, + "windSpeed": 6.78, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:18:00Z", + "values": { + "temperatureMin": 43.48, + "temperatureMax": 43.48, + "windSpeed": 6.44, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:23:00Z", + "values": { + "temperatureMin": 43.23, + "temperatureMax": 43.23, + "windSpeed": 6.08, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:28:00Z", + "values": { + "temperatureMin": 42.98, + "temperatureMax": 42.98, + "windSpeed": 5.75, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:33:00Z", + "values": { + "temperatureMin": 42.71, + "temperatureMax": 42.71, + "windSpeed": 5.39, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:38:00Z", + "values": { + "temperatureMin": 42.46, + "temperatureMax": 42.46, + "windSpeed": 5.06, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:43:00Z", + "values": { + "temperatureMin": 42.21, + "temperatureMax": 42.21, + "windSpeed": 4.7, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:48:00Z", + "values": { + "temperatureMin": 41.94, + "temperatureMax": 41.94, + "windSpeed": 4.36, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:53:00Z", + "values": { + "temperatureMin": 41.68, + "temperatureMax": 41.68, + "windSpeed": 4, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:58:00Z", + "values": { + "temperatureMin": 41.43, + "temperatureMax": 41.43, + "windSpeed": 3.67, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:03:00Z", + "values": { + "temperatureMin": 41.16, + "temperatureMax": 41.16, + "windSpeed": 3.6, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:08:00Z", + "values": { + "temperatureMin": 40.91, + "temperatureMax": 40.91, + "windSpeed": 3.76, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:13:00Z", + "values": { + "temperatureMin": 40.66, + "temperatureMax": 40.66, + "windSpeed": 3.91, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:18:00Z", + "values": { + "temperatureMin": 40.41, + "temperatureMax": 40.41, + "windSpeed": 4.05, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:23:00Z", + "values": { + "temperatureMin": 40.14, + "temperatureMax": 40.14, + "windSpeed": 4.21, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:28:00Z", + "values": { + "temperatureMin": 39.88, + "temperatureMax": 39.88, + "windSpeed": 4.36, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:33:00Z", + "values": { + "temperatureMin": 39.63, + "temperatureMax": 39.63, + "windSpeed": 4.5, + "windDirection": 295.94, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:38:00Z", + "values": { + "temperatureMin": 39.38, + "temperatureMax": 39.38, + "windSpeed": 4.65, + "windDirection": 295.94, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:43:00Z", + "values": { + "temperatureMin": 39.11, + "temperatureMax": 39.11, + "windSpeed": 4.79, + "windDirection": 295.94, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + } + ], + "hourly": [ + { + "startTime": "2021-03-07T17:48:00Z", + "values": { + "temperatureMin": 44.13, + "temperatureMax": 44.13, + "windSpeed": 9.33, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:48:00Z", + "values": { + "temperatureMin": 44.82, + "temperatureMax": 44.82, + "windSpeed": 8.84, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:48:00Z", + "values": { + "temperatureMin": 45.75, + "temperatureMax": 45.75, + "windSpeed": 8.9, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:48:00Z", + "values": { + "temperatureMin": 45.32, + "temperatureMax": 45.32, + "windSpeed": 9.37, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:48:00Z", + "values": { + "temperatureMin": 44.56, + "temperatureMax": 44.56, + "windSpeed": 8.05, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:48:00Z", + "values": { + "temperatureMin": 41.94, + "temperatureMax": 41.94, + "windSpeed": 4.36, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:48:00Z", + "values": { + "temperatureMin": 38.86, + "temperatureMax": 38.86, + "windSpeed": 4.94, + "windDirection": 295.94, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T00:48:00Z", + "values": { + "temperatureMin": 36.18, + "temperatureMax": 36.18, + "windSpeed": 5.59, + "windDirection": 11.94, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T01:48:00Z", + "values": { + "temperatureMin": 34.3, + "temperatureMax": 34.3, + "windSpeed": 5.57, + "windDirection": 13.68, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T02:48:00Z", + "values": { + "temperatureMin": 32.88, + "temperatureMax": 32.88, + "windSpeed": 5.41, + "windDirection": 14.93, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T03:48:00Z", + "values": { + "temperatureMin": 31.91, + "temperatureMax": 31.91, + "windSpeed": 4.61, + "windDirection": 26.07, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T04:48:00Z", + "values": { + "temperatureMin": 29.17, + "temperatureMax": 29.17, + "windSpeed": 2.59, + "windDirection": 51.27, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T05:48:00Z", + "values": { + "temperatureMin": 27.37, + "temperatureMax": 27.37, + "windSpeed": 3.31, + "windDirection": 343.25, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T06:48:00Z", + "values": { + "temperatureMin": 26.73, + "temperatureMax": 26.73, + "windSpeed": 4.27, + "windDirection": 341.46, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T07:48:00Z", + "values": { + "temperatureMin": 26.38, + "temperatureMax": 26.38, + "windSpeed": 3.53, + "windDirection": 322.34, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T08:48:00Z", + "values": { + "temperatureMin": 26.15, + "temperatureMax": 26.15, + "windSpeed": 3.65, + "windDirection": 294.69, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T09:48:00Z", + "values": { + "temperatureMin": 30.07, + "temperatureMax": 30.07, + "windSpeed": 3.2, + "windDirection": 325.32, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T10:48:00Z", + "values": { + "temperatureMin": 31.03, + "temperatureMax": 31.03, + "windSpeed": 2.84, + "windDirection": 322.27, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T11:48:00Z", + "values": { + "temperatureMin": 27.23, + "temperatureMax": 27.23, + "windSpeed": 5.59, + "windDirection": 310.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T12:48:00Z", + "values": { + "temperatureMin": 29.21, + "temperatureMax": 29.21, + "windSpeed": 7.05, + "windDirection": 324.8, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T13:48:00Z", + "values": { + "temperatureMin": 33.19, + "temperatureMax": 33.19, + "windSpeed": 6.46, + "windDirection": 335.16, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T14:48:00Z", + "values": { + "temperatureMin": 37.02, + "temperatureMax": 37.02, + "windSpeed": 5.88, + "windDirection": 324.49, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T15:48:00Z", + "values": { + "temperatureMin": 40.01, + "temperatureMax": 40.01, + "windSpeed": 5.55, + "windDirection": 310.68, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T16:48:00Z", + "values": { + "temperatureMin": 42.37, + "temperatureMax": 42.37, + "windSpeed": 5.46, + "windDirection": 304.18, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T17:48:00Z", + "values": { + "temperatureMin": 44.62, + "temperatureMax": 44.62, + "windSpeed": 4.99, + "windDirection": 301.19, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T18:48:00Z", + "values": { + "temperatureMin": 46.78, + "temperatureMax": 46.78, + "windSpeed": 4.72, + "windDirection": 295.05, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T19:48:00Z", + "values": { + "temperatureMin": 48.42, + "temperatureMax": 48.42, + "windSpeed": 4.81, + "windDirection": 287.4, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T20:48:00Z", + "values": { + "temperatureMin": 49.28, + "temperatureMax": 49.28, + "windSpeed": 4.74, + "windDirection": 282.48, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T21:48:00Z", + "values": { + "temperatureMin": 48.72, + "temperatureMax": 48.72, + "windSpeed": 2.51, + "windDirection": 268.74, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T22:48:00Z", + "values": { + "temperatureMin": 44.37, + "temperatureMax": 44.37, + "windSpeed": 3.56, + "windDirection": 180.04, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T23:48:00Z", + "values": { + "temperatureMin": 39.9, + "temperatureMax": 39.9, + "windSpeed": 4.68, + "windDirection": 177.89, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T00:48:00Z", + "values": { + "temperatureMin": 37.87, + "temperatureMax": 37.87, + "windSpeed": 5.21, + "windDirection": 197.47, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T01:48:00Z", + "values": { + "temperatureMin": 36.91, + "temperatureMax": 36.91, + "windSpeed": 5.46, + "windDirection": 209.77, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T02:48:00Z", + "values": { + "temperatureMin": 36.64, + "temperatureMax": 36.64, + "windSpeed": 6.11, + "windDirection": 210.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T03:48:00Z", + "values": { + "temperatureMin": 36.63, + "temperatureMax": 36.63, + "windSpeed": 6.4, + "windDirection": 216, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T04:48:00Z", + "values": { + "temperatureMin": 36.23, + "temperatureMax": 36.23, + "windSpeed": 6.22, + "windDirection": 223.92, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T05:48:00Z", + "values": { + "temperatureMin": 35.58, + "temperatureMax": 35.58, + "windSpeed": 5.75, + "windDirection": 229.68, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T06:48:00Z", + "values": { + "temperatureMin": 34.68, + "temperatureMax": 34.68, + "windSpeed": 5.21, + "windDirection": 235.24, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T07:48:00Z", + "values": { + "temperatureMin": 33.69, + "temperatureMax": 33.69, + "windSpeed": 4.81, + "windDirection": 237.24, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T08:48:00Z", + "values": { + "temperatureMin": 32.74, + "temperatureMax": 32.74, + "windSpeed": 4.52, + "windDirection": 239.35, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T09:48:00Z", + "values": { + "temperatureMin": 32.05, + "temperatureMax": 32.05, + "windSpeed": 4.32, + "windDirection": 245.68, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T10:48:00Z", + "values": { + "temperatureMin": 31.57, + "temperatureMax": 31.57, + "windSpeed": 4.14, + "windDirection": 248.11, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T11:48:00Z", + "values": { + "temperatureMin": 32.92, + "temperatureMax": 32.92, + "windSpeed": 4.32, + "windDirection": 249.54, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T12:48:00Z", + "values": { + "temperatureMin": 38.5, + "temperatureMax": 38.5, + "windSpeed": 4.7, + "windDirection": 253.3, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T13:48:00Z", + "values": { + "temperatureMin": 46.08, + "temperatureMax": 46.08, + "windSpeed": 4.41, + "windDirection": 258.49, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T14:48:00Z", + "values": { + "temperatureMin": 53.26, + "temperatureMax": 53.26, + "windSpeed": 4.9, + "windDirection": 260.49, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T15:48:00Z", + "values": { + "temperatureMin": 58.15, + "temperatureMax": 58.15, + "windSpeed": 5.55, + "windDirection": 261.29, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T16:48:00Z", + "values": { + "temperatureMin": 61.56, + "temperatureMax": 61.56, + "windSpeed": 6.35, + "windDirection": 264.3, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T17:48:00Z", + "values": { + "temperatureMin": 64, + "temperatureMax": 64, + "windSpeed": 6.6, + "windDirection": 257.54, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T18:48:00Z", + "values": { + "temperatureMin": 65.79, + "temperatureMax": 65.79, + "windSpeed": 6.96, + "windDirection": 253.12, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T19:48:00Z", + "values": { + "temperatureMin": 66.74, + "temperatureMax": 66.74, + "windSpeed": 6.8, + "windDirection": 259.46, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T20:48:00Z", + "values": { + "temperatureMin": 66.96, + "temperatureMax": 66.96, + "windSpeed": 6.33, + "windDirection": 294.25, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T21:48:00Z", + "values": { + "temperatureMin": 64.35, + "temperatureMax": 64.35, + "windSpeed": 3.91, + "windDirection": 279.37, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T22:48:00Z", + "values": { + "temperatureMin": 61.07, + "temperatureMax": 61.07, + "windSpeed": 3.65, + "windDirection": 218.19, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T23:48:00Z", + "values": { + "temperatureMin": 56.3, + "temperatureMax": 56.3, + "windSpeed": 4.09, + "windDirection": 208.3, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T00:48:00Z", + "values": { + "temperatureMin": 53.19, + "temperatureMax": 53.19, + "windSpeed": 4.21, + "windDirection": 216.42, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T01:48:00Z", + "values": { + "temperatureMin": 51.94, + "temperatureMax": 51.94, + "windSpeed": 3.38, + "windDirection": 257.19, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T02:48:00Z", + "values": { + "temperatureMin": 49.82, + "temperatureMax": 49.82, + "windSpeed": 2.71, + "windDirection": 288.85, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T03:48:00Z", + "values": { + "temperatureMin": 48.24, + "temperatureMax": 48.24, + "windSpeed": 2.8, + "windDirection": 334.41, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T04:48:00Z", + "values": { + "temperatureMin": 47.44, + "temperatureMax": 47.44, + "windSpeed": 2.26, + "windDirection": 342.01, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T05:48:00Z", + "values": { + "temperatureMin": 45.59, + "temperatureMax": 45.59, + "windSpeed": 2.35, + "windDirection": 2.43, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T06:48:00Z", + "values": { + "temperatureMin": 43.43, + "temperatureMax": 43.43, + "windSpeed": 2.3, + "windDirection": 336.56, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T07:48:00Z", + "values": { + "temperatureMin": 41.11, + "temperatureMax": 41.11, + "windSpeed": 2.71, + "windDirection": 4.41, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T08:48:00Z", + "values": { + "temperatureMin": 39.58, + "temperatureMax": 39.58, + "windSpeed": 3.4, + "windDirection": 21.26, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T09:48:00Z", + "values": { + "temperatureMin": 39.85, + "temperatureMax": 39.85, + "windSpeed": 3.31, + "windDirection": 22.76, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T10:48:00Z", + "values": { + "temperatureMin": 37.85, + "temperatureMax": 37.85, + "windSpeed": 4.03, + "windDirection": 29.3, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T11:48:00Z", + "values": { + "temperatureMin": 38.97, + "temperatureMax": 38.97, + "windSpeed": 3.15, + "windDirection": 21.82, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T12:48:00Z", + "values": { + "temperatureMin": 44.31, + "temperatureMax": 44.31, + "windSpeed": 3.53, + "windDirection": 14.25, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T13:48:00Z", + "values": { + "temperatureMin": 50.25, + "temperatureMax": 50.25, + "windSpeed": 2.82, + "windDirection": 42.41, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T14:48:00Z", + "values": { + "temperatureMin": 54.97, + "temperatureMax": 54.97, + "windSpeed": 2.53, + "windDirection": 87.81, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T15:48:00Z", + "values": { + "temperatureMin": 58.46, + "temperatureMax": 58.46, + "windSpeed": 3.09, + "windDirection": 125.82, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T16:48:00Z", + "values": { + "temperatureMin": 61.21, + "temperatureMax": 61.21, + "windSpeed": 4.03, + "windDirection": 157.54, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T17:48:00Z", + "values": { + "temperatureMin": 63.36, + "temperatureMax": 63.36, + "windSpeed": 5.21, + "windDirection": 166.66, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T18:48:00Z", + "values": { + "temperatureMin": 64.83, + "temperatureMax": 64.83, + "windSpeed": 6.93, + "windDirection": 189.24, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T19:48:00Z", + "values": { + "temperatureMin": 65.23, + "temperatureMax": 65.23, + "windSpeed": 8.95, + "windDirection": 194.58, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T20:48:00Z", + "values": { + "temperatureMin": 64.98, + "temperatureMax": 64.98, + "windSpeed": 9.4, + "windDirection": 193.22, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T21:48:00Z", + "values": { + "temperatureMin": 64.06, + "temperatureMax": 64.06, + "windSpeed": 8.55, + "windDirection": 186.39, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T22:48:00Z", + "values": { + "temperatureMin": 61.9, + "temperatureMax": 61.9, + "windSpeed": 7.49, + "windDirection": 171.81, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T23:48:00Z", + "values": { + "temperatureMin": 59.4, + "temperatureMax": 59.4, + "windSpeed": 7.54, + "windDirection": 165.51, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T00:48:00Z", + "values": { + "temperatureMin": 57.63, + "temperatureMax": 57.63, + "windSpeed": 8.12, + "windDirection": 171.94, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T01:48:00Z", + "values": { + "temperatureMin": 56.17, + "temperatureMax": 56.17, + "windSpeed": 8.7, + "windDirection": 176.84, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T02:48:00Z", + "values": { + "temperatureMin": 55.36, + "temperatureMax": 55.36, + "windSpeed": 9.42, + "windDirection": 184.14, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T03:48:00Z", + "values": { + "temperatureMin": 54.88, + "temperatureMax": 54.88, + "windSpeed": 10, + "windDirection": 195.54, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T04:48:00Z", + "values": { + "temperatureMin": 54.14, + "temperatureMax": 54.14, + "windSpeed": 10.4, + "windDirection": 200.56, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T05:48:00Z", + "values": { + "temperatureMin": 53.46, + "temperatureMax": 53.46, + "windSpeed": 10.04, + "windDirection": 198.08, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T06:48:00Z", + "values": { + "temperatureMin": 52.11, + "temperatureMax": 52.11, + "windSpeed": 10.02, + "windDirection": 199.54, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T07:48:00Z", + "values": { + "temperatureMin": 51.64, + "temperatureMax": 51.64, + "windSpeed": 10.51, + "windDirection": 202.73, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T08:48:00Z", + "values": { + "temperatureMin": 50.79, + "temperatureMax": 50.79, + "windSpeed": 10.38, + "windDirection": 203.35, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T09:48:00Z", + "values": { + "temperatureMin": 49.93, + "temperatureMax": 49.93, + "windSpeed": 9.51, + "windDirection": 210.36, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T10:48:00Z", + "values": { + "temperatureMin": 49.1, + "temperatureMax": 49.1, + "windSpeed": 8.61, + "windDirection": 210.6, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T11:48:00Z", + "values": { + "temperatureMin": 48.42, + "temperatureMax": 48.42, + "windSpeed": 9.15, + "windDirection": 211.29, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T12:48:00Z", + "values": { + "temperatureMin": 48.9, + "temperatureMax": 48.9, + "windSpeed": 10.25, + "windDirection": 215.59, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T13:48:00Z", + "values": { + "temperatureMin": 50.54, + "temperatureMax": 50.54, + "windSpeed": 10.18, + "windDirection": 215.48, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T14:48:00Z", + "values": { + "temperatureMin": 53.19, + "temperatureMax": 53.19, + "windSpeed": 9.4, + "windDirection": 208.76, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T15:48:00Z", + "values": { + "temperatureMin": 56.19, + "temperatureMax": 56.19, + "windSpeed": 9.73, + "windDirection": 197.59, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T16:48:00Z", + "values": { + "temperatureMin": 59.34, + "temperatureMax": 59.34, + "windSpeed": 10.69, + "windDirection": 204.29, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T17:48:00Z", + "values": { + "temperatureMin": 62.35, + "temperatureMax": 62.35, + "windSpeed": 11.81, + "windDirection": 204.56, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T18:48:00Z", + "values": { + "temperatureMin": 64.6, + "temperatureMax": 64.6, + "windSpeed": 13.09, + "windDirection": 206.85, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T19:48:00Z", + "values": { + "temperatureMin": 65.91, + "temperatureMax": 65.91, + "windSpeed": 13.82, + "windDirection": 204.82, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T20:48:00Z", + "values": { + "temperatureMin": 66.22, + "temperatureMax": 66.22, + "windSpeed": 14.54, + "windDirection": 208.43, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T21:48:00Z", + "values": { + "temperatureMin": 65.46, + "temperatureMax": 65.46, + "windSpeed": 13.2, + "windDirection": 208.3, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T22:48:00Z", + "values": { + "temperatureMin": 64.35, + "temperatureMax": 64.35, + "windSpeed": 12.35, + "windDirection": 208.58, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T23:48:00Z", + "values": { + "temperatureMin": 62.85, + "temperatureMax": 62.85, + "windSpeed": 12.86, + "windDirection": 205.39, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-12T00:48:00Z", + "values": { + "temperatureMin": 61.75, + "temperatureMax": 61.75, + "windSpeed": 14.7, + "windDirection": 209.51, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-12T01:48:00Z", + "values": { + "temperatureMin": 61.2, + "temperatureMax": 61.2, + "windSpeed": 15.57, + "windDirection": 211.47, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-12T02:48:00Z", + "values": { + "temperatureMin": 60.46, + "temperatureMax": 60.46, + "windSpeed": 14.94, + "windDirection": 211.57, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-12T03:48:00Z", + "values": { + "temperatureMin": 59.94, + "temperatureMax": 59.94, + "windSpeed": 14.29, + "windDirection": 208.93, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-12T04:48:00Z", + "values": { + "temperatureMin": 59.52, + "temperatureMax": 59.52, + "windSpeed": 14.36, + "windDirection": 217.91, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + } + ], + "daily": [ + { + "startTime": "2021-03-07T11:00:00Z", + "values": { + "temperatureMin": 26.11, + "temperatureMax": 45.93, + "windSpeed": 9.49, + "windDirection": 239.6, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T11:00:00Z", + "values": { + "temperatureMin": 26.28, + "temperatureMax": 49.42, + "windSpeed": 7.24, + "windDirection": 262.82, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T11:00:00Z", + "values": { + "temperatureMin": 31.48, + "temperatureMax": 66.98, + "windSpeed": 7.05, + "windDirection": 229.3, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T11:00:00Z", + "values": { + "temperatureMin": 37.32, + "temperatureMax": 65.28, + "windSpeed": 10.64, + "windDirection": 149.91, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T11:00:00Z", + "values": { + "temperatureMin": 48.29, + "temperatureMax": 66.25, + "windSpeed": 15.69, + "windDirection": 210.45, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-12T11:00:00Z", + "values": { + "temperatureMin": 53.83, + "temperatureMax": 67.91, + "windSpeed": 12.3, + "windDirection": 217.98, + "weatherCode": 4000, + "precipitationIntensityAvg": 0.0002, + "precipitationProbability": 25 + } + }, + { + "startTime": "2021-03-13T11:00:00Z", + "values": { + "temperatureMin": 42.91, + "temperatureMax": 54.48, + "windSpeed": 9.72, + "windDirection": 58.79, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 25 + } + }, + { + "startTime": "2021-03-14T10:00:00Z", + "values": { + "temperatureMin": 33.35, + "temperatureMax": 42.91, + "windSpeed": 16.25, + "windDirection": 70.25, + "weatherCode": 5101, + "precipitationIntensityAvg": 0.0393, + "precipitationProbability": 95 + } + }, + { + "startTime": "2021-03-15T10:00:00Z", + "values": { + "temperatureMin": 29.35, + "temperatureMax": 43.67, + "windSpeed": 15.89, + "windDirection": 84.47, + "weatherCode": 5001, + "precipitationIntensityAvg": 0.0024, + "precipitationProbability": 55 + } + }, + { + "startTime": "2021-03-16T10:00:00Z", + "values": { + "temperatureMin": 29.1, + "temperatureMax": 43, + "windSpeed": 6.71, + "windDirection": 103.85, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-17T10:00:00Z", + "values": { + "temperatureMin": 34.32, + "temperatureMax": 52.4, + "windSpeed": 7.27, + "windDirection": 145.41, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-18T10:00:00Z", + "values": { + "temperatureMin": 41.32, + "temperatureMax": 54.07, + "windSpeed": 6.58, + "windDirection": 62.99, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 10 + } + }, + { + "startTime": "2021-03-19T10:00:00Z", + "values": { + "temperatureMin": 39.4, + "temperatureMax": 48.94, + "windSpeed": 13.91, + "windDirection": 68.54, + "weatherCode": 4000, + "precipitationIntensityAvg": 0.0048, + "precipitationProbability": 55 + } + }, + { + "startTime": "2021-03-20T10:00:00Z", + "values": { + "temperatureMin": 35.06, + "temperatureMax": 40.12, + "windSpeed": 17.35, + "windDirection": 56.98, + "weatherCode": 5001, + "precipitationIntensityAvg": 0.002, + "precipitationProbability": 33.3 + } + }, + { + "startTime": "2021-03-21T10:00:00Z", + "values": { + "temperatureMin": 33.66, + "temperatureMax": 66.54, + "windSpeed": 15.93, + "windDirection": 82.57, + "weatherCode": 5001, + "precipitationIntensityAvg": 0.0004, + "precipitationProbability": 45 + } + } + ] + } + } diff --git a/tests/components/tomorrowio/test_config_flow.py b/tests/components/tomorrowio/test_config_flow.py new file mode 100644 index 0000000000000..7e04e213bbb08 --- /dev/null +++ b/tests/components/tomorrowio/test_config_flow.py @@ -0,0 +1,279 @@ +"""Test the Tomorrow.io config flow.""" +from unittest.mock import patch + +from pytomorrowio.exceptions import ( + CantConnectException, + InvalidAPIKeyException, + RateLimitedException, + UnknownException, +) + +from homeassistant import data_entry_flow +from homeassistant.components.climacell.const import DOMAIN as CC_DOMAIN +from homeassistant.components.tomorrowio.config_flow import ( + _get_config_schema, + _get_unique_id, +) +from homeassistant.components.tomorrowio.const import ( + CONF_TIMESTEP, + DEFAULT_NAME, + DEFAULT_TIMESTEP, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, ConfigEntryState +from homeassistant.const import ( + CONF_API_KEY, + CONF_API_VERSION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + CONF_RADIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import API_KEY, MIN_CONFIG + +from tests.common import MockConfigEntry +from tests.components.climacell.const import API_V3_ENTRY_DATA + + +async def test_user_flow_minimum_fields(hass: HomeAssistant) -> None: + """Test user config flow with minimum fields.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=_get_config_schema(hass, SOURCE_USER, MIN_CONFIG)(MIN_CONFIG), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"][CONF_NAME] == DEFAULT_NAME + assert result["data"][CONF_API_KEY] == API_KEY + assert result["data"][CONF_LATITUDE] == hass.config.latitude + assert result["data"][CONF_LONGITUDE] == hass.config.longitude + + +async def test_user_flow_minimum_fields_in_zone(hass: HomeAssistant) -> None: + """Test user config flow with minimum fields.""" + assert await async_setup_component( + hass, + "zone", + { + "zone": { + CONF_NAME: "Home", + CONF_LATITUDE: hass.config.latitude, + CONF_LONGITUDE: hass.config.longitude, + CONF_RADIUS: 100, + } + }, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=_get_config_schema(hass, SOURCE_USER, MIN_CONFIG)(MIN_CONFIG), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == f"{DEFAULT_NAME} - Home" + assert result["data"][CONF_NAME] == f"{DEFAULT_NAME} - Home" + assert result["data"][CONF_API_KEY] == API_KEY + assert result["data"][CONF_LATITUDE] == hass.config.latitude + assert result["data"][CONF_LONGITUDE] == hass.config.longitude + + +async def test_user_flow_same_unique_ids(hass: HomeAssistant) -> None: + """Test user config flow with the same unique ID as an existing entry.""" + user_input = _get_config_schema(hass, SOURCE_USER, MIN_CONFIG)(MIN_CONFIG) + MockConfigEntry( + domain=DOMAIN, + data=user_input, + options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, + source=SOURCE_USER, + unique_id=_get_unique_id(hass, user_input), + version=2, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_user_flow_cannot_connect(hass: HomeAssistant) -> None: + """Test user config flow when Tomorrow.io can't connect.""" + with patch( + "homeassistant.components.tomorrowio.config_flow.TomorrowioV4.realtime", + side_effect=CantConnectException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=_get_config_schema(hass, SOURCE_USER, MIN_CONFIG)(MIN_CONFIG), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_user_flow_invalid_api(hass: HomeAssistant) -> None: + """Test user config flow when API key is invalid.""" + with patch( + "homeassistant.components.tomorrowio.config_flow.TomorrowioV4.realtime", + side_effect=InvalidAPIKeyException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=_get_config_schema(hass, SOURCE_USER, MIN_CONFIG)(MIN_CONFIG), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} + + +async def test_user_flow_rate_limited(hass: HomeAssistant) -> None: + """Test user config flow when API key is rate limited.""" + with patch( + "homeassistant.components.tomorrowio.config_flow.TomorrowioV4.realtime", + side_effect=RateLimitedException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=_get_config_schema(hass, SOURCE_USER, MIN_CONFIG)(MIN_CONFIG), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_API_KEY: "rate_limited"} + + +async def test_user_flow_unknown_exception(hass: HomeAssistant) -> None: + """Test user config flow when unknown error occurs.""" + with patch( + "homeassistant.components.tomorrowio.config_flow.TomorrowioV4.realtime", + side_effect=UnknownException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=_get_config_schema(hass, SOURCE_USER, MIN_CONFIG)(MIN_CONFIG), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options config flow for tomorrowio.""" + user_config = _get_config_schema(hass, SOURCE_USER)(MIN_CONFIG) + entry = MockConfigEntry( + domain=DOMAIN, + data=user_config, + options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, + source=SOURCE_USER, + unique_id=_get_unique_id(hass, user_config), + version=1, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.options[CONF_TIMESTEP] == DEFAULT_TIMESTEP + assert CONF_TIMESTEP not in entry.data + + result = await hass.config_entries.options.async_init(entry.entry_id, data=None) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_TIMESTEP: 1} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "" + assert result["data"][CONF_TIMESTEP] == 1 + assert entry.options[CONF_TIMESTEP] == 1 + + +async def test_import_flow_v4(hass: HomeAssistant) -> None: + """Test import flow for climacell v4 config entry.""" + user_config = API_V3_ENTRY_DATA.copy() + user_config[CONF_API_VERSION] = 4 + old_entry = MockConfigEntry( + domain=CC_DOMAIN, + data=user_config, + source=SOURCE_USER, + unique_id=_get_unique_id(hass, user_config), + version=1, + ) + old_entry.add_to_hass(hass) + await hass.config_entries.async_setup(old_entry.entry_id) + await hass.async_block_till_done() + assert old_entry.state != ConfigEntryState.LOADED + + assert len(hass.config_entries.async_entries(CC_DOMAIN)) == 0 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert "old_config_entry_id" not in entry.data + assert CONF_API_VERSION not in entry.data + + +async def test_import_flow_v3( + hass: HomeAssistant, climacell_config_entry_update +) -> None: + """Test import flow for climacell v3 config entry.""" + user_config = API_V3_ENTRY_DATA + old_entry = MockConfigEntry( + domain=CC_DOMAIN, + data=user_config, + source=SOURCE_USER, + unique_id=_get_unique_id(hass, user_config), + version=1, + ) + old_entry.add_to_hass(hass) + await hass.config_entries.async_setup(old_entry.entry_id) + assert old_entry.state == ConfigEntryState.LOADED + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT, "old_config_entry_id": old_entry.entry_id}, + data=old_entry.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "this is a test"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_API_KEY: "this is a test", + CONF_LATITUDE: 80, + CONF_LONGITUDE: 80, + CONF_NAME: "ClimaCell", + "old_config_entry_id": old_entry.entry_id, + } + + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(CC_DOMAIN)) == 0 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert "old_config_entry_id" not in entry.data + assert CONF_API_VERSION not in entry.data diff --git a/tests/components/tomorrowio/test_init.py b/tests/components/tomorrowio/test_init.py new file mode 100644 index 0000000000000..f0c963d0deec3 --- /dev/null +++ b/tests/components/tomorrowio/test_init.py @@ -0,0 +1,151 @@ +"""Tests for Tomorrow.io init.""" +from homeassistant.components.climacell.const import CONF_TIMESTEP, DOMAIN as CC_DOMAIN +from homeassistant.components.tomorrowio.config_flow import ( + _get_config_schema, + _get_unique_id, +) +from homeassistant.components.tomorrowio.const import DOMAIN +from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_API_VERSION, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import MIN_CONFIG + +from tests.common import MockConfigEntry +from tests.components.climacell.const import API_V3_ENTRY_DATA + +NEW_NAME = "New Name" + + +async def test_load_and_unload(hass: HomeAssistant) -> None: + """Test loading and unloading entry.""" + data = _get_config_schema(hass, SOURCE_USER)(MIN_CONFIG) + data[CONF_NAME] = "test" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=data, + options={CONF_TIMESTEP: 1}, + unique_id=_get_unique_id(hass, data), + version=1, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 1 + + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 0 + + +async def test_climacell_migration_logic( + hass: HomeAssistant, climacell_config_entry_update +) -> None: + """Test that climacell config entry is properly migrated.""" + old_data = API_V3_ENTRY_DATA.copy() + old_data[CONF_API_KEY] = "v3apikey" + old_config_entry = MockConfigEntry( + domain=CC_DOMAIN, + data=old_data, + unique_id=_get_unique_id(hass, old_data), + version=1, + ) + old_config_entry.add_to_hass(hass) + # Let's create a device and update its name + dev_reg = dr.async_get(hass) + old_device = dev_reg.async_get_or_create( + config_entry_id=old_config_entry.entry_id, + identifiers={(CC_DOMAIN, old_data[CONF_API_KEY])}, + manufacturer="ClimaCell", + sw_version="v4", + entry_type="service", + name="ClimaCell", + ) + dev_reg.async_update_device(old_device.id, name_by_user=NEW_NAME) + # Now let's create some entity and update some things to see if everything migrates + # over + ent_reg = er.async_get(hass) + old_entity_daily = ent_reg.async_get_or_create( + "weather", + CC_DOMAIN, + f"{_get_unique_id(hass, old_data)}_daily", + config_entry=old_config_entry, + original_name="ClimaCell - Daily", + suggested_object_id="climacell_daily", + device_id=old_device.id, + ) + old_entity_hourly = ent_reg.async_get_or_create( + "weather", + CC_DOMAIN, + f"{_get_unique_id(hass, old_data)}_hourly", + config_entry=old_config_entry, + original_name="ClimaCell - Hourly", + suggested_object_id="climacell_hourly", + device_id=old_device.id, + disabled_by=er.DISABLED_USER, + ) + old_entity_nowcast = ent_reg.async_get_or_create( + "weather", + CC_DOMAIN, + f"{_get_unique_id(hass, old_data)}_nowcast", + config_entry=old_config_entry, + original_name="ClimaCell - Nowcast", + suggested_object_id="climacell_nowcast", + device_id=old_device.id, + ) + ent_reg.async_update_entity(old_entity_daily.entity_id, name=NEW_NAME) + + # Now let's create a new tomorrowio config entry that is supposedly created from a + # climacell import and see what happens - we are also changing the API key to ensure + # that things work as expected + new_data = API_V3_ENTRY_DATA.copy() + new_data[CONF_API_VERSION] = 4 + new_data["old_config_entry_id"] = old_config_entry.entry_id + config_entry = MockConfigEntry( + domain=DOMAIN, + data=new_data, + unique_id=_get_unique_id(hass, new_data), + version=1, + source=SOURCE_IMPORT, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check that the old device no longer exists + assert dev_reg.async_get(old_device.id) is None + + # Check that the new device was created and that it has the correct name + assert ( + dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)[ + 0 + ].name_by_user + == NEW_NAME + ) + + # Check that the new entities match the old ones (minus the default name) + new_entity_daily = ent_reg.async_get(old_entity_daily.entity_id) + assert new_entity_daily.platform == DOMAIN + assert new_entity_daily.name == NEW_NAME + assert new_entity_daily.original_name == "ClimaCell - Daily" + assert new_entity_daily.device_id != old_device.id + assert new_entity_daily.unique_id == f"{_get_unique_id(hass, new_data)}_daily" + assert new_entity_daily.disabled_by is None + + new_entity_hourly = ent_reg.async_get(old_entity_hourly.entity_id) + assert new_entity_hourly.platform == DOMAIN + assert new_entity_hourly.name is None + assert new_entity_hourly.original_name == "ClimaCell - Hourly" + assert new_entity_hourly.device_id != old_device.id + assert new_entity_hourly.unique_id == f"{_get_unique_id(hass, new_data)}_hourly" + assert new_entity_hourly.disabled_by == er.DISABLED_USER + + new_entity_nowcast = ent_reg.async_get(old_entity_nowcast.entity_id) + assert new_entity_nowcast.platform == DOMAIN + assert new_entity_nowcast.name is None + assert new_entity_nowcast.original_name == "ClimaCell - Nowcast" + assert new_entity_nowcast.device_id != old_device.id + assert new_entity_nowcast.unique_id == f"{_get_unique_id(hass, new_data)}_nowcast" + assert new_entity_nowcast.disabled_by is None diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py new file mode 100644 index 0000000000000..09316ed25180d --- /dev/null +++ b/tests/components/tomorrowio/test_sensor.py @@ -0,0 +1,166 @@ +"""Tests for Tomorrow.io sensor entities.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any +from unittest.mock import patch + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.tomorrowio.config_flow import ( + _get_config_schema, + _get_unique_id, +) +from homeassistant.components.tomorrowio.const import ( + ATTRIBUTION, + CONF_TIMESTEP, + DEFAULT_NAME, + DEFAULT_TIMESTEP, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers.entity_registry import async_get +from homeassistant.util import dt as dt_util + +from .const import API_V4_ENTRY_DATA + +from tests.common import MockConfigEntry + +CC_SENSOR_ENTITY_ID = "sensor.tomorrow_io_{}" + +O3 = "ozone" +CO = "carbon_monoxide" +NO2 = "nitrogen_dioxide" +SO2 = "sulphur_dioxide" +PM25 = "particulate_matter_2_5_mm" +PM10 = "particulate_matter_10_mm" +MEP_AQI = "china_mep_air_quality_index" +MEP_HEALTH_CONCERN = "china_mep_health_concern" +MEP_PRIMARY_POLLUTANT = "china_mep_primary_pollutant" +EPA_AQI = "us_epa_air_quality_index" +EPA_HEALTH_CONCERN = "us_epa_health_concern" +EPA_PRIMARY_POLLUTANT = "us_epa_primary_pollutant" +FIRE_INDEX = "fire_index" +GRASS_POLLEN = "grass_pollen_index" +WEED_POLLEN = "weed_pollen_index" +TREE_POLLEN = "tree_pollen_index" +FEELS_LIKE = "feels_like" +DEW_POINT = "dew_point" +PRESSURE_SURFACE_LEVEL = "pressure_surface_level" +SNOW_ACCUMULATION = "snow_accumulation" +ICE_ACCUMULATION = "ice_accumulation" +GHI = "global_horizontal_irradiance" +CLOUD_BASE = "cloud_base" +CLOUD_COVER = "cloud_cover" +CLOUD_CEILING = "cloud_ceiling" +WIND_GUST = "wind_gust" +PRECIPITATION_TYPE = "precipitation_type" + +V3_FIELDS = [ + O3, + CO, + NO2, + SO2, + PM25, + PM10, + MEP_AQI, + MEP_HEALTH_CONCERN, + MEP_PRIMARY_POLLUTANT, + EPA_AQI, + EPA_HEALTH_CONCERN, + EPA_PRIMARY_POLLUTANT, + FIRE_INDEX, + GRASS_POLLEN, + WEED_POLLEN, + TREE_POLLEN, +] + +V4_FIELDS = [ + *V3_FIELDS, + FEELS_LIKE, + DEW_POINT, + PRESSURE_SURFACE_LEVEL, + GHI, + CLOUD_BASE, + CLOUD_COVER, + CLOUD_CEILING, + WIND_GUST, + PRECIPITATION_TYPE, +] + + +@callback +def _enable_entity(hass: HomeAssistant, entity_name: str) -> None: + """Enable disabled entity.""" + ent_reg = async_get(hass) + entry = ent_reg.async_get(entity_name) + updated_entry = ent_reg.async_update_entity( + entry.entity_id, **{"disabled_by": None} + ) + assert updated_entry != entry + assert updated_entry.disabled is False + + +async def _setup( + hass: HomeAssistant, sensors: list[str], config: dict[str, Any] +) -> State: + """Set up entry and return entity state.""" + with patch( + "homeassistant.util.dt.utcnow", + return_value=datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC), + ): + data = _get_config_schema(hass, SOURCE_USER)(config) + data[CONF_NAME] = DEFAULT_NAME + config_entry = MockConfigEntry( + domain=DOMAIN, + data=data, + options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, + unique_id=_get_unique_id(hass, data), + version=1, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + for entity_name in sensors: + _enable_entity(hass, CC_SENSOR_ENTITY_ID.format(entity_name)) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == len(sensors) + + +def check_sensor_state(hass: HomeAssistant, entity_name: str, value: str): + """Check the state of a Tomorrow.io sensor.""" + state = hass.states.get(CC_SENSOR_ENTITY_ID.format(entity_name)) + assert state + assert state.state == value + assert state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + + +async def test_v4_sensor(hass: HomeAssistant) -> None: + """Test v4 sensor data.""" + await _setup(hass, V4_FIELDS, API_V4_ENTRY_DATA) + check_sensor_state(hass, O3, "94.46") + check_sensor_state(hass, CO, "0.63") + check_sensor_state(hass, NO2, "20.81") + check_sensor_state(hass, SO2, "4.47") + check_sensor_state(hass, PM25, "5.3") + check_sensor_state(hass, PM10, "20.13") + check_sensor_state(hass, MEP_AQI, "23") + check_sensor_state(hass, MEP_HEALTH_CONCERN, "good") + check_sensor_state(hass, MEP_PRIMARY_POLLUTANT, "pm10") + check_sensor_state(hass, EPA_AQI, "24") + check_sensor_state(hass, EPA_HEALTH_CONCERN, "good") + check_sensor_state(hass, EPA_PRIMARY_POLLUTANT, "pm25") + check_sensor_state(hass, FIRE_INDEX, "10") + check_sensor_state(hass, GRASS_POLLEN, "none") + check_sensor_state(hass, WEED_POLLEN, "none") + check_sensor_state(hass, TREE_POLLEN, "none") + check_sensor_state(hass, FEELS_LIKE, "38.5") + check_sensor_state(hass, DEW_POINT, "22.68") + check_sensor_state(hass, PRESSURE_SURFACE_LEVEL, "997.97") + check_sensor_state(hass, GHI, "0.0") + check_sensor_state(hass, CLOUD_BASE, "1.19") + check_sensor_state(hass, CLOUD_COVER, "100") + check_sensor_state(hass, CLOUD_CEILING, "1.19") + check_sensor_state(hass, WIND_GUST, "5.65") + check_sensor_state(hass, PRECIPITATION_TYPE, "rain") diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py new file mode 100644 index 0000000000000..f47e8ed22d80e --- /dev/null +++ b/tests/components/tomorrowio/test_weather.py @@ -0,0 +1,245 @@ +"""Tests for Tomorrow.io weather entity.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any +from unittest.mock import patch + +from homeassistant.components.tomorrowio.config_flow import ( + _get_config_schema, + _get_unique_id, +) +from homeassistant.components.tomorrowio.const import ( + ATTRIBUTION, + CONF_TIMESTEP, + DEFAULT_NAME, + DEFAULT_TIMESTEP, + DOMAIN, +) +from homeassistant.components.weather import ( + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SUNNY, + ATTR_FORECAST, + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_SPEED, + DOMAIN as WEATHER_DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, CONF_NAME +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers.entity_registry import async_get +from homeassistant.util import dt as dt_util + +from .const import API_V4_ENTRY_DATA + +from tests.common import MockConfigEntry + + +@callback +def _enable_entity(hass: HomeAssistant, entity_name: str) -> None: + """Enable disabled entity.""" + ent_reg = async_get(hass) + entry = ent_reg.async_get(entity_name) + updated_entry = ent_reg.async_update_entity( + entry.entity_id, **{"disabled_by": None} + ) + assert updated_entry != entry + assert updated_entry.disabled is False + + +async def _setup(hass: HomeAssistant, config: dict[str, Any]) -> State: + """Set up entry and return entity state.""" + with patch( + "homeassistant.util.dt.utcnow", + return_value=datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC), + ): + data = _get_config_schema(hass, SOURCE_USER)(config) + data[CONF_NAME] = DEFAULT_NAME + config_entry = MockConfigEntry( + domain=DOMAIN, + data=data, + options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, + unique_id=_get_unique_id(hass, data), + version=1, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + for entity_name in ("hourly", "nowcast"): + _enable_entity(hass, f"weather.tomorrow_io_{entity_name}") + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 3 + + return hass.states.get("weather.tomorrow_io_daily") + + +async def test_v4_weather(hass: HomeAssistant) -> None: + """Test v4 weather data.""" + weather_state = await _setup(hass, API_V4_ENTRY_DATA) + assert weather_state.state == ATTR_CONDITION_SUNNY + assert weather_state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + assert weather_state.attributes[ATTR_FORECAST] == [ + { + ATTR_FORECAST_CONDITION: ATTR_CONDITION_SUNNY, + ATTR_FORECAST_TIME: "2021-03-07T11:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, + ATTR_FORECAST_TEMP: 8, + ATTR_FORECAST_TEMP_LOW: -3, + ATTR_FORECAST_WIND_BEARING: 239.6, + ATTR_FORECAST_WIND_SPEED: 4.24, + }, + { + ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, + ATTR_FORECAST_TIME: "2021-03-08T11:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, + ATTR_FORECAST_TEMP: 10, + ATTR_FORECAST_TEMP_LOW: -3, + ATTR_FORECAST_WIND_BEARING: 262.82, + ATTR_FORECAST_WIND_SPEED: 3.24, + }, + { + ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, + ATTR_FORECAST_TIME: "2021-03-09T11:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, + ATTR_FORECAST_TEMP: 19, + ATTR_FORECAST_TEMP_LOW: 0, + ATTR_FORECAST_WIND_BEARING: 229.3, + ATTR_FORECAST_WIND_SPEED: 3.15, + }, + { + ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, + ATTR_FORECAST_TIME: "2021-03-10T11:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, + ATTR_FORECAST_TEMP: 18, + ATTR_FORECAST_TEMP_LOW: 3, + ATTR_FORECAST_WIND_BEARING: 149.91, + ATTR_FORECAST_WIND_SPEED: 4.76, + }, + { + ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, + ATTR_FORECAST_TIME: "2021-03-11T11:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, + ATTR_FORECAST_TEMP: 19, + ATTR_FORECAST_TEMP_LOW: 9, + ATTR_FORECAST_WIND_BEARING: 210.45, + ATTR_FORECAST_WIND_SPEED: 7.01, + }, + { + ATTR_FORECAST_CONDITION: ATTR_CONDITION_RAINY, + ATTR_FORECAST_TIME: "2021-03-12T11:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0.12, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 25, + ATTR_FORECAST_TEMP: 20, + ATTR_FORECAST_TEMP_LOW: 12, + ATTR_FORECAST_WIND_BEARING: 217.98, + ATTR_FORECAST_WIND_SPEED: 5.5, + }, + { + ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, + ATTR_FORECAST_TIME: "2021-03-13T11:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 25, + ATTR_FORECAST_TEMP: 12, + ATTR_FORECAST_TEMP_LOW: 6, + ATTR_FORECAST_WIND_BEARING: 58.79, + ATTR_FORECAST_WIND_SPEED: 4.35, + }, + { + ATTR_FORECAST_CONDITION: ATTR_CONDITION_SNOWY, + ATTR_FORECAST_TIME: "2021-03-14T10:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 23.96, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 95, + ATTR_FORECAST_TEMP: 6, + ATTR_FORECAST_TEMP_LOW: 1, + ATTR_FORECAST_WIND_BEARING: 70.25, + ATTR_FORECAST_WIND_SPEED: 7.26, + }, + { + ATTR_FORECAST_CONDITION: ATTR_CONDITION_SNOWY, + ATTR_FORECAST_TIME: "2021-03-15T10:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 1.46, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 55, + ATTR_FORECAST_TEMP: 6, + ATTR_FORECAST_TEMP_LOW: -1, + ATTR_FORECAST_WIND_BEARING: 84.47, + ATTR_FORECAST_WIND_SPEED: 7.1, + }, + { + ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, + ATTR_FORECAST_TIME: "2021-03-16T10:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, + ATTR_FORECAST_TEMP: 6, + ATTR_FORECAST_TEMP_LOW: -2, + ATTR_FORECAST_WIND_BEARING: 103.85, + ATTR_FORECAST_WIND_SPEED: 3.0, + }, + { + ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, + ATTR_FORECAST_TIME: "2021-03-17T10:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, + ATTR_FORECAST_TEMP: 11, + ATTR_FORECAST_TEMP_LOW: 1, + ATTR_FORECAST_WIND_BEARING: 145.41, + ATTR_FORECAST_WIND_SPEED: 3.25, + }, + { + ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, + ATTR_FORECAST_TIME: "2021-03-18T10:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 10, + ATTR_FORECAST_TEMP: 12, + ATTR_FORECAST_TEMP_LOW: 5, + ATTR_FORECAST_WIND_BEARING: 62.99, + ATTR_FORECAST_WIND_SPEED: 2.94, + }, + { + ATTR_FORECAST_CONDITION: ATTR_CONDITION_RAINY, + ATTR_FORECAST_TIME: "2021-03-19T10:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 2.93, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 55, + ATTR_FORECAST_TEMP: 9, + ATTR_FORECAST_TEMP_LOW: 4, + ATTR_FORECAST_WIND_BEARING: 68.54, + ATTR_FORECAST_WIND_SPEED: 6.22, + }, + { + ATTR_FORECAST_CONDITION: ATTR_CONDITION_SNOWY, + ATTR_FORECAST_TIME: "2021-03-20T10:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 1.22, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 33.3, + ATTR_FORECAST_TEMP: 5, + ATTR_FORECAST_TEMP_LOW: 2, + ATTR_FORECAST_WIND_BEARING: 56.98, + ATTR_FORECAST_WIND_SPEED: 7.76, + }, + ] + assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io - Daily" + assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 23 + assert weather_state.attributes[ATTR_WEATHER_OZONE] == 46.53 + assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 102776.91 + assert weather_state.attributes[ATTR_WEATHER_TEMPERATURE] == 7 + assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 13.12 + assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 315.14 + assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 4.17 diff --git a/tests/components/trafikverket_weatherstation/test_config_flow.py b/tests/components/trafikverket_weatherstation/test_config_flow.py index b8a18181a111c..458652f817514 100644 --- a/tests/components/trafikverket_weatherstation/test_config_flow.py +++ b/tests/components/trafikverket_weatherstation/test_config_flow.py @@ -6,12 +6,10 @@ import pytest from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import RESULT_TYPE_FORM -from tests.common import MockConfigEntry - DOMAIN = "trafikverket_weatherstation" CONF_STATION = "station" @@ -49,67 +47,6 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_import_flow_success(hass: HomeAssistant) -> None: - """Test a successful import of yaml.""" - - with patch( - "homeassistant.components.trafikverket_weatherstation.config_flow.TrafikverketWeather.async_get_weather", - ), patch( - "homeassistant.components.trafikverket_weatherstation.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_NAME: "Vallby", - CONF_API_KEY: "1234567890", - CONF_STATION: "Vallby", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == "create_entry" - assert result2["title"] == "Vallby" - assert result2["data"] == { - "api_key": "1234567890", - "station": "Vallby", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_flow_already_exist(hass: HomeAssistant) -> None: - """Test import of yaml already exist.""" - - MockConfigEntry( - domain=DOMAIN, - data={ - CONF_API_KEY: "1234567890", - CONF_STATION: "Vallby", - }, - ).add_to_hass(hass) - - with patch( - "homeassistant.components.trafikverket_weatherstation.async_setup_entry", - return_value=True, - ), patch( - "homeassistant.components.trafikverket_weatherstation.config_flow.TrafikverketWeather.async_get_weather", - ): - result3 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_NAME: "Vallby", - CONF_API_KEY: "1234567890", - CONF_STATION: "Vallby", - }, - ) - await hass.async_block_till_done() - - assert result3["type"] == "abort" - assert result3["reason"] == "already_configured" - - @pytest.mark.parametrize( "error_message,base_error", [ diff --git a/tests/components/twentemilieu/test_calendar.py b/tests/components/twentemilieu/test_calendar.py new file mode 100644 index 0000000000000..27e3ff8ebf33d --- /dev/null +++ b/tests/components/twentemilieu/test_calendar.py @@ -0,0 +1,83 @@ +"""Tests for the Twente Milieu calendar.""" +from http import HTTPStatus + +import pytest + +from homeassistant.components.twentemilieu.const import DOMAIN +from homeassistant.const import ATTR_ICON, STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.mark.freeze_time("2022-01-05 00:00:00+00:00") +async def test_waste_pickup_calendar( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the Twente Milieu waste pickup calendar.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("calendar.twente_milieu") + entry = entity_registry.async_get("calendar.twente_milieu") + assert entry + assert state + assert entry.unique_id == "12345" + assert state.attributes[ATTR_ICON] == "mdi:delete-empty" + assert state.attributes["all_day"] is True + assert state.attributes["message"] == "Christmas Tree Pickup" + assert not state.attributes["location"] + assert not state.attributes["description"] + assert state.state == STATE_OFF + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, "12345")} + assert device_entry.manufacturer == "Twente Milieu" + assert device_entry.name == "Twente Milieu" + assert device_entry.entry_type is dr.DeviceEntryType.SERVICE + assert device_entry.configuration_url == "https://www.twentemilieu.nl" + assert not device_entry.model + assert not device_entry.sw_version + + +async def test_api_calendar( + hass: HomeAssistant, + init_integration: MockConfigEntry, + hass_client, +) -> None: + """Test the API returns the calendar.""" + client = await hass_client() + response = await client.get("/api/calendars") + assert response.status == HTTPStatus.OK + data = await response.json() + assert data == [ + { + "entity_id": "calendar.twente_milieu", + "name": "Twente Milieu", + } + ] + + +async def test_api_events( + hass: HomeAssistant, + init_integration: MockConfigEntry, + hass_client, +) -> None: + """Test the Twente Milieu calendar view.""" + client = await hass_client() + response = await client.get( + "/api/calendars/calendar.twente_milieu?start=2022-01-05&end=2022-01-06" + ) + assert response.status == HTTPStatus.OK + events = await response.json() + assert len(events) == 1 + assert events[0] == { + "all_day": True, + "start": {"date": "2022-01-06"}, + "end": {"date": "2022-01-06"}, + "summary": "Christmas Tree Pickup", + } diff --git a/tests/components/update/__init__.py b/tests/components/update/__init__.py new file mode 100644 index 0000000000000..c711b2779b9ea --- /dev/null +++ b/tests/components/update/__init__.py @@ -0,0 +1 @@ +"""The tests for the Update integration.""" diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py new file mode 100644 index 0000000000000..35366217a3d55 --- /dev/null +++ b/tests/components/update/test_init.py @@ -0,0 +1,589 @@ +"""The tests for the Update component.""" +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.update import ( + ATTR_BACKUP, + ATTR_VERSION, + DOMAIN, + SERVICE_INSTALL, + SERVICE_SKIP, + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, +) +from homeassistant.components.update.const import ( + ATTR_CURRENT_VERSION, + ATTR_IN_PROGRESS, + ATTR_LATEST_VERSION, + ATTR_RELEASE_SUMMARY, + ATTR_RELEASE_URL, + ATTR_SKIPPED_VERSION, + ATTR_TITLE, + UpdateEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_PLATFORM, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.setup import async_setup_component + +from tests.common import mock_restore_cache + + +class MockUpdateEntity(UpdateEntity): + """Mock UpdateEntity to use in tests.""" + + +async def test_update(hass: HomeAssistant) -> None: + """Test getting data from the mocked update entity.""" + update = MockUpdateEntity() + update.hass = hass + + update._attr_current_version = "1.0.0" + update._attr_latest_version = "1.0.1" + update._attr_release_summary = "Summary" + update._attr_release_url = "https://example.com" + update._attr_title = "Title" + + assert update.entity_category is EntityCategory.DIAGNOSTIC + assert update.current_version == "1.0.0" + assert update.latest_version == "1.0.1" + assert update.release_summary == "Summary" + assert update.release_url == "https://example.com" + assert update.title == "Title" + assert update.in_progress is False + assert update.state == STATE_ON + assert update.state_attributes == { + ATTR_CURRENT_VERSION: "1.0.0", + ATTR_IN_PROGRESS: False, + ATTR_LATEST_VERSION: "1.0.1", + ATTR_RELEASE_SUMMARY: "Summary", + ATTR_RELEASE_URL: "https://example.com", + ATTR_SKIPPED_VERSION: None, + ATTR_TITLE: "Title", + } + + # Test no update available + update._attr_current_version = "1.0.0" + update._attr_latest_version = "1.0.0" + assert update.state is STATE_OFF + + # Test state becomes unknown if current version is unknown + update._attr_current_version = None + update._attr_latest_version = "1.0.0" + assert update.state is None + + # Test state becomes unknown if latest version is unknown + update._attr_current_version = "1.0.0" + update._attr_latest_version = None + assert update.state is None + + # Test entity category becomes config when its possible to install + update._attr_supported_features = UpdateEntityFeature.INSTALL + assert update.entity_category is EntityCategory.CONFIG + + # UpdateEntityDescription was set + update._attr_supported_features = 0 + update.entity_description = UpdateEntityDescription(key="F5 - Its very refreshing") + assert update.device_class is None + assert update.entity_category is EntityCategory.CONFIG + update.entity_description = UpdateEntityDescription( + key="F5 - Its very refreshing", + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=None, + ) + assert update.device_class is UpdateDeviceClass.FIRMWARE + assert update.entity_category is None + + # Device class via attribute (override entity description) + update._attr_device_class = None + assert update.device_class is None + update._attr_device_class = UpdateDeviceClass.FIRMWARE + assert update.device_class is UpdateDeviceClass.FIRMWARE + + # Entity Attribute via attribute (override entity description) + update._attr_entity_category = None + assert update.entity_category is None + update._attr_entity_category = EntityCategory.DIAGNOSTIC + assert update.entity_category is EntityCategory.DIAGNOSTIC + + with pytest.raises(NotImplementedError): + await update.async_install() + + with pytest.raises(NotImplementedError): + update.install() + + update.install = MagicMock() + await update.async_install(version="1.0.1", backup=True) + + assert update.install.called + assert update.install.call_args[0][0] == "1.0.1" + assert update.install.call_args[0][1] is True + + +async def test_entity_with_no_install( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """Test entity with no updates.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + # Update is available + state = hass.states.get("update.update_no_install") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + + # Should not be able to install as the entity doesn't support that + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.update_no_install"}, + blocking=True, + ) + + # Nothing changed + state = hass.states.get("update.update_no_install") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert state.attributes[ATTR_SKIPPED_VERSION] is None + + # We can mark the update as skipped + await hass.services.async_call( + DOMAIN, + SERVICE_SKIP, + {ATTR_ENTITY_ID: "update.update_no_install"}, + blocking=True, + ) + + state = hass.states.get("update.update_no_install") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert state.attributes[ATTR_SKIPPED_VERSION] == "1.0.1" + + +async def test_entity_with_no_updates( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """Test entity with no updates.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + # No update available + state = hass.states.get("update.no_update") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.0" + + # Should not be able to skip when there is no update available + with pytest.raises(HomeAssistantError, match="No update available to skip for"): + await hass.services.async_call( + DOMAIN, + SERVICE_SKIP, + {ATTR_ENTITY_ID: "update.no_update"}, + blocking=True, + ) + + # Should not be able to install an update when there is no update available + with pytest.raises(HomeAssistantError, match="No update available for"): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.no_update"}, + blocking=True, + ) + + # Updating to a specific version is not supported by this entity + with pytest.raises( + HomeAssistantError, + match="Installing a specific version is not supported for", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_VERSION: "0.9.0", ATTR_ENTITY_ID: "update.no_update"}, + blocking=True, + ) + + +async def test_entity_with_updates_available( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test basic update entity with updates available.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + # Entity has an update available + state = hass.states.get("update.update_available") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert state.attributes[ATTR_SKIPPED_VERSION] is None + + # Skip skip the update + await hass.services.async_call( + DOMAIN, + SERVICE_SKIP, + {ATTR_ENTITY_ID: "update.update_available"}, + blocking=True, + ) + + # The state should have changed to off, skipped version should be set + state = hass.states.get("update.update_available") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert state.attributes[ATTR_SKIPPED_VERSION] == "1.0.1" + + # Even though skipped, we can still update if we want to + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.update_available"}, + blocking=True, + ) + + # The state should have changed to off, skipped version should be set + state = hass.states.get("update.update_available") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.1" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert state.attributes[ATTR_SKIPPED_VERSION] is None + assert "Installed latest update" in caplog.text + + +async def test_entity_with_unknown_version( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update entity that has an unknown version.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("update.update_unknown") + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] is None + assert state.attributes[ATTR_SKIPPED_VERSION] is None + + # Should not be able to install an update when there is no update available + with pytest.raises(HomeAssistantError, match="No update available for"): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.update_unknown"}, + blocking=True, + ) + + # Should not be to skip the update + with pytest.raises(HomeAssistantError, match="Cannot skip an unknown version for"): + await hass.services.async_call( + DOMAIN, + SERVICE_SKIP, + {ATTR_ENTITY_ID: "update.update_unknown"}, + blocking=True, + ) + + +async def test_entity_with_specific_version( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update entity that support specific version.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("update.update_specific_version") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.0" + + # Update to a specific version + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_VERSION: "0.9.9", ATTR_ENTITY_ID: "update.update_specific_version"}, + blocking=True, + ) + + # Version has changed, state should be on as there is an update available + state = hass.states.get("update.update_specific_version") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_CURRENT_VERSION] == "0.9.9" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.0" + assert "Installed update with version: 0.9.9" in caplog.text + + # Update back to the latest version + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.update_specific_version"}, + blocking=True, + ) + + state = hass.states.get("update.update_specific_version") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.0" + assert "Installed latest update" in caplog.text + + # This entity does not support doing a backup before upgrade + with pytest.raises(HomeAssistantError, match="Backup is not supported for"): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + { + ATTR_VERSION: "0.9.9", + ATTR_BACKUP: True, + ATTR_ENTITY_ID: "update.update_specific_version", + }, + blocking=True, + ) + + +async def test_entity_with_backup_support( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update entity with backup support.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + # This entity support backing up before install the update + state = hass.states.get("update.update_backup") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + + # Without a backup + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + { + ATTR_BACKUP: False, + ATTR_ENTITY_ID: "update.update_backup", + }, + blocking=True, + ) + + state = hass.states.get("update.update_backup") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.1" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert "Creating backup before installing update" not in caplog.text + assert "Installed latest update" in caplog.text + + # Specific version, do create a backup this time + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + { + ATTR_BACKUP: True, + ATTR_VERSION: "0.9.8", + ATTR_ENTITY_ID: "update.update_backup", + }, + blocking=True, + ) + + # This entity support backing up before install the update + state = hass.states.get("update.update_backup") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_CURRENT_VERSION] == "0.9.8" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert "Creating backup before installing update" in caplog.text + assert "Installed update with version: 0.9.8" in caplog.text + + +async def test_entity_already_in_progress( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update install already in progress.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("update.update_already_in_progress") + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert state.attributes[ATTR_IN_PROGRESS] == 50 + + with pytest.raises( + HomeAssistantError, + match="Update installation already in progress for", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.update_already_in_progress"}, + blocking=True, + ) + + +async def test_entity_without_progress_support( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update entity without progress support. + + In that case, progress is still handled by Home Assistant. + """ + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + events = [] + async_track_state_change_event( + hass, "update.update_available", callback(lambda event: events.append(event)) + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.update_available"}, + blocking=True, + ) + + assert len(events) == 2 + assert events[0].data.get("old_state").attributes[ATTR_IN_PROGRESS] is False + assert events[0].data.get("old_state").attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert events[0].data.get("new_state").attributes[ATTR_IN_PROGRESS] is True + assert events[0].data.get("new_state").attributes[ATTR_CURRENT_VERSION] == "1.0.0" + + assert events[1].data.get("old_state").attributes[ATTR_IN_PROGRESS] is True + assert events[1].data.get("old_state").attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert events[1].data.get("new_state").attributes[ATTR_IN_PROGRESS] is False + assert events[1].data.get("new_state").attributes[ATTR_CURRENT_VERSION] == "1.0.1" + + +async def test_entity_without_progress_support_raising( + hass: HomeAssistant, + enable_custom_integrations: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update entity without progress support that raises during install. + + In that case, progress is still handled by Home Assistant. + """ + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + events = [] + async_track_state_change_event( + hass, "update.update_available", callback(lambda event: events.append(event)) + ) + + with patch( + "homeassistant.components.update.UpdateEntity.async_install", + side_effect=RuntimeError, + ), pytest.raises(RuntimeError): + await hass.services.async_call( + DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.update_available"}, + blocking=True, + ) + + assert len(events) == 2 + assert events[0].data.get("old_state").attributes[ATTR_IN_PROGRESS] is False + assert events[0].data.get("old_state").attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert events[0].data.get("new_state").attributes[ATTR_IN_PROGRESS] is True + assert events[0].data.get("new_state").attributes[ATTR_CURRENT_VERSION] == "1.0.0" + + assert events[1].data.get("old_state").attributes[ATTR_IN_PROGRESS] is True + assert events[1].data.get("old_state").attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert events[1].data.get("new_state").attributes[ATTR_IN_PROGRESS] is False + assert events[1].data.get("new_state").attributes[ATTR_CURRENT_VERSION] == "1.0.0" + + +async def test_restore_state( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: + """Test we restore skipped version state.""" + mock_restore_cache( + hass, + ( + State( + "update.update_available", + STATE_ON, # Incorrect, but helps checking if it is ignored + { + ATTR_SKIPPED_VERSION: "1.0.1", + }, + ), + ), + ) + + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("update.update_available") + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_CURRENT_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.1" + assert state.attributes[ATTR_SKIPPED_VERSION] == "1.0.1" diff --git a/tests/components/update/test_significant_change.py b/tests/components/update/test_significant_change.py new file mode 100644 index 0000000000000..699d3e60f571e --- /dev/null +++ b/tests/components/update/test_significant_change.py @@ -0,0 +1,90 @@ +"""Test the update significant change platform.""" +from homeassistant.components.update.const import ( + ATTR_CURRENT_VERSION, + ATTR_IN_PROGRESS, + ATTR_LATEST_VERSION, + ATTR_RELEASE_SUMMARY, + ATTR_RELEASE_URL, + ATTR_SKIPPED_VERSION, + ATTR_TITLE, +) +from homeassistant.components.update.significant_change import ( + async_check_significant_change, +) +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + + +async def test_significant_change(hass: HomeAssistant) -> None: + """Detect update significant changes.""" + assert async_check_significant_change(hass, STATE_ON, {}, STATE_OFF, {}) + assert async_check_significant_change(hass, STATE_OFF, {}, STATE_ON, {}) + assert not async_check_significant_change(hass, STATE_OFF, {}, STATE_OFF, {}) + assert not async_check_significant_change(hass, STATE_ON, {}, STATE_ON, {}) + + attrs = { + ATTR_CURRENT_VERSION: "1.0.0", + ATTR_IN_PROGRESS: False, + ATTR_LATEST_VERSION: "1.0.1", + ATTR_RELEASE_SUMMARY: "Fixes!", + ATTR_RELEASE_URL: "https://www.example.com", + ATTR_SKIPPED_VERSION: None, + ATTR_TITLE: "Piece of Software", + } + assert not async_check_significant_change(hass, STATE_ON, attrs, STATE_ON, attrs) + + assert async_check_significant_change( + hass, + STATE_ON, + attrs, + STATE_ON, + attrs.copy() | {ATTR_CURRENT_VERSION: "1.0.1"}, + ) + + assert async_check_significant_change( + hass, + STATE_ON, + attrs, + STATE_ON, + attrs.copy() | {ATTR_LATEST_VERSION: "1.0.2"}, + ) + + assert not async_check_significant_change( + hass, + STATE_ON, + attrs, + STATE_ON, + attrs.copy() | {ATTR_IN_PROGRESS: True}, + ) + + assert not async_check_significant_change( + hass, + STATE_ON, + attrs, + STATE_ON, + attrs.copy() | {ATTR_RELEASE_SUMMARY: "More fixes!"}, + ) + + assert not async_check_significant_change( + hass, + STATE_ON, + attrs, + STATE_ON, + attrs.copy() | {ATTR_RELEASE_URL: "https://www.example.com/changed_url"}, + ) + + assert not async_check_significant_change( + hass, + STATE_ON, + attrs, + STATE_ON, + attrs.copy() | {ATTR_SKIPPED_VERSION: "1.0.0"}, + ) + + assert not async_check_significant_change( + hass, + STATE_ON, + attrs, + STATE_ON, + attrs.copy() | {ATTR_TITLE: "Renamed the software..."}, + ) diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index 3297c696ca109..8b600865d44ee 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -2,12 +2,16 @@ from datetime import timedelta from unittest.mock import patch +from homeassistant.components.select.const import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) from homeassistant.components.utility_meter.const import ( - ATTR_TARIFF, DOMAIN, SERVICE_RESET, SERVICE_SELECT_NEXT_TARIFF, SERVICE_SELECT_TARIFF, + SIGNAL_RESET_METER, ) import homeassistant.components.utility_meter.sensor as um_sensor from homeassistant.const import ( @@ -19,6 +23,7 @@ Platform, ) from homeassistant.core import State +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -39,7 +44,7 @@ async def test_restore_state(hass): hass, [ State( - "utility_meter.energy_bill", + "select.energy_bill", "midpeak", ), ], @@ -50,7 +55,7 @@ async def test_restore_state(hass): await hass.async_block_till_done() # restore from cache - state = hass.states.get("utility_meter.energy_bill") + state = hass.states.get("select.energy_bill") assert state.state == "midpeak" @@ -98,7 +103,7 @@ async def test_services(hass): state = hass.states.get("sensor.energy_bill_offpeak") assert state.state == "0" - # Next tariff + # Next tariff - only supported on legacy entity data = {ATTR_ENTITY_ID: "utility_meter.energy_bill"} await hass.services.async_call(DOMAIN, SERVICE_SELECT_NEXT_TARIFF, data) await hass.async_block_till_done() @@ -120,15 +125,15 @@ async def test_services(hass): assert state.state == "1" # Change tariff - data = {ATTR_ENTITY_ID: "utility_meter.energy_bill", ATTR_TARIFF: "wrong_tariff"} - await hass.services.async_call(DOMAIN, SERVICE_SELECT_TARIFF, data) + data = {ATTR_ENTITY_ID: "select.energy_bill", "option": "wrong_tariff"} + await hass.services.async_call(SELECT_DOMAIN, SERVICE_SELECT_OPTION, data) await hass.async_block_till_done() # Inexisting tariff, ignoring - assert hass.states.get("utility_meter.energy_bill").state != "wrong_tariff" + assert hass.states.get("select.energy_bill").state != "wrong_tariff" - data = {ATTR_ENTITY_ID: "utility_meter.energy_bill", ATTR_TARIFF: "peak"} - await hass.services.async_call(DOMAIN, SERVICE_SELECT_TARIFF, data) + data = {ATTR_ENTITY_ID: "select.energy_bill", "option": "peak"} + await hass.services.async_call(SELECT_DOMAIN, SERVICE_SELECT_OPTION, data) await hass.async_block_till_done() now += timedelta(seconds=10) @@ -148,7 +153,7 @@ async def test_services(hass): assert state.state == "1" # Reset meters - data = {ATTR_ENTITY_ID: "utility_meter.energy_bill"} + data = {ATTR_ENTITY_ID: "select.energy_bill"} await hass.services.async_call(DOMAIN, SERVICE_RESET, data) await hass.async_block_till_done() @@ -240,3 +245,85 @@ async def test_bad_cron(hass, legacy_patchable_time): async def test_setup_missing_discovery(hass): """Test setup with configuration missing discovery_info.""" assert not await um_sensor.async_setup_platform(hass, {CONF_PLATFORM: DOMAIN}, None) + + +async def test_legacy_support(hass): + """Test legacy entity support.""" + config = { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + "cycle": "hourly", + "tariffs": ["peak", "offpeak"], + }, + } + } + + assert await async_setup_component(hass, DOMAIN, config) + assert await async_setup_component(hass, Platform.SENSOR, config) + await hass.async_block_till_done() + + select_state = hass.states.get("select.energy_bill") + legacy_state = hass.states.get("utility_meter.energy_bill") + + assert select_state.state == legacy_state.state == "peak" + select_attributes = select_state.attributes + legacy_attributes = legacy_state.attributes + assert select_attributes.keys() == { + "friendly_name", + "icon", + "options", + } + assert legacy_attributes.keys() == {"friendly_name", "icon", "tariffs"} + assert select_attributes["friendly_name"] == legacy_attributes["friendly_name"] + assert select_attributes["icon"] == legacy_attributes["icon"] + assert select_attributes["options"] == legacy_attributes["tariffs"] + + # Change tariff on the select + data = {ATTR_ENTITY_ID: "select.energy_bill", "option": "offpeak"} + await hass.services.async_call(SELECT_DOMAIN, SERVICE_SELECT_OPTION, data) + await hass.async_block_till_done() + + select_state = hass.states.get("select.energy_bill") + legacy_state = hass.states.get("utility_meter.energy_bill") + assert select_state.state == legacy_state.state == "offpeak" + + # Change tariff on the legacy entity + data = {ATTR_ENTITY_ID: "utility_meter.energy_bill", "tariff": "offpeak"} + await hass.services.async_call(DOMAIN, SERVICE_SELECT_TARIFF, data) + await hass.async_block_till_done() + + select_state = hass.states.get("select.energy_bill") + legacy_state = hass.states.get("utility_meter.energy_bill") + assert select_state.state == legacy_state.state == "offpeak" + + # Cycle tariffs on the select - not supported + data = {ATTR_ENTITY_ID: "select.energy_bill"} + await hass.services.async_call(DOMAIN, SERVICE_SELECT_NEXT_TARIFF, data) + await hass.async_block_till_done() + + select_state = hass.states.get("select.energy_bill") + legacy_state = hass.states.get("utility_meter.energy_bill") + assert select_state.state == legacy_state.state == "offpeak" + + # Cycle tariffs on the legacy entity + data = {ATTR_ENTITY_ID: "utility_meter.energy_bill"} + await hass.services.async_call(DOMAIN, SERVICE_SELECT_NEXT_TARIFF, data) + await hass.async_block_till_done() + + select_state = hass.states.get("select.energy_bill") + legacy_state = hass.states.get("utility_meter.energy_bill") + assert select_state.state == legacy_state.state == "peak" + + # Reset the legacy entity + reset_calls = [] + + def async_reset_meter(entity_id): + reset_calls.append(entity_id) + + async_dispatcher_connect(hass, SIGNAL_RESET_METER, async_reset_meter) + + data = {ATTR_ENTITY_ID: "utility_meter.energy_bill"} + await hass.services.async_call(DOMAIN, SERVICE_RESET, data) + await hass.async_block_till_done() + assert reset_calls == ["select.energy_bill"] diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index fbaf795f9e2fd..df8e1c5e6a1f6 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -3,20 +3,22 @@ from datetime import timedelta from unittest.mock import patch +from homeassistant.components.select.const import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, SensorStateClass, ) from homeassistant.components.utility_meter.const import ( - ATTR_TARIFF, ATTR_VALUE, DAILY, DOMAIN, HOURLY, QUARTER_HOURLY, SERVICE_CALIBRATE_METER, - SERVICE_SELECT_TARIFF, ) from homeassistant.components.utility_meter.sensor import ( ATTR_LAST_RESET, @@ -117,9 +119,9 @@ async def test_state(hass): assert state.attributes.get("status") == PAUSED await hass.services.async_call( - DOMAIN, - SERVICE_SELECT_TARIFF, - {ATTR_ENTITY_ID: "utility_meter.energy_bill", ATTR_TARIFF: "offpeak"}, + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.energy_bill", "option": "offpeak"}, blocking=True, ) @@ -343,7 +345,7 @@ async def test_restore_state(hass): hass.bus.async_fire(EVENT_HOMEASSISTANT_START) await hass.async_block_till_done() - state = hass.states.get("utility_meter.energy_bill") + state = hass.states.get("select.energy_bill") assert state.state == "onpeak" state = hass.states.get("sensor.energy_bill_onpeak") diff --git a/tests/components/vallox/test_sensor.py b/tests/components/vallox/test_sensor.py index bd8ecbea905e5..7649799f56b4b 100644 --- a/tests/components/vallox/test_sensor.py +++ b/tests/components/vallox/test_sensor.py @@ -12,44 +12,29 @@ from tests.common import MockConfigEntry -ORIG_TZ = dt.DEFAULT_TIME_ZONE - - -@pytest.fixture(autouse=True) -def reset_tz(): - """Restore the default TZ after test runs.""" - yield - dt.DEFAULT_TIME_ZONE = ORIG_TZ - @pytest.fixture def set_tz(request): """Set the default TZ to the one requested.""" - return request.getfixturevalue(request.param) + request.getfixturevalue(request.param) @pytest.fixture -def utc() -> tzinfo: +def utc(hass: HomeAssistant) -> None: """Set the default TZ to UTC.""" - tz = dt.get_time_zone("UTC") - dt.set_default_time_zone(tz) - return tz + hass.config.set_time_zone("UTC") @pytest.fixture -def helsinki() -> tzinfo: +def helsinki(hass: HomeAssistant) -> None: """Set the default TZ to Europe/Helsinki.""" - tz = dt.get_time_zone("Europe/Helsinki") - dt.set_default_time_zone(tz) - return tz + hass.config.set_time_zone("Europe/Helsinki") @pytest.fixture -def new_york() -> tzinfo: +def new_york(hass: HomeAssistant) -> None: """Set the default TZ to America/New_York.""" - tz = dt.get_time_zone("America/New_York") - dt.set_default_time_zone(tz) - return tz + hass.config.set_time_zone("America/New_York") def _sensor_to_datetime(sensor): diff --git a/tests/components/webostv/test_trigger.py b/tests/components/webostv/test_trigger.py index cbc72638ad98b..e11f2db3bad54 100644 --- a/tests/components/webostv/test_trigger.py +++ b/tests/components/webostv/test_trigger.py @@ -1,9 +1,12 @@ """The tests for WebOS TV automation triggers.""" from unittest.mock import patch +import pytest + from homeassistant.components import automation from homeassistant.components.webostv import DOMAIN from homeassistant.const import SERVICE_RELOAD +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import async_get as get_dev_reg from homeassistant.setup import async_setup_component @@ -57,17 +60,18 @@ async def test_webostv_turn_on_trigger_device_id(hass, calls, client): with patch("homeassistant.config.load_yaml", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) - await hass.services.async_call( - "media_player", - "turn_on", - {"entity_id": ENTITY_ID}, - blocking=True, - ) + calls.clear() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) await hass.async_block_till_done() - assert len(calls) == 1 - assert calls[0].data["some"] == device.id - assert calls[0].data["id"] == 0 + assert len(calls) == 0 async def test_webostv_turn_on_trigger_entity_id(hass, calls, client): diff --git a/tests/components/zodiac/test_sensor.py b/tests/components/zodiac/test_sensor.py index 6c784d3998f27..90b19e73b04fd 100644 --- a/tests/components/zodiac/test_sensor.py +++ b/tests/components/zodiac/test_sensor.py @@ -35,6 +35,7 @@ ) async def test_zodiac_day(hass, now, sign, element, modality): """Test the zodiac sensor.""" + hass.config.set_time_zone("UTC") config = {DOMAIN: {}} with patch("homeassistant.components.zodiac.sensor.utcnow", return_value=now): diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index 2535377e9d3e8..140d9fb3d831e 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -1,10 +1,12 @@ """Test the Z-Wave JS fan platform.""" +import copy import math import pytest from voluptuous.error import MultipleInvalid from zwave_js_server.const import CommandClass from zwave_js_server.event import Event +from zwave_js_server.model.node import Node from homeassistant.components.fan import ( ATTR_PERCENTAGE, @@ -14,6 +16,7 @@ DOMAIN as FAN_DOMAIN, SERVICE_SET_PRESET_MODE, SUPPORT_PRESET_MODE, + NotValidPresetModeError, ) from homeassistant.components.zwave_js.fan import ATTR_FAN_STATE from homeassistant.const import ( @@ -23,6 +26,7 @@ SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.exceptions import HomeAssistantError @@ -63,7 +67,6 @@ async def test_generic_fan(hass, client, fan_generic, integration): "type": "number", "readable": True, "writeable": True, - "label": "Target value", }, } assert args["value"] == 66 @@ -106,7 +109,6 @@ async def test_generic_fan(hass, client, fan_generic, integration): "type": "number", "readable": True, "writeable": True, - "label": "Target value", }, } assert args["value"] == 255 @@ -138,7 +140,6 @@ async def test_generic_fan(hass, client, fan_generic, integration): "type": "number", "readable": True, "writeable": True, - "label": "Target value", }, } assert args["value"] == 0 @@ -259,6 +260,65 @@ async def get_percentage_from_zwave_speed(zwave_speed): state = hass.states.get(entity_id) assert math.isclose(state.attributes[ATTR_PERCENTAGE_STEP], 33.3333, rel_tol=1e-3) + assert state.attributes[ATTR_PRESET_MODES] == [] + + +async def test_configurable_speeds_fan_with_missing_config_value( + hass, client, hs_fc200_state, integration +): + """Test a fan entity with configurable speeds.""" + entity_id = "fan.scene_capable_fan_control_switch" + + # Attach a modified version of the node with a bad config + bad_node_data = copy.deepcopy(hs_fc200_state) + fan_type_value = next( + ( + v + for v in bad_node_data["values"] + if v["endpoint"] == 0 and v["commandClass"] == 112 and v["property"] == 5 + ), + None, + ) + assert fan_type_value is not None + bad_node_data["values"].remove(fan_type_value) + + node = Node(client, bad_node_data) + event = {"node": node} + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + +async def test_configurable_speeds_fan_with_bad_config_value( + hass, client, hs_fc200_state, integration +): + """Test a fan entity with configurable speeds.""" + entity_id = "fan.scene_capable_fan_control_switch" + + # Attach a modified version of the node with a bad config + bad_node_data = copy.deepcopy(hs_fc200_state) + fan_type_value = next( + ( + v + for v in bad_node_data["values"] + if v["endpoint"] == 0 and v["commandClass"] == 112 and v["property"] == 5 + ), + None, + ) + assert fan_type_value is not None + + # 42 is not a valid configuration option with this device + fan_type_value["value"] = 42 + + node = Node(client, bad_node_data) + event = {"node": node} + client.driver.controller.emit("node added", event) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE async def test_fixed_speeds_fan(hass, client, ge_12730, integration): @@ -325,6 +385,110 @@ async def get_percentage_from_zwave_speed(zwave_speed): state = hass.states.get(entity_id) assert math.isclose(state.attributes[ATTR_PERCENTAGE_STEP], 33.3333, rel_tol=1e-3) + assert state.attributes[ATTR_PRESET_MODES] == [] + + +async def test_inovelli_lzw36(hass, client, inovelli_lzw36, integration): + """Test an LZW36.""" + node = inovelli_lzw36 + node_id = 19 + entity_id = "fan.family_room_combo_2" + + async def get_zwave_speed_from_percentage(percentage): + """Set the fan to a particular percentage and get the resulting Zwave speed.""" + client.async_send_command.reset_mock() + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": entity_id, "percentage": percentage}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node_id + return args["value"] + + async def set_zwave_speed(zwave_speed): + """Set the underlying device speed.""" + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node_id, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 2, + "property": "currentValue", + "newValue": zwave_speed, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + async def get_percentage_from_zwave_speed(zwave_speed): + """Set the underlying device speed and get the resulting percentage.""" + await set_zwave_speed(zwave_speed) + state = hass.states.get(entity_id) + return state.attributes[ATTR_PERCENTAGE] + + # This device has the speeds: + # low = 2-33, med = 34-66, high = 67-99 + percentages_to_zwave_speeds = [ + [[0], [0]], + [range(1, 34), range(2, 34)], + [range(34, 68), range(34, 67)], + [range(68, 101), range(67, 100)], + ] + + for percentages, zwave_speeds in percentages_to_zwave_speeds: + for percentage in percentages: + actual_zwave_speed = await get_zwave_speed_from_percentage(percentage) + assert actual_zwave_speed in zwave_speeds + for zwave_speed in zwave_speeds: + actual_percentage = await get_percentage_from_zwave_speed(zwave_speed) + assert actual_percentage in percentages + + # Check static entity properties + state = hass.states.get(entity_id) + assert math.isclose(state.attributes[ATTR_PERCENTAGE_STEP], 33.3333, rel_tol=1e-3) + assert state.attributes[ATTR_PRESET_MODES] == ["breeze"] + + # This device has one preset, where a device level of "1" is the + # "breeze" mode + await set_zwave_speed(1) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == "breeze" + assert state.attributes[ATTR_PERCENTAGE] is None + + client.async_send_command.reset_mock() + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": entity_id, "preset_mode": "breeze"}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node_id + assert args["value"] == 1 + + client.async_send_command.reset_mock() + with pytest.raises(NotValidPresetModeError): + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": entity_id, "preset_mode": "wheeze"}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 0 async def test_thermostat_fan(hass, client, climate_adc_t3000, integration): diff --git a/tests/conftest.py b/tests/conftest.py index 902fc55eac4f2..a7dcef3159120 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,7 +29,7 @@ from homeassistant.const import ATTR_NOW, EVENT_TIME_CHANGED, HASSIO_USER_NAME from homeassistant.helpers import config_entry_oauth2_flow, event from homeassistant.setup import async_setup_component -from homeassistant.util import location +from homeassistant.util import dt as dt_util, location from tests.ignore_uncaught_exceptions import IGNORE_UNCAUGHT_EXCEPTIONS @@ -249,6 +249,8 @@ def load_registries(): def hass(loop, load_registries, hass_storage, request): """Fixture to provide a test instance of Home Assistant.""" + orig_tz = dt_util.DEFAULT_TIME_ZONE + def exc_handle(loop, context): """Handle exceptions by rethrowing them, which will fail the test.""" # Most of these contexts will contain an exception, but not all. @@ -273,6 +275,10 @@ def exc_handle(loop, context): yield hass loop.run_until_complete(hass.async_stop(force=True)) + + # Restore timezone, it is set when creating the hass object + dt_util.DEFAULT_TIME_ZONE = orig_tz + for ex in exceptions: if ( request.module.__name__, diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index ffbc2130f3b30..2b5c35f06ada5 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -28,8 +28,6 @@ from tests.common import async_mock_service -ORIG_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE - @pytest.fixture def calls(hass): @@ -46,14 +44,6 @@ def setup_comp(hass): ) -@pytest.fixture(autouse=True) -def teardown(): - """Restore.""" - yield - - dt_util.set_default_time_zone(ORIG_TIME_ZONE) - - def assert_element(trace_element, expected_element, path): """Assert a trace element is as expected. @@ -2659,8 +2649,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue(hass, hass_ws_client, at 7 AM and sunset at 3AM during summer After sunrise is true from sunrise until midnight, local time. """ - tz = dt_util.get_time_zone("America/Anchorage") - dt_util.set_default_time_zone(tz) + hass.config.set_time_zone("America/Anchorage") hass.config.latitude = 66.5 hass.config.longitude = 162.4 await async_setup_component( @@ -2736,8 +2725,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue(hass, hass_ws_client, at 7 AM and sunset at 3AM during summer Before sunrise is true from midnight until sunrise, local time. """ - tz = dt_util.get_time_zone("America/Anchorage") - dt_util.set_default_time_zone(tz) + hass.config.set_time_zone("America/Anchorage") hass.config.latitude = 66.5 hass.config.longitude = 162.4 await async_setup_component( @@ -2813,8 +2801,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue(hass, hass_ws_client, at 7 AM and sunset at 3AM during summer Before sunset is true from midnight until sunset, local time. """ - tz = dt_util.get_time_zone("America/Anchorage") - dt_util.set_default_time_zone(tz) + hass.config.set_time_zone("America/Anchorage") hass.config.latitude = 66.5 hass.config.longitude = 162.4 await async_setup_component( @@ -2890,8 +2877,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue(hass, hass_ws_client, c at 7 AM and sunset at 3AM during summer After sunset is true from sunset until midnight, local time. """ - tz = dt_util.get_time_zone("America/Anchorage") - dt_util.set_default_time_zone(tz) + hass.config.set_time_zone("America/Anchorage") hass.config.latitude = 66.5 hass.config.longitude = 162.4 await async_setup_component( diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index bd17aec92e655..67fc679bb8b7b 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -46,14 +46,6 @@ DEFAULT_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE -@pytest.fixture(autouse=True) -def teardown(): - """Stop everything that was started.""" - yield - - dt_util.set_default_time_zone(DEFAULT_TIME_ZONE) - - async def test_track_point_in_time(hass): """Test track point in time.""" before_birthday = datetime(1985, 7, 9, 12, 0, 0, tzinfo=dt_util.UTC) @@ -3751,8 +3743,7 @@ async def test_periodic_task_duplicate_time(hass): @pytest.mark.freeze_time("2021-03-28 01:28:00+01:00") async def test_periodic_task_entering_dst(hass, freezer): """Test periodic task behavior when entering dst.""" - timezone = dt_util.get_time_zone("Europe/Vienna") - dt_util.set_default_time_zone(timezone) + hass.config.set_time_zone("Europe/Vienna") specific_runs = [] today = date.today().isoformat() @@ -3801,8 +3792,7 @@ async def test_periodic_task_entering_dst_2(hass, freezer): This tests a task firing every second in the range 0..58 (not *:*:59) """ - timezone = dt_util.get_time_zone("Europe/Vienna") - dt_util.set_default_time_zone(timezone) + hass.config.set_time_zone("Europe/Vienna") specific_runs = [] today = date.today().isoformat() @@ -3850,8 +3840,7 @@ async def test_periodic_task_entering_dst_2(hass, freezer): @pytest.mark.freeze_time("2021-10-31 02:28:00+02:00") async def test_periodic_task_leaving_dst(hass, freezer): """Test periodic task behavior when leaving dst.""" - timezone = dt_util.get_time_zone("Europe/Vienna") - dt_util.set_default_time_zone(timezone) + hass.config.set_time_zone("Europe/Vienna") specific_runs = [] today = date.today().isoformat() @@ -3925,8 +3914,7 @@ async def test_periodic_task_leaving_dst(hass, freezer): @pytest.mark.freeze_time("2021-10-31 02:28:00+02:00") async def test_periodic_task_leaving_dst_2(hass, freezer): """Test periodic task behavior when leaving dst.""" - timezone = dt_util.get_time_zone("Europe/Vienna") - dt_util.set_default_time_zone(timezone) + hass.config.set_time_zone("Europe/Vienna") specific_runs = [] today = date.today().isoformat() @@ -4188,8 +4176,8 @@ async def test_async_track_point_in_time_cancel(hass): """Test cancel of async track point in time.""" times = [] + hass.config.set_time_zone("US/Hawaii") hst_tz = dt_util.get_time_zone("US/Hawaii") - dt_util.set_default_time_zone(hst_tz) @ha.callback def run_callback(local_time): diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py index 0c9e8361104b1..13f1a3cdd7820 100644 --- a/tests/helpers/test_network.py +++ b/tests/helpers/test_network.py @@ -681,6 +681,9 @@ async def test_is_hass_url(hass): assert hass.config.external_url is None assert is_hass_url(hass, "http://example.com") is False + assert is_hass_url(hass, "bad_url") is False + assert is_hass_url(hass, "bad_url.com") is False + assert is_hass_url(hass, "http:/bad_url.com") is False hass.config.api = Mock(use_ssl=False, port=8123, local_ip="192.168.123.123") assert is_hass_url(hass, "http://192.168.123.123:8123") is True diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index bbf4a72430ce7..ce4a32bb2a420 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -552,6 +552,20 @@ async def test_call_with_required_features(hass, mock_entities): actual = [call[0][0] for call in test_service_mock.call_args_list] assert all(entity in actual for entity in expected) + # Test we raise if we target entity ID that does not support the service + test_service_mock.reset_mock() + with pytest.raises(exceptions.HomeAssistantError): + await service.entity_service_call( + hass, + [Mock(entities=mock_entities)], + test_service_mock, + ha.ServiceCall( + "test_domain", "test_service", {"entity_id": "light.living_room"} + ), + required_features=[SUPPORT_A], + ) + assert test_service_mock.call_count == 0 + async def test_call_with_both_required_features(hass, mock_entities): """Test service calls invoked only if entity has both features.""" diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index d70837bd08834..bbb5e11be7f62 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -674,6 +674,7 @@ def test_strptime(hass): def test_timestamp_custom(hass): """Test the timestamps to custom filter.""" + hass.config.set_time_zone("UTC") now = dt_util.utcnow() tests = [ (None, None, None, None), @@ -700,6 +701,7 @@ def test_timestamp_custom(hass): def test_timestamp_local(hass): """Test the timestamps to local filter.""" + hass.config.set_time_zone("UTC") tests = {None: None, 1469119144: "2016-07-21T16:39:04+00:00"} for inp, out in tests.items(): @@ -1290,10 +1292,7 @@ def test_today_at(mock_is_safe, hass, now, expected, expected_midnight, timezone freezer = freeze_time(now) freezer.start() - original_tz = dt_util.DEFAULT_TIME_ZONE - - timezone = dt_util.get_time_zone(timezone_str) - dt_util.set_default_time_zone(timezone) + hass.config.set_time_zone(timezone_str) result = template.Template( "{{ today_at('10:00').isoformat() }}", @@ -1323,7 +1322,6 @@ def test_today_at(mock_is_safe, hass, now, expected, expected_midnight, timezone template.Template("{{ today_at('bad') }}", hass).async_render() freezer.stop() - dt_util.set_default_time_zone(original_tz) @patch( @@ -1332,6 +1330,7 @@ def test_today_at(mock_is_safe, hass, now, expected, expected_midnight, timezone ) def test_relative_time(mock_is_safe, hass): """Test relative_time method.""" + hass.config.set_time_zone("UTC") now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") with patch("homeassistant.util.dt.now", return_value=now): result = template.Template( diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index be57b2d52e7de..5520269ca9d30 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -15,7 +15,7 @@ @pytest.fixture def mock_config_flows(): """Mock the config flows.""" - flows = [] + flows = {"integration": [], "helper": {}} with patch.object(config_flows, "FLOWS", flows): yield flows @@ -124,7 +124,7 @@ async def test_get_translations(hass, mock_config_flows, enable_custom_integrati async def test_get_translations_loads_config_flows(hass, mock_config_flows): """Test the get translations helper loads config flow translations.""" - mock_config_flows.append("component1") + mock_config_flows["integration"].append("component1") integration = Mock(file_path=pathlib.Path(__file__)) integration.name = "Component 1" @@ -153,7 +153,7 @@ async def test_get_translations_loads_config_flows(hass, mock_config_flows): assert "component1" not in hass.config.components - mock_config_flows.append("component2") + mock_config_flows["integration"].append("component2") integration = Mock(file_path=pathlib.Path(__file__)) integration.name = "Component 2" diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 87d93c1a1ac0b..c6c507a0f738f 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -12,7 +12,6 @@ from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATONS from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect -import homeassistant.util.dt as dt_util from tests.common import ( MockModule, @@ -23,7 +22,6 @@ mock_integration, ) -ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE VERSION_PATH = os.path.join(get_test_config_dir(), config_util.VERSION_FILE) diff --git a/tests/test_config.py b/tests/test_config.py index 4e761bc3f4706..552139fa0ef8f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -32,7 +32,6 @@ import homeassistant.helpers.check_config as check_config from homeassistant.helpers.entity import Entity from homeassistant.loader import async_get_integration -from homeassistant.util import dt as dt_util from homeassistant.util.yaml import SECRET_YAML from tests.common import get_test_config_dir, patch_yaml_files @@ -44,7 +43,6 @@ AUTOMATIONS_PATH = os.path.join(CONFIG_DIR, config_util.AUTOMATION_CONFIG_PATH) SCRIPTS_PATH = os.path.join(CONFIG_DIR, config_util.SCRIPT_CONFIG_PATH) SCENES_PATH = os.path.join(CONFIG_DIR, config_util.SCENE_CONFIG_PATH) -ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE def create_file(path): @@ -58,8 +56,6 @@ def teardown(): """Clean up.""" yield - dt_util.DEFAULT_TIME_ZONE = ORIG_TIMEZONE - if os.path.isfile(YAML_PATH): os.remove(YAML_PATH) diff --git a/tests/testing_config/custom_components/test/update.py b/tests/testing_config/custom_components/test/update.py new file mode 100644 index 0000000000000..aeac37d198e88 --- /dev/null +++ b/tests/testing_config/custom_components/test/update.py @@ -0,0 +1,138 @@ +""" +Provide a mock update platform. + +Call init before using it in your tests to ensure clean test data. +""" +from __future__ import annotations + +import logging + +from homeassistant.components.update import UpdateEntity, UpdateEntityFeature + +from tests.common import MockEntity + +ENTITIES = [] + +_LOGGER = logging.getLogger(__name__) + + +class MockUpdateEntity(MockEntity, UpdateEntity): + """Mock UpdateEntity class.""" + + @property + def current_version(self) -> str | None: + """Version currently in use.""" + return self._handle("current_version") + + @property + def in_progress(self) -> bool | int | None: + """Update installation progress.""" + return self._handle("in_progress") + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + return self._handle("latest_version") + + @property + def release_summary(self) -> str | None: + """Summary of the release notes or changelog.""" + return self._handle("release_summary") + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + return self._handle("release_url") + + @property + def title(self) -> str | None: + """Title of the software.""" + return self._handle("title") + + def install( + self, + version: str | None = None, + backup: bool | None = None, + ) -> None: + """Install an update.""" + if backup: + _LOGGER.info("Creating backup before installing update") + + if version is not None: + self._values["current_version"] = version + _LOGGER.info(f"Installed update with version: {version}") + else: + self._values["current_version"] = self.latest_version + _LOGGER.info("Installed latest update") + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + [] + if empty + else [ + MockUpdateEntity( + name="No Update", + unique_id="no_update", + current_version="1.0.0", + latest_version="1.0.0", + supported_features=UpdateEntityFeature.INSTALL, + ), + MockUpdateEntity( + name="Update Available", + unique_id="update_available", + current_version="1.0.0", + latest_version="1.0.1", + supported_features=UpdateEntityFeature.INSTALL, + ), + MockUpdateEntity( + name="Update Unknown", + unique_id="update_unknown", + current_version="1.0.0", + latest_version=None, + supported_features=UpdateEntityFeature.INSTALL, + ), + MockUpdateEntity( + name="Update Specific Version", + unique_id="update_specific_version", + current_version="1.0.0", + latest_version="1.0.0", + supported_features=UpdateEntityFeature.INSTALL + | UpdateEntityFeature.SPECIFIC_VERSION, + ), + MockUpdateEntity( + name="Update Backup", + unique_id="update_backup", + current_version="1.0.0", + latest_version="1.0.1", + supported_features=UpdateEntityFeature.INSTALL + | UpdateEntityFeature.SPECIFIC_VERSION + | UpdateEntityFeature.BACKUP, + ), + MockUpdateEntity( + name="Update Already in Progress", + unique_id="update_already_in_progres", + current_version="1.0.0", + latest_version="1.0.1", + in_progress=50, + supported_features=UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS, + ), + MockUpdateEntity( + name="Update No Install", + unique_id="no_install", + current_version="1.0.0", + latest_version="1.0.1", + ), + ] + ) + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES) diff --git a/tests/util/test_network.py b/tests/util/test_network.py index b5c6b1a3e2417..4f372e5e1a7d4 100644 --- a/tests/util/test_network.py +++ b/tests/util/test_network.py @@ -91,3 +91,4 @@ def test_normalize_url(): network_util.normalize_url("https://example.com:443/test/") == "https://example.com/test" ) + assert network_util.normalize_url("/test/") == "/test"