diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 217093793d1..f02a8bacce8 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -174,15 +174,6 @@ jobs: sed -i "s|pyezviz|# pyezviz|g" requirements_all.txt sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt - - name: Adjustments for 64-bit - if: matrix.arch == 'amd64' || matrix.arch == 'aarch64' - run: | - # Some speedups are only available on 64-bit, and since - # we build 32bit images on 64bit hosts, we only enable - # the speed ups on 64bit since the wheels for 32bit - # are not available. - sed -i "s|aiohttp-zlib-ng|aiohttp-zlib-ng\[isal\]|g" requirements_all.txt - - name: Download translations uses: actions/download-artifact@v4.1.4 with: diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d619fd8c7dc..a5bafa0c52d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,7 +33,7 @@ on: type: boolean env: - CACHE_VERSION: 5 + CACHE_VERSION: 7 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 8 HA_SHORT_VERSION: "2024.5" @@ -95,6 +95,7 @@ jobs: run: >- echo "key=venv-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements_test.txt', 'requirements_test_pre_commit.txt') }}-${{ + hashFiles('requirements.txt') }}-${{ hashFiles('requirements_all.txt') }}-${{ hashFiles('homeassistant/package_constraints.txt') }}" >> $GITHUB_OUTPUT - name: Generate partial pre-commit restore key @@ -484,6 +485,7 @@ jobs: libavfilter-dev \ libavformat-dev \ libavutil-dev \ + libgammu-dev \ libswresample-dev \ libswscale-dev \ libudev-dev @@ -496,6 +498,7 @@ jobs: pip install "$(grep '^uv' < requirements_test.txt)" uv pip install -U "pip>=21.3.1" setuptools wheel uv pip install -r requirements_all.txt + uv pip install "$(grep 'python-gammu' < requirements_all.txt | sed -e 's|# python-gammu|python-gammu|g')" uv pip install -r requirements_test.txt uv pip install -e . --config-settings editable_mode=compat @@ -688,7 +691,8 @@ jobs: sudo apt-get update sudo apt-get -y install \ bluez \ - ffmpeg + ffmpeg \ + libgammu-dev - name: Check out code from GitHub uses: actions/checkout@v4.1.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} @@ -747,7 +751,8 @@ jobs: sudo apt-get update sudo apt-get -y install \ bluez \ - ffmpeg + ffmpeg \ + libgammu-dev - name: Check out code from GitHub uses: actions/checkout@v4.1.2 - name: Set up Python ${{ matrix.python-version }} @@ -1124,7 +1129,8 @@ jobs: sudo apt-get update sudo apt-get -y install \ bluez \ - ffmpeg + ffmpeg \ + libgammu-dev - name: Check out code from GitHub uses: actions/checkout@v4.1.2 - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 475c0bd352f..2b9a2af127f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.1.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.24.10 + uses: github/codeql-action/init@v3.25.1 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.24.10 + uses: github/codeql-action/analyze@v3.25.1 with: category: "/language:python" diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 9f127acb57d..7102df0ae4d 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -142,11 +142,9 @@ jobs: run: | requirement_files="requirements_all.txt requirements_diff.txt" for requirement_file in ${requirement_files}; do - sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file} sed -i "s|# evdev|evdev|g" ${requirement_file} sed -i "s|# pycups|pycups|g" ${requirement_file} - sed -i "s|# homekit|homekit|g" ${requirement_file} sed -i "s|# decora-wifi|decora-wifi|g" ${requirement_file} sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} @@ -163,16 +161,11 @@ jobs: sed -i "s|pykrakenapi|# pykrakenapi|g" ${requirement_file} fi - # Some speedups are only for 64-bit - if [ "${{ matrix.arch }}" = "amd64" ] || [ "${{ matrix.arch }}" = "aarch64" ]; then - sed -i "s|aiohttp-zlib-ng|aiohttp-zlib-ng\[isal\]|g" ${requirement_file} - fi - done - name: Split requirements all run: | - # We split requirements all into two different files. + # We split requirements all into multiple files. # This is to prevent the build from running out of memory when # resolving packages on 32-bits systems (like armhf, armv7). diff --git a/.strict-typing b/.strict-typing index 63a867e9c50..5985938885f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -66,6 +66,7 @@ homeassistant.components.alpha_vantage.* homeassistant.components.amazon_polly.* homeassistant.components.amberelectric.* homeassistant.components.ambiclimate.* +homeassistant.components.ambient_network.* homeassistant.components.ambient_station.* homeassistant.components.amcrest.* homeassistant.components.ampio.* diff --git a/CODEOWNERS b/CODEOWNERS index 0b9f59efea1..3b617f97453 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -90,6 +90,8 @@ build.json @home-assistant/supervisor /tests/components/amberelectric/ @madpilot /homeassistant/components/ambiclimate/ @danielhiversen /tests/components/ambiclimate/ @danielhiversen +/homeassistant/components/ambient_network/ @thomaskistler +/tests/components/ambient_network/ @thomaskistler /homeassistant/components/ambient_station/ @bachya /tests/components/ambient_station/ @bachya /homeassistant/components/amcrest/ @flacjacket @@ -387,6 +389,7 @@ build.json @home-assistant/supervisor /homeassistant/components/energyzero/ @klaasnicolaas /tests/components/energyzero/ @klaasnicolaas /homeassistant/components/enigma2/ @autinerd +/tests/components/enigma2/ @autinerd /homeassistant/components/enocean/ @bdurrer /tests/components/enocean/ @bdurrer /homeassistant/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek @catsmanac @@ -683,8 +686,8 @@ build.json @home-assistant/supervisor /homeassistant/components/iqvia/ @bachya /tests/components/iqvia/ @bachya /homeassistant/components/irish_rail_transport/ @ttroy50 -/homeassistant/components/islamic_prayer_times/ @engrbm87 -/tests/components/islamic_prayer_times/ @engrbm87 +/homeassistant/components/islamic_prayer_times/ @engrbm87 @cpfair +/tests/components/islamic_prayer_times/ @engrbm87 @cpfair /homeassistant/components/iss/ @DurgNomis-drol /tests/components/iss/ @DurgNomis-drol /homeassistant/components/isy994/ @bdraco @shbatm @@ -753,7 +756,8 @@ build.json @home-assistant/supervisor /tests/components/leaone/ @bdraco /homeassistant/components/led_ble/ @bdraco /tests/components/led_ble/ @bdraco -/homeassistant/components/lg_netcast/ @Drafteed +/homeassistant/components/lg_netcast/ @Drafteed @splinter98 +/tests/components/lg_netcast/ @Drafteed @splinter98 /homeassistant/components/lidarr/ @tkdrob /tests/components/lidarr/ @tkdrob /homeassistant/components/light/ @home-assistant/core @@ -1025,8 +1029,8 @@ build.json @home-assistant/supervisor /tests/components/pglab/ @pierluigi /homeassistant/components/philips_js/ @elupus /tests/components/philips_js/ @elupus -/homeassistant/components/pi_hole/ @johnluetke @shenxn -/tests/components/pi_hole/ @johnluetke @shenxn +/homeassistant/components/pi_hole/ @shenxn +/tests/components/pi_hole/ @shenxn /homeassistant/components/picnic/ @corneyl /tests/components/picnic/ @corneyl /homeassistant/components/pilight/ @trekky12 @@ -1185,6 +1189,8 @@ build.json @home-assistant/supervisor /homeassistant/components/saj/ @fredericvl /homeassistant/components/samsungtv/ @chemelli74 @epenet /tests/components/samsungtv/ @chemelli74 @epenet +/homeassistant/components/sanix/ @tomaszsluszniak +/tests/components/sanix/ @tomaszsluszniak /homeassistant/components/scene/ @home-assistant/core /tests/components/scene/ @home-assistant/core /homeassistant/components/schedule/ @home-assistant/core @@ -1271,6 +1277,7 @@ build.json @home-assistant/supervisor /homeassistant/components/smhi/ @gjohansson-ST /tests/components/smhi/ @gjohansson-ST /homeassistant/components/sms/ @ocalvo +/tests/components/sms/ @ocalvo /homeassistant/components/snapcast/ @luar123 /tests/components/snapcast/ @luar123 /homeassistant/components/snmp/ @nmaggioni diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index 26e0c1331be..d52ef5e0ec6 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -2,14 +2,10 @@ from __future__ import annotations -from asyncio import timeout -from datetime import timedelta +from dataclasses import dataclass import logging -from typing import Any -from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError -from aiohttp import ClientSession -from aiohttp.client_exceptions import ClientConnectorError +from accuweather import AccuWeather from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM from homeassistant.config_entries import ConfigEntry @@ -17,43 +13,70 @@ from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ATTR_FORECAST, CONF_FORECAST, DOMAIN, MANUFACTURER +from .const import DOMAIN, UPDATE_INTERVAL_DAILY_FORECAST, UPDATE_INTERVAL_OBSERVATION +from .coordinator import ( + AccuWeatherDailyForecastDataUpdateCoordinator, + AccuWeatherObservationDataUpdateCoordinator, +) _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR, Platform.WEATHER] +@dataclass +class AccuWeatherData: + """Data for AccuWeather integration.""" + + coordinator_observation: AccuWeatherObservationDataUpdateCoordinator + coordinator_daily_forecast: AccuWeatherDailyForecastDataUpdateCoordinator + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up AccuWeather as config entry.""" api_key: str = entry.data[CONF_API_KEY] name: str = entry.data[CONF_NAME] - assert entry.unique_id is not None - location_key = entry.unique_id - forecast: bool = entry.options.get(CONF_FORECAST, False) - _LOGGER.debug("Using location_key: %s, get forecast: %s", location_key, forecast) + location_key = entry.unique_id + + _LOGGER.debug("Using location_key: %s", location_key) websession = async_get_clientsession(hass) + accuweather = AccuWeather(api_key, websession, location_key=location_key) - coordinator = AccuWeatherDataUpdateCoordinator( - hass, websession, api_key, location_key, forecast, name + coordinator_observation = AccuWeatherObservationDataUpdateCoordinator( + hass, + accuweather, + name, + "observation", + UPDATE_INTERVAL_OBSERVATION, ) - await coordinator.async_config_entry_first_refresh() + + coordinator_daily_forecast = AccuWeatherDailyForecastDataUpdateCoordinator( + hass, + accuweather, + name, + "daily forecast", + UPDATE_INTERVAL_DAILY_FORECAST, + ) + + await coordinator_observation.async_config_entry_first_refresh() + await coordinator_daily_forecast.async_config_entry_first_refresh() entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AccuWeatherData( + coordinator_observation=coordinator_observation, + coordinator_daily_forecast=coordinator_daily_forecast, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Remove ozone sensors from registry if they exist ent_reg = er.async_get(hass) for day in range(5): - unique_id = f"{coordinator.location_key}-ozone-{day}" + unique_id = f"{location_key}-ozone-{day}" if entity_id := ent_reg.async_get_entity_id(SENSOR_PLATFORM, DOMAIN, unique_id): _LOGGER.debug("Removing ozone sensor entity %s", entity_id) ent_reg.async_remove(entity_id) @@ -74,65 +97,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener.""" await hass.config_entries.async_reload(entry.entry_id) - - -class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # pylint: disable=hass-enforce-coordinator-module - """Class to manage fetching AccuWeather data API.""" - - def __init__( - self, - hass: HomeAssistant, - session: ClientSession, - api_key: str, - location_key: str, - forecast: bool, - name: str, - ) -> None: - """Initialize.""" - self.location_key = location_key - self.forecast = forecast - self.accuweather = AccuWeather(api_key, session, location_key=location_key) - self.device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, location_key)}, - manufacturer=MANUFACTURER, - name=name, - # You don't need to provide specific details for the URL, - # so passing in _ characters is fine if the location key - # is correct - configuration_url=( - "http://accuweather.com/en/" - f"_/_/{location_key}/" - f"weather-forecast/{location_key}/" - ), - ) - - # Enabling the forecast download increases the number of requests per data - # update, we use 40 minutes for current condition only and 80 minutes for - # current condition and forecast as update interval to not exceed allowed number - # of requests. We have 50 requests allowed per day, so we use 36 and leave 14 as - # a reserve for restarting HA. - update_interval = timedelta(minutes=40) - if self.forecast: - update_interval *= 2 - _LOGGER.debug("Data will be update every %s", update_interval) - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - - async def _async_update_data(self) -> dict[str, Any]: - """Update data via library.""" - forecast: list[dict[str, Any]] = [] - try: - async with timeout(10): - current = await self.accuweather.async_get_current_conditions() - if self.forecast: - forecast = await self.accuweather.async_get_daily_forecast() - except ( - ApiError, - ClientConnectorError, - InvalidApiKeyError, - RequestsExceededError, - ) as error: - raise UpdateFailed(error) from error - _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) - return {**current, ATTR_FORECAST: forecast} diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py index af7560d963a..71f7de89528 100644 --- a/homeassistant/components/accuweather/config_flow.py +++ b/homeassistant/components/accuweather/config_flow.py @@ -10,26 +10,12 @@ from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME -from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.schema_config_entry_flow import ( - SchemaFlowFormStep, - SchemaOptionsFlowHandler, -) -from .const import CONF_FORECAST, DOMAIN - -OPTIONS_SCHEMA = vol.Schema( - { - vol.Optional(CONF_FORECAST, default=False): bool, - } -) -OPTIONS_FLOW = { - "init": SchemaFlowFormStep(OPTIONS_SCHEMA), -} +from .const import DOMAIN class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN): @@ -87,9 +73,3 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN): ), errors=errors, ) - - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry) -> SchemaOptionsFlowHandler: - """Options callback for AccuWeather.""" - return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 31925172d1c..1bbf5a36187 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import timedelta from typing import Final from homeassistant.components.weather import ( @@ -27,10 +28,8 @@ ATTR_CATEGORY: Final = "Category" ATTR_DIRECTION: Final = "Direction" ATTR_ENGLISH: Final = "English" ATTR_LEVEL: Final = "level" -ATTR_FORECAST: Final = "forecast" ATTR_SPEED: Final = "Speed" ATTR_VALUE: Final = "Value" -CONF_FORECAST: Final = "forecast" DOMAIN: Final = "accuweather" MANUFACTURER: Final = "AccuWeather, Inc." MAX_FORECAST_DAYS: Final = 4 @@ -56,3 +55,5 @@ CONDITION_MAP = { for cond_ha, cond_codes in CONDITION_CLASSES.items() for cond_code in cond_codes } +UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40) +UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6) diff --git a/homeassistant/components/accuweather/coordinator.py b/homeassistant/components/accuweather/coordinator.py new file mode 100644 index 00000000000..26fadd6806c --- /dev/null +++ b/homeassistant/components/accuweather/coordinator.py @@ -0,0 +1,124 @@ +"""The AccuWeather coordinator.""" + +from asyncio import timeout +from datetime import timedelta +import logging +from typing import TYPE_CHECKING, Any + +from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError +from aiohttp.client_exceptions import ClientConnectorError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + TimestampDataUpdateCoordinator, + UpdateFailed, +) + +from .const import DOMAIN, MANUFACTURER + +EXCEPTIONS = (ApiError, ClientConnectorError, InvalidApiKeyError, RequestsExceededError) + +_LOGGER = logging.getLogger(__name__) + + +class AccuWeatherObservationDataUpdateCoordinator( + DataUpdateCoordinator[dict[str, Any]] +): + """Class to manage fetching AccuWeather data API.""" + + def __init__( + self, + hass: HomeAssistant, + accuweather: AccuWeather, + name: str, + coordinator_type: str, + update_interval: timedelta, + ) -> None: + """Initialize.""" + self.accuweather = accuweather + self.location_key = accuweather.location_key + + if TYPE_CHECKING: + assert self.location_key is not None + + self.device_info = _get_device_info(self.location_key, name) + + super().__init__( + hass, + _LOGGER, + name=f"{name} ({coordinator_type})", + update_interval=update_interval, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via library.""" + try: + async with timeout(10): + result = await self.accuweather.async_get_current_conditions() + except EXCEPTIONS as error: + raise UpdateFailed(error) from error + + _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) + + return result + + +class AccuWeatherDailyForecastDataUpdateCoordinator( + TimestampDataUpdateCoordinator[list[dict[str, Any]]] +): + """Class to manage fetching AccuWeather data API.""" + + def __init__( + self, + hass: HomeAssistant, + accuweather: AccuWeather, + name: str, + coordinator_type: str, + update_interval: timedelta, + ) -> None: + """Initialize.""" + self.accuweather = accuweather + self.location_key = accuweather.location_key + + if TYPE_CHECKING: + assert self.location_key is not None + + self.device_info = _get_device_info(self.location_key, name) + + super().__init__( + hass, + _LOGGER, + name=f"{name} ({coordinator_type})", + update_interval=update_interval, + ) + + async def _async_update_data(self) -> list[dict[str, Any]]: + """Update data via library.""" + try: + async with timeout(10): + result = await self.accuweather.async_get_daily_forecast() + except EXCEPTIONS as error: + raise UpdateFailed(error) from error + + _LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining) + + return result + + +def _get_device_info(location_key: str, name: str) -> DeviceInfo: + """Get device info.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, location_key)}, + manufacturer=MANUFACTURER, + name=name, + # You don't need to provide specific details for the URL, + # so passing in _ characters is fine if the location key + # is correct + configuration_url=( + "http://accuweather.com/en/" + f"_/_/{location_key}/weather-forecast/{location_key}/" + ), + ) diff --git a/homeassistant/components/accuweather/diagnostics.py b/homeassistant/components/accuweather/diagnostics.py index c4f04b209cf..810638a1e49 100644 --- a/homeassistant/components/accuweather/diagnostics.py +++ b/homeassistant/components/accuweather/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from . import AccuWeatherDataUpdateCoordinator +from . import AccuWeatherData from .const import DOMAIN TO_REDACT = {CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE} @@ -19,11 +19,9 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: AccuWeatherDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + accuweather_data: AccuWeatherData = hass.data[DOMAIN][config_entry.entry_id] return { "config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT), - "coordinator_data": coordinator.data, + "observation_data": accuweather_data.coordinator_observation.data, } diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 521dfdfbead..95274297828 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -28,13 +28,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AccuWeatherDataUpdateCoordinator +from . import AccuWeatherData from .const import ( API_METRIC, ATTR_CATEGORY, ATTR_DIRECTION, ATTR_ENGLISH, - ATTR_FORECAST, ATTR_LEVEL, ATTR_SPEED, ATTR_VALUE, @@ -42,6 +41,10 @@ from .const import ( DOMAIN, MAX_FORECAST_DAYS, ) +from .coordinator import ( + AccuWeatherDailyForecastDataUpdateCoordinator, + AccuWeatherObservationDataUpdateCoordinator, +) PARALLEL_UPDATES = 1 @@ -52,12 +55,18 @@ class AccuWeatherSensorDescription(SensorEntityDescription): value_fn: Callable[[dict[str, Any]], str | int | float | None] attr_fn: Callable[[dict[str, Any]], dict[str, Any]] = lambda _: {} - day: int | None = None -FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( +@dataclass(frozen=True, kw_only=True) +class AccuWeatherForecastSensorDescription(AccuWeatherSensorDescription): + """Class describing AccuWeather sensor entities.""" + + day: int + + +FORECAST_SENSOR_TYPES: tuple[AccuWeatherForecastSensorDescription, ...] = ( *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="AirQuality", icon="mdi:air-filter", value_fn=lambda data: cast(str, data[ATTR_CATEGORY]), @@ -69,7 +78,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="CloudCoverDay", icon="mdi:weather-cloudy", entity_registry_enabled_default=False, @@ -81,7 +90,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="CloudCoverNight", icon="mdi:weather-cloudy", entity_registry_enabled_default=False, @@ -93,7 +102,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="Grass", icon="mdi:grass", entity_registry_enabled_default=False, @@ -106,7 +115,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="HoursOfSun", icon="mdi:weather-partly-cloudy", native_unit_of_measurement=UnitOfTime.HOURS, @@ -117,7 +126,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="LongPhraseDay", value_fn=lambda data: cast(str, data), translation_key=f"condition_day_{day}d", @@ -126,7 +135,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="LongPhraseNight", value_fn=lambda data: cast(str, data), translation_key=f"condition_night_{day}d", @@ -135,7 +144,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="Mold", icon="mdi:blur", entity_registry_enabled_default=False, @@ -148,7 +157,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="Ragweed", icon="mdi:sprout", native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, @@ -161,7 +170,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="RealFeelTemperatureMax", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -172,7 +181,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="RealFeelTemperatureMin", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -183,7 +192,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="RealFeelTemperatureShadeMax", device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, @@ -195,7 +204,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="RealFeelTemperatureShadeMin", device_class=SensorDeviceClass.TEMPERATURE, entity_registry_enabled_default=False, @@ -207,7 +216,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="SolarIrradianceDay", icon="mdi:weather-sunny", entity_registry_enabled_default=False, @@ -219,7 +228,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="SolarIrradianceNight", icon="mdi:weather-sunny", entity_registry_enabled_default=False, @@ -231,7 +240,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="ThunderstormProbabilityDay", icon="mdi:weather-lightning", native_unit_of_measurement=PERCENTAGE, @@ -242,7 +251,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="ThunderstormProbabilityNight", icon="mdi:weather-lightning", native_unit_of_measurement=PERCENTAGE, @@ -253,7 +262,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="Tree", icon="mdi:tree-outline", native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, @@ -266,7 +275,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="UVIndex", icon="mdi:weather-sunny", native_unit_of_measurement=UV_INDEX, @@ -278,7 +287,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="WindGustDay", device_class=SensorDeviceClass.WIND_SPEED, entity_registry_enabled_default=False, @@ -291,7 +300,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="WindGustNight", device_class=SensorDeviceClass.WIND_SPEED, entity_registry_enabled_default=False, @@ -304,7 +313,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="WindDay", device_class=SensorDeviceClass.WIND_SPEED, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, @@ -316,7 +325,7 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( for day in range(MAX_FORECAST_DAYS + 1) ), *( - AccuWeatherSensorDescription( + AccuWeatherForecastSensorDescription( key="WindNight", device_class=SensorDeviceClass.WIND_SPEED, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, @@ -453,25 +462,33 @@ async def async_setup_entry( ) -> None: """Add AccuWeather entities from a config_entry.""" - coordinator: AccuWeatherDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + accuweather_data: AccuWeatherData = hass.data[DOMAIN][entry.entry_id] - sensors = [ - AccuWeatherSensor(coordinator, description) for description in SENSOR_TYPES + observation_coordinator: AccuWeatherObservationDataUpdateCoordinator = ( + accuweather_data.coordinator_observation + ) + forecast_daily_coordinator: AccuWeatherDailyForecastDataUpdateCoordinator = ( + accuweather_data.coordinator_daily_forecast + ) + + sensors: list[AccuWeatherSensor | AccuWeatherForecastSensor] = [ + AccuWeatherSensor(observation_coordinator, description) + for description in SENSOR_TYPES ] - if coordinator.forecast: - for description in FORECAST_SENSOR_TYPES: - # Some air quality/allergy sensors are only available for certain - # locations. - if description.key not in coordinator.data[ATTR_FORECAST][description.day]: - continue - sensors.append(AccuWeatherSensor(coordinator, description)) + sensors.extend( + [ + AccuWeatherForecastSensor(forecast_daily_coordinator, description) + for description in FORECAST_SENSOR_TYPES + if description.key in forecast_daily_coordinator.data[description.day] + ] + ) async_add_entities(sensors) class AccuWeatherSensor( - CoordinatorEntity[AccuWeatherDataUpdateCoordinator], SensorEntity + CoordinatorEntity[AccuWeatherObservationDataUpdateCoordinator], SensorEntity ): """Define an AccuWeather entity.""" @@ -481,22 +498,15 @@ class AccuWeatherSensor( def __init__( self, - coordinator: AccuWeatherDataUpdateCoordinator, + coordinator: AccuWeatherObservationDataUpdateCoordinator, description: AccuWeatherSensorDescription, ) -> None: """Initialize.""" super().__init__(coordinator) - self.forecast_day = description.day + self.entity_description = description - self._sensor_data = _get_sensor_data( - coordinator.data, description.key, self.forecast_day - ) - if self.forecast_day is not None: - self._attr_unique_id = f"{coordinator.location_key}-{description.key}-{self.forecast_day}".lower() - else: - self._attr_unique_id = ( - f"{coordinator.location_key}-{description.key}".lower() - ) + self._sensor_data = self._get_sensor_data(coordinator.data, description.key) + self._attr_unique_id = f"{coordinator.location_key}-{description.key}".lower() self._attr_device_info = coordinator.device_info @property @@ -507,30 +517,78 @@ class AccuWeatherSensor( @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - if self.forecast_day is not None: - return self.entity_description.attr_fn(self._sensor_data) - return self.entity_description.attr_fn(self.coordinator.data) @callback def _handle_coordinator_update(self) -> None: """Handle data update.""" - self._sensor_data = _get_sensor_data( + self._sensor_data = self._get_sensor_data( + self.coordinator.data, self.entity_description.key + ) + self.async_write_ha_state() + + @staticmethod + def _get_sensor_data( + sensors: dict[str, Any], + kind: str, + ) -> Any: + """Get sensor data.""" + if kind == "Precipitation": + return sensors["PrecipitationSummary"]["PastHour"] + + return sensors[kind] + + +class AccuWeatherForecastSensor( + CoordinatorEntity[AccuWeatherDailyForecastDataUpdateCoordinator], SensorEntity +): + """Define an AccuWeather entity.""" + + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + entity_description: AccuWeatherForecastSensorDescription + + def __init__( + self, + coordinator: AccuWeatherDailyForecastDataUpdateCoordinator, + description: AccuWeatherForecastSensorDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.forecast_day = description.day + self.entity_description = description + self._sensor_data = self._get_sensor_data( + coordinator.data, description.key, self.forecast_day + ) + self._attr_unique_id = ( + f"{coordinator.location_key}-{description.key}-{self.forecast_day}".lower() + ) + self._attr_device_info = coordinator.device_info + + @property + def native_value(self) -> str | int | float | None: + """Return the state.""" + return self.entity_description.value_fn(self._sensor_data) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes.""" + return self.entity_description.attr_fn(self._sensor_data) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle data update.""" + self._sensor_data = self._get_sensor_data( self.coordinator.data, self.entity_description.key, self.forecast_day ) self.async_write_ha_state() - -def _get_sensor_data( - sensors: dict[str, Any], - kind: str, - forecast_day: int | None = None, -) -> Any: - """Get sensor data.""" - if forecast_day is not None: - return sensors[ATTR_FORECAST][forecast_day][kind] - - if kind == "Precipitation": - return sensors["PrecipitationSummary"]["PastHour"] - - return sensors[kind] + @staticmethod + def _get_sensor_data( + sensors: list[dict[str, Any]], + kind: str, + forecast_day: int, + ) -> Any: + """Get sensor data.""" + return sensors[forecast_day][kind] diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index 718f2da6a75..9d8fce865fd 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -11,7 +11,7 @@ } }, "create_entry": { - "default": "Some sensors are not enabled by default. You can enable them in the entity registry after the integration configuration.\nWeather forecast is not enabled by default. You can enable it in the integration options." + "default": "Some sensors are not enabled by default. You can enable them in the entity registry after the integration configuration." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -790,16 +790,6 @@ } } }, - "options": { - "step": { - "init": { - "description": "Due to the limitations of the free version of the AccuWeather API key, when you enable weather forecast, data updates will be performed every 80 minutes instead of every 40 minutes.", - "data": { - "forecast": "Weather forecast" - } - } - } - }, "system_health": { "info": { "can_reach_server": "Reach AccuWeather server", diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 1f2e606f6ea..4d248a06ac3 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -17,8 +17,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TIME, ATTR_FORECAST_UV_INDEX, ATTR_FORECAST_WIND_BEARING, + CoordinatorWeatherEntity, Forecast, - SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -31,19 +31,23 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from homeassistant.util.dt import utc_from_timestamp -from . import AccuWeatherDataUpdateCoordinator +from . import AccuWeatherData from .const import ( API_METRIC, ATTR_DIRECTION, - ATTR_FORECAST, ATTR_SPEED, ATTR_VALUE, ATTRIBUTION, CONDITION_MAP, DOMAIN, ) +from .coordinator import ( + AccuWeatherDailyForecastDataUpdateCoordinator, + AccuWeatherObservationDataUpdateCoordinator, +) PARALLEL_UPDATES = 1 @@ -52,106 +56,134 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Add a AccuWeather weather entity from a config_entry.""" + accuweather_data: AccuWeatherData = hass.data[DOMAIN][entry.entry_id] - coordinator: AccuWeatherDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - - async_add_entities([AccuWeatherEntity(coordinator)]) + async_add_entities([AccuWeatherEntity(accuweather_data)]) class AccuWeatherEntity( - SingleCoordinatorWeatherEntity[AccuWeatherDataUpdateCoordinator] + CoordinatorWeatherEntity[ + AccuWeatherObservationDataUpdateCoordinator, + AccuWeatherDailyForecastDataUpdateCoordinator, + TimestampDataUpdateCoordinator, + TimestampDataUpdateCoordinator, + ] ): """Define an AccuWeather entity.""" _attr_has_entity_name = True _attr_name = None - def __init__(self, coordinator: AccuWeatherDataUpdateCoordinator) -> None: + def __init__(self, accuweather_data: AccuWeatherData) -> None: """Initialize.""" - super().__init__(coordinator) + super().__init__( + observation_coordinator=accuweather_data.coordinator_observation, + daily_coordinator=accuweather_data.coordinator_daily_forecast, + ) + self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS self._attr_native_pressure_unit = UnitOfPressure.HPA self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS self._attr_native_visibility_unit = UnitOfLength.KILOMETERS self._attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR - self._attr_unique_id = coordinator.location_key + self._attr_unique_id = accuweather_data.coordinator_observation.location_key self._attr_attribution = ATTRIBUTION - self._attr_device_info = coordinator.device_info - if self.coordinator.forecast: - self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY + self._attr_device_info = accuweather_data.coordinator_observation.device_info + self._attr_supported_features = WeatherEntityFeature.FORECAST_DAILY + + self.observation_coordinator = accuweather_data.coordinator_observation + self.daily_coordinator = accuweather_data.coordinator_daily_forecast @property def condition(self) -> str | None: """Return the current condition.""" - return CONDITION_MAP.get(self.coordinator.data["WeatherIcon"]) + return CONDITION_MAP.get(self.observation_coordinator.data["WeatherIcon"]) @property def cloud_coverage(self) -> float: """Return the Cloud coverage in %.""" - return cast(float, self.coordinator.data["CloudCover"]) + return cast(float, self.observation_coordinator.data["CloudCover"]) @property def native_apparent_temperature(self) -> float: """Return the apparent temperature.""" return cast( - float, self.coordinator.data["ApparentTemperature"][API_METRIC][ATTR_VALUE] + float, + self.observation_coordinator.data["ApparentTemperature"][API_METRIC][ + ATTR_VALUE + ], ) @property def native_temperature(self) -> float: """Return the temperature.""" - return cast(float, self.coordinator.data["Temperature"][API_METRIC][ATTR_VALUE]) + return cast( + float, + self.observation_coordinator.data["Temperature"][API_METRIC][ATTR_VALUE], + ) @property def native_pressure(self) -> float: """Return the pressure.""" - return cast(float, self.coordinator.data["Pressure"][API_METRIC][ATTR_VALUE]) + return cast( + float, self.observation_coordinator.data["Pressure"][API_METRIC][ATTR_VALUE] + ) @property def native_dew_point(self) -> float: """Return the dew point.""" - return cast(float, self.coordinator.data["DewPoint"][API_METRIC][ATTR_VALUE]) + return cast( + float, self.observation_coordinator.data["DewPoint"][API_METRIC][ATTR_VALUE] + ) @property def humidity(self) -> int: """Return the humidity.""" - return cast(int, self.coordinator.data["RelativeHumidity"]) + return cast(int, self.observation_coordinator.data["RelativeHumidity"]) @property def native_wind_gust_speed(self) -> float: """Return the wind gust speed.""" return cast( - float, self.coordinator.data["WindGust"][ATTR_SPEED][API_METRIC][ATTR_VALUE] + float, + self.observation_coordinator.data["WindGust"][ATTR_SPEED][API_METRIC][ + ATTR_VALUE + ], ) @property def native_wind_speed(self) -> float: """Return the wind speed.""" return cast( - float, self.coordinator.data["Wind"][ATTR_SPEED][API_METRIC][ATTR_VALUE] + float, + self.observation_coordinator.data["Wind"][ATTR_SPEED][API_METRIC][ + ATTR_VALUE + ], ) @property def wind_bearing(self) -> int: """Return the wind bearing.""" - return cast(int, self.coordinator.data["Wind"][ATTR_DIRECTION]["Degrees"]) + return cast( + int, self.observation_coordinator.data["Wind"][ATTR_DIRECTION]["Degrees"] + ) @property def native_visibility(self) -> float: """Return the visibility.""" - return cast(float, self.coordinator.data["Visibility"][API_METRIC][ATTR_VALUE]) + return cast( + float, + self.observation_coordinator.data["Visibility"][API_METRIC][ATTR_VALUE], + ) @property def uv_index(self) -> float: """Return the UV index.""" - return cast(float, self.coordinator.data["UVIndex"]) + return cast(float, self.observation_coordinator.data["UVIndex"]) @callback def _async_forecast_daily(self) -> list[Forecast] | None: """Return the daily forecast in native units.""" - if not self.coordinator.forecast: - return None - # remap keys from library to keys understood by the weather component return [ { ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(), @@ -175,5 +207,5 @@ class AccuWeatherEntity( ATTR_FORECAST_WIND_BEARING: item["WindDay"][ATTR_DIRECTION]["Degrees"], ATTR_FORECAST_CONDITION: CONDITION_MAP.get(item["IconDay"]), } - for item in self.coordinator.data[ATTR_FORECAST] + for item in self.daily_coordinator.data ] diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index b3cbb3300bf..874a4cae963 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +from dataclasses import dataclass + from adguardhome import AdGuardHome, AdGuardHomeConnectionError import voluptuous as vol @@ -24,7 +26,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( CONF_FORCE, - DATA_ADGUARD_CLIENT, DOMAIN, SERVICE_ADD_URL, SERVICE_DISABLE_URL, @@ -44,6 +45,14 @@ SERVICE_REFRESH_SCHEMA = vol.Schema( PLATFORMS = [Platform.SENSOR, Platform.SWITCH] +@dataclass +class AdGuardData: + """Adguard data type.""" + + client: AdGuardHome + version: str + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up AdGuard Home from a config entry.""" session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]) @@ -57,13 +66,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=session, ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {DATA_ADGUARD_CLIENT: adguard} - try: - await adguard.version() + version = await adguard.version() except AdGuardHomeConnectionError as exception: raise ConfigEntryNotReady from exception + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AdGuardData(adguard, version) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def add_url(call: ServiceCall) -> None: diff --git a/homeassistant/components/adguard/const.py b/homeassistant/components/adguard/const.py index 7b6827c19d4..5af739a8f0b 100644 --- a/homeassistant/components/adguard/const.py +++ b/homeassistant/components/adguard/const.py @@ -6,9 +6,6 @@ DOMAIN = "adguard" LOGGER = logging.getLogger(__package__) -DATA_ADGUARD_CLIENT = "adguard_client" -DATA_ADGUARD_VERSION = "adguard_version" - CONF_FORCE = "force" SERVICE_ADD_URL = "add_url" diff --git a/homeassistant/components/adguard/entity.py b/homeassistant/components/adguard/entity.py index 8cb71a861e8..a4e16f1b995 100644 --- a/homeassistant/components/adguard/entity.py +++ b/homeassistant/components/adguard/entity.py @@ -2,13 +2,14 @@ from __future__ import annotations -from adguardhome import AdGuardHome, AdGuardHomeError +from adguardhome import AdGuardHomeError from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity -from .const import DATA_ADGUARD_VERSION, DOMAIN, LOGGER +from . import AdGuardData +from .const import DOMAIN, LOGGER class AdGuardHomeEntity(Entity): @@ -19,12 +20,13 @@ class AdGuardHomeEntity(Entity): def __init__( self, - adguard: AdGuardHome, + data: AdGuardData, entry: ConfigEntry, ) -> None: """Initialize the AdGuard Home entity.""" self._entry = entry - self.adguard = adguard + self.data = data + self.adguard = data.client async def async_update(self) -> None: """Update AdGuard Home entity.""" @@ -68,8 +70,6 @@ class AdGuardHomeEntity(Entity): }, manufacturer="AdGuard Team", name="AdGuard Home", - sw_version=self.hass.data[DOMAIN][self._entry.entry_id].get( - DATA_ADGUARD_VERSION - ), + sw_version=self.data.version, configuration_url=config_url, ) diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index 1e95a07bffa..ce112f49531 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -7,16 +7,16 @@ from dataclasses import dataclass from datetime import timedelta from typing import Any -from adguardhome import AdGuardHome, AdGuardHomeConnectionError +from adguardhome import AdGuardHome from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERSION, DOMAIN +from . import AdGuardData +from .const import DOMAIN from .entity import AdGuardHomeEntity SCAN_INTERVAL = timedelta(seconds=300) @@ -89,17 +89,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up AdGuard Home sensor based on a config entry.""" - adguard = hass.data[DOMAIN][entry.entry_id][DATA_ADGUARD_CLIENT] - - try: - version = await adguard.version() - except AdGuardHomeConnectionError as exception: - raise PlatformNotReady from exception - - hass.data[DOMAIN][entry.entry_id][DATA_ADGUARD_VERSION] = version + data: AdGuardData = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [AdGuardHomeSensor(adguard, entry, description) for description in SENSORS], + [AdGuardHomeSensor(data, entry, description) for description in SENSORS], True, ) @@ -111,18 +104,18 @@ class AdGuardHomeSensor(AdGuardHomeEntity, SensorEntity): def __init__( self, - adguard: AdGuardHome, + data: AdGuardData, entry: ConfigEntry, description: AdGuardHomeEntityDescription, ) -> None: """Initialize AdGuard Home sensor.""" - super().__init__(adguard, entry) + super().__init__(data, entry) self.entity_description = description self._attr_unique_id = "_".join( [ DOMAIN, - adguard.host, - str(adguard.port), + self.adguard.host, + str(self.adguard.port), "sensor", description.key, ] diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py index ae4bee85d23..e084ed2f349 100644 --- a/homeassistant/components/adguard/switch.py +++ b/homeassistant/components/adguard/switch.py @@ -7,15 +7,15 @@ from dataclasses import dataclass from datetime import timedelta from typing import Any -from adguardhome import AdGuardHome, AdGuardHomeConnectionError, AdGuardHomeError +from adguardhome import AdGuardHome, AdGuardHomeError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERSION, DOMAIN, LOGGER +from . import AdGuardData +from .const import DOMAIN, LOGGER from .entity import AdGuardHomeEntity SCAN_INTERVAL = timedelta(seconds=10) @@ -83,17 +83,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up AdGuard Home switch based on a config entry.""" - adguard = hass.data[DOMAIN][entry.entry_id][DATA_ADGUARD_CLIENT] - - try: - version = await adguard.version() - except AdGuardHomeConnectionError as exception: - raise PlatformNotReady from exception - - hass.data[DOMAIN][entry.entry_id][DATA_ADGUARD_VERSION] = version + data: AdGuardData = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [AdGuardHomeSwitch(adguard, entry, description) for description in SWITCHES], + [AdGuardHomeSwitch(data, entry, description) for description in SWITCHES], True, ) @@ -105,15 +98,21 @@ class AdGuardHomeSwitch(AdGuardHomeEntity, SwitchEntity): def __init__( self, - adguard: AdGuardHome, + data: AdGuardData, entry: ConfigEntry, description: AdGuardHomeSwitchEntityDescription, ) -> None: """Initialize AdGuard Home switch.""" - super().__init__(adguard, entry) + super().__init__(data, entry) self.entity_description = description self._attr_unique_id = "_".join( - [DOMAIN, adguard.host, str(adguard.port), "switch", description.key] + [ + DOMAIN, + self.adguard.host, + str(self.adguard.port), + "switch", + description.key, + ] ) async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/airthings_ble/__init__.py b/homeassistant/components/airthings_ble/__init__.py index e8a2d492ae2..39617a8a019 100644 --- a/homeassistant/components/airthings_ble/__init__.py +++ b/homeassistant/components/airthings_ble/__init__.py @@ -44,10 +44,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_method() -> AirthingsDevice: """Get data from Airthings BLE.""" - ble_device = bluetooth.async_ble_device_from_address(hass, address) - try: - data = await airthings.update_device(ble_device) # type: ignore[arg-type] + data = await airthings.update_device(ble_device) except Exception as err: raise UpdateFailed(f"Unable to fetch data: {err}") from err diff --git a/homeassistant/components/airthings_ble/strings.json b/homeassistant/components/airthings_ble/strings.json index 6f17b9a317e..4b38923384a 100644 --- a/homeassistant/components/airthings_ble/strings.json +++ b/homeassistant/components/airthings_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py index c6908b191d7..e53c01e0f81 100644 --- a/homeassistant/components/airzone_cloud/__init__.py +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -16,6 +16,7 @@ from .coordinator import AirzoneUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.SELECT, Platform.SENSOR, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/airzone_cloud/select.py b/homeassistant/components/airzone_cloud/select.py new file mode 100644 index 00000000000..c5c9f664503 --- /dev/null +++ b/homeassistant/components/airzone_cloud/select.py @@ -0,0 +1,124 @@ +"""Support for the Airzone Cloud select.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Final + +from aioairzone_cloud.common import AirQualityMode +from aioairzone_cloud.const import ( + API_AQ_MODE_CONF, + API_VALUE, + AZD_AQ_MODE_CONF, + AZD_ZONES, +) + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator +from .entity import AirzoneEntity, AirzoneZoneEntity + + +@dataclass(frozen=True, kw_only=True) +class AirzoneSelectDescription(SelectEntityDescription): + """Class to describe an Airzone select entity.""" + + api_param: str + options_dict: dict[str, str] + + +AIR_QUALITY_MAP: Final[dict[str, str]] = { + "off": AirQualityMode.OFF, + "on": AirQualityMode.ON, + "auto": AirQualityMode.AUTO, +} + + +ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( + AirzoneSelectDescription( + api_param=API_AQ_MODE_CONF, + entity_category=EntityCategory.CONFIG, + key=AZD_AQ_MODE_CONF, + options=list(AIR_QUALITY_MAP), + options_dict=AIR_QUALITY_MAP, + translation_key="air_quality", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Airzone Cloud select from a config_entry.""" + coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + # Zones + async_add_entities( + AirzoneZoneSelect( + coordinator, + description, + zone_id, + zone_data, + ) + for description in ZONE_SELECT_TYPES + for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items() + if description.key in zone_data + ) + + +class AirzoneBaseSelect(AirzoneEntity, SelectEntity): + """Define an Airzone Cloud select.""" + + entity_description: AirzoneSelectDescription + values_dict: dict[str, str] + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + def _get_current_option(self) -> str | None: + """Get current selected option.""" + value = self.get_airzone_value(self.entity_description.key) + return self.values_dict.get(value) + + @callback + def _async_update_attrs(self) -> None: + """Update select attributes.""" + self._attr_current_option = self._get_current_option() + + +class AirzoneZoneSelect(AirzoneZoneEntity, AirzoneBaseSelect): + """Define an Airzone Cloud Zone select.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: AirzoneSelectDescription, + zone_id: str, + zone_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator, zone_id, zone_data) + + self._attr_unique_id = f"{zone_id}_{description.key}" + self.entity_description = description + self.values_dict = {v: k for k, v in description.options_dict.items()} + + self._async_update_attrs() + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + param = self.entity_description.api_param + value = self.entity_description.options_dict[option] + params: dict[str, Any] = {} + params[param] = { + API_VALUE: value, + } + await self._async_update_params(params) diff --git a/homeassistant/components/airzone_cloud/strings.json b/homeassistant/components/airzone_cloud/strings.json index fe7c38c8374..fe9455aa69e 100644 --- a/homeassistant/components/airzone_cloud/strings.json +++ b/homeassistant/components/airzone_cloud/strings.json @@ -21,6 +21,16 @@ "air_quality_active": { "name": "Air Quality active" } + }, + "select": { + "air_quality": { + "name": "Air Quality mode", + "state": { + "off": "Off", + "on": "On", + "auto": "Auto" + } + } } } } diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index bc9b482109f..df32220895d 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -300,6 +300,10 @@ class Alexa(AlexaCapability): The API suggests you should explicitly include this interface. https://developer.amazon.com/docs/device-apis/alexa-interface.html + + To compare current supported locales in Home Assistant + with Alexa supported locales, run the following script: + python -m script.alexa_locales """ supported_locales = { diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index fb589dde566..0801a32a607 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -13,6 +13,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.storage import Store from .const import DOMAIN +from .entities import TRANSLATION_TABLE from .state_report import async_enable_proactive_mode STORE_AUTHORIZED = "authorized" @@ -101,6 +102,10 @@ class AbstractConfig(ABC): """If an entity should be exposed.""" return False + def generate_alexa_id(self, entity_id: str) -> str: + """Return the alexa ID for an entity ID.""" + return entity_id.replace(".", "#").translate(TRANSLATION_TABLE) + @callback def async_invalidate_access_token(self) -> None: """Invalidate access token.""" diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 240f676b5f3..ca7b78f7ff5 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -259,11 +259,6 @@ class DisplayCategory: WEARABLE = "WEARABLE" -def generate_alexa_id(entity_id: str) -> str: - """Return the alexa ID for an entity ID.""" - return entity_id.replace(".", "#").translate(TRANSLATION_TABLE) - - class AlexaEntity: """An adaptation of an entity, expressed in Alexa's terms. @@ -298,7 +293,7 @@ class AlexaEntity: def alexa_id(self) -> str: """Return the Alexa API entity id.""" - return generate_alexa_id(self.entity.entity_id) + return self.config.generate_alexa_id(self.entity.entity_id) def display_categories(self) -> list[str] | None: """Return a list of display categories.""" diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 24d750e7cb7..dc6c8ee3186 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -41,7 +41,7 @@ from .const import ( Cause, ) from .diagnostics import async_redact_auth_data -from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id +from .entities import ENTITY_ADAPTERS, AlexaEntity from .errors import AlexaInvalidEndpointError, NoTokenAvailable, RequireRelink if TYPE_CHECKING: @@ -492,7 +492,7 @@ async def async_send_delete_message( if domain not in ENTITY_ADAPTERS: continue - endpoints.append({"endpointId": generate_alexa_id(entity_id)}) + endpoints.append({"endpointId": config.generate_alexa_id(entity_id)}) payload: dict[str, Any] = { "endpoints": endpoints, diff --git a/homeassistant/components/ambient_network/__init__.py b/homeassistant/components/ambient_network/__init__.py new file mode 100644 index 00000000000..b286fb7fbc9 --- /dev/null +++ b/homeassistant/components/ambient_network/__init__.py @@ -0,0 +1,35 @@ +"""The Ambient Weather Network integration.""" + +from __future__ import annotations + +from aioambient.open_api import OpenAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import AmbientNetworkDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the Ambient Weather Network from a config entry.""" + + api = OpenAPI() + coordinator = AmbientNetworkDataUpdateCoordinator(hass, api) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/ambient_network/config_flow.py b/homeassistant/components/ambient_network/config_flow.py new file mode 100644 index 00000000000..d29134db1c9 --- /dev/null +++ b/homeassistant/components/ambient_network/config_flow.py @@ -0,0 +1,152 @@ +"""Config flow for the Ambient Weather Network integration.""" + +from __future__ import annotations + +from typing import Any + +from aioambient import OpenAPI +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_MAC, + CONF_RADIUS, + UnitOfLength, +) +from homeassistant.helpers.selector import ( + LocationSelector, + LocationSelectorConfig, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) +from homeassistant.util.unit_conversion import DistanceConverter + +from .const import API_STATION_INDOOR, API_STATION_INFO, API_STATION_MAC_ADDRESS, DOMAIN +from .helper import get_station_name + +CONF_USER = "user" +CONF_STATION = "station" + +# One mile +CONF_RADIUS_DEFAULT = 1609.34 + + +class AmbientNetworkConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the config flow for the Ambient Weather Network integration.""" + + VERSION = 1 + + def __init__(self) -> None: + """Construct the config flow.""" + + self._longitude = 0.0 + self._latitude = 0.0 + self._radius = 0.0 + self._stations: dict[str, dict[str, Any]] = {} + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle the initial step to select the location.""" + + errors: dict[str, str] | None = None + if user_input: + self._latitude = user_input[CONF_LOCATION][CONF_LATITUDE] + self._longitude = user_input[CONF_LOCATION][CONF_LONGITUDE] + self._radius = user_input[CONF_LOCATION][CONF_RADIUS] + + client: OpenAPI = OpenAPI() + self._stations = { + x[API_STATION_MAC_ADDRESS]: x + for x in await client.get_devices_by_location( + self._latitude, + self._longitude, + radius=DistanceConverter.convert( + self._radius, + UnitOfLength.METERS, + UnitOfLength.MILES, + ), + ) + } + + # Filter out indoor stations + self._stations = dict( + filter( + lambda item: not item[1] + .get(API_STATION_INFO, {}) + .get(API_STATION_INDOOR, False), + self._stations.items(), + ) + ) + + if self._stations: + return await self.async_step_station() + + errors = {"base": "no_stations_found"} + + schema: vol.Schema = self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required( + CONF_LOCATION, + ): LocationSelector(LocationSelectorConfig(radius=True)), + } + ), + { + CONF_LOCATION: { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + CONF_RADIUS: CONF_RADIUS_DEFAULT, + } + if not errors + else { + CONF_LATITUDE: self._latitude, + CONF_LONGITUDE: self._longitude, + CONF_RADIUS: self._radius, + } + }, + ) + + return self.async_show_form( + step_id=CONF_USER, data_schema=schema, errors=errors if errors else {} + ) + + async def async_step_station( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the second step to select the station.""" + + if user_input: + mac_address = user_input[CONF_STATION] + await self.async_set_unique_id(mac_address) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=get_station_name(self._stations[mac_address]), + data={CONF_MAC: mac_address}, + ) + + options: list[SelectOptionDict] = [ + SelectOptionDict( + label=get_station_name(station), + value=mac_address, + ) + for mac_address, station in self._stations.items() + ] + + schema: vol.Schema = vol.Schema( + { + vol.Required(CONF_STATION): SelectSelector( + SelectSelectorConfig(options=options, multiple=False, sort=True), + ) + } + ) + + return self.async_show_form( + step_id=CONF_STATION, + data_schema=schema, + ) diff --git a/homeassistant/components/ambient_network/const.py b/homeassistant/components/ambient_network/const.py new file mode 100644 index 00000000000..402e5f81097 --- /dev/null +++ b/homeassistant/components/ambient_network/const.py @@ -0,0 +1,16 @@ +"""Constants for the Ambient Weather Network integration.""" + +import logging + +DOMAIN = "ambient_network" + +API_LAST_DATA = "lastData" +API_STATION_COORDS = "coords" +API_STATION_INDOOR = "indoor" +API_STATION_INFO = "info" +API_STATION_LOCATION = "location" +API_STATION_NAME = "name" +API_STATION_MAC_ADDRESS = "macAddress" +API_STATION_TYPE = "stationtype" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/ambient_network/coordinator.py b/homeassistant/components/ambient_network/coordinator.py new file mode 100644 index 00000000000..f26ddd47b24 --- /dev/null +++ b/homeassistant/components/ambient_network/coordinator.py @@ -0,0 +1,65 @@ +"""DataUpdateCoordinator for the Ambient Weather Network integration.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, cast + +from aioambient import OpenAPI +from aioambient.errors import RequestError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import API_LAST_DATA, DOMAIN, LOGGER +from .helper import get_station_name + +SCAN_INTERVAL = timedelta(minutes=5) + + +class AmbientNetworkDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """The Ambient Network Data Update Coordinator.""" + + config_entry: ConfigEntry + station_name: str + + def __init__(self, hass: HomeAssistant, api: OpenAPI) -> None: + """Initialize the coordinator.""" + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + self.api = api + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch the latest data from the Ambient Network.""" + + try: + response = await self.api.get_device_details( + self.config_entry.data[CONF_MAC] + ) + except RequestError as ex: + raise UpdateFailed("Cannot connect to Ambient Network") from ex + + self.station_name = get_station_name(response) + + if (last_data := response.get(API_LAST_DATA)) is None: + raise UpdateFailed( + f"Station '{self.config_entry.title}' did not report any data" + ) + + # Eliminate data if the station hasn't been updated for a while. + if (created_at := last_data.get("created_at")) is None: + raise UpdateFailed( + f"Station '{self.config_entry.title}' did not report a time stamp" + ) + + # Eliminate data that has been generated more than an hour ago. The station is + # probably offline. + if int(created_at / 1000) < int( + (datetime.now() - timedelta(hours=1)).timestamp() + ): + raise UpdateFailed( + f"Station '{self.config_entry.title}' reported stale data" + ) + + return cast(dict[str, Any], last_data) diff --git a/homeassistant/components/ambient_network/entity.py b/homeassistant/components/ambient_network/entity.py new file mode 100644 index 00000000000..ad0241ea3de --- /dev/null +++ b/homeassistant/components/ambient_network/entity.py @@ -0,0 +1,50 @@ +"""Base entity class for the Ambient Weather Network integration.""" + +from __future__ import annotations + +from abc import abstractmethod + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AmbientNetworkDataUpdateCoordinator + + +class AmbientNetworkEntity(CoordinatorEntity[AmbientNetworkDataUpdateCoordinator]): + """Entity class for Ambient network devices.""" + + _attr_attribution = "Data provided by ambientnetwork.net" + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AmbientNetworkDataUpdateCoordinator, + description: EntityDescription, + mac_address: str, + ) -> None: + """Initialize the Ambient network entity.""" + + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{mac_address}_{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name=coordinator.station_name, + identifiers={(DOMAIN, mac_address)}, + manufacturer="Ambient Weather", + ) + self._update_attrs() + + @abstractmethod + def _update_attrs(self) -> None: + """Update state attributes.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Get the latest data and updates the state.""" + + self._update_attrs() + super()._handle_coordinator_update() diff --git a/homeassistant/components/ambient_network/helper.py b/homeassistant/components/ambient_network/helper.py new file mode 100644 index 00000000000..fbde45ee756 --- /dev/null +++ b/homeassistant/components/ambient_network/helper.py @@ -0,0 +1,31 @@ +"""Helper class for the Ambient Weather Network integration.""" + +from __future__ import annotations + +from typing import Any + +from .const import ( + API_LAST_DATA, + API_STATION_COORDS, + API_STATION_INFO, + API_STATION_LOCATION, + API_STATION_NAME, + API_STATION_TYPE, +) + + +def get_station_name(station: dict[str, Any]) -> str: + """Pick a station name. + + Station names can be empty, in which case we construct the name from + the location and device type. + """ + if name := station.get(API_STATION_INFO, {}).get(API_STATION_NAME): + return str(name) + location = ( + station.get(API_STATION_INFO, {}) + .get(API_STATION_COORDS, {}) + .get(API_STATION_LOCATION) + ) + station_type = station.get(API_LAST_DATA, {}).get(API_STATION_TYPE) + return f"{location}{'' if location is None or station_type is None else ' '}{station_type}" diff --git a/homeassistant/components/ambient_network/icons.json b/homeassistant/components/ambient_network/icons.json new file mode 100644 index 00000000000..a7abebce187 --- /dev/null +++ b/homeassistant/components/ambient_network/icons.json @@ -0,0 +1,21 @@ +{ + "entity": { + "sensor": { + "last_rain": { + "default": "mdi:water" + }, + "lightning_strikes_per_day": { + "default": "mdi:lightning-bolt" + }, + "lightning_strikes_per_hour": { + "default": "mdi:lightning-bolt" + }, + "lightning_distance": { + "default": "mdi:lightning-bolt" + }, + "wind_direction": { + "default": "mdi:compass-outline" + } + } + } +} diff --git a/homeassistant/components/ambient_network/manifest.json b/homeassistant/components/ambient_network/manifest.json new file mode 100644 index 00000000000..553adb240b0 --- /dev/null +++ b/homeassistant/components/ambient_network/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ambient_network", + "name": "Ambient Weather Network", + "codeowners": ["@thomaskistler"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ambient_network", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["aioambient"], + "requirements": ["aioambient==2024.01.0"] +} diff --git a/homeassistant/components/ambient_network/sensor.py b/homeassistant/components/ambient_network/sensor.py new file mode 100644 index 00000000000..c28b69229d8 --- /dev/null +++ b/homeassistant/components/ambient_network/sensor.py @@ -0,0 +1,315 @@ +"""Support for Ambient Weather Network sensors.""" + +from __future__ import annotations + +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + CONF_MAC, + DEGREE, + PERCENTAGE, + UnitOfIrradiance, + UnitOfLength, + UnitOfPrecipitationDepth, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfVolumetricFlux, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .coordinator import AmbientNetworkDataUpdateCoordinator +from .entity import AmbientNetworkEntity + +TYPE_AQI_PM25 = "aqi_pm25" +TYPE_AQI_PM25_24H = "aqi_pm25_24h" +TYPE_BAROMABSIN = "baromabsin" +TYPE_BAROMRELIN = "baromrelin" +TYPE_CO2 = "co2" +TYPE_DAILYRAININ = "dailyrainin" +TYPE_DEWPOINT = "dewPoint" +TYPE_EVENTRAININ = "eventrainin" +TYPE_FEELSLIKE = "feelsLike" +TYPE_HOURLYRAININ = "hourlyrainin" +TYPE_HUMIDITY = "humidity" +TYPE_LASTRAIN = "lastRain" +TYPE_LIGHTNING_DISTANCE = "lightning_distance" +TYPE_LIGHTNING_PER_DAY = "lightning_day" +TYPE_LIGHTNING_PER_HOUR = "lightning_hour" +TYPE_MAXDAILYGUST = "maxdailygust" +TYPE_MONTHLYRAININ = "monthlyrainin" +TYPE_PM25 = "pm25" +TYPE_PM25_24H = "pm25_24h" +TYPE_SOLARRADIATION = "solarradiation" +TYPE_TEMPF = "tempf" +TYPE_UV = "uv" +TYPE_WEEKLYRAININ = "weeklyrainin" +TYPE_WINDDIR = "winddir" +TYPE_WINDGUSTMPH = "windgustmph" +TYPE_WINDSPEEDMPH = "windspeedmph" +TYPE_YEARLYRAININ = "yearlyrainin" + + +SENSOR_DESCRIPTIONS = ( + SensorEntityDescription( + key=TYPE_AQI_PM25, + translation_key="pm25_aqi", + device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + SensorEntityDescription( + key=TYPE_AQI_PM25_24H, + translation_key="pm25_aqi_24h_average", + device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_BAROMABSIN, + translation_key="absolute_pressure", + native_unit_of_measurement=UnitOfPressure.INHG, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_BAROMRELIN, + translation_key="relative_pressure", + native_unit_of_measurement=UnitOfPressure.INHG, + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + SensorEntityDescription( + key=TYPE_CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_DAILYRAININ, + translation_key="daily_rain", + native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + ), + SensorEntityDescription( + key=TYPE_DEWPOINT, + translation_key="dew_point", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_FEELSLIKE, + translation_key="feels_like", + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_HOURLYRAININ, + translation_key="hourly_rain", + native_unit_of_measurement=UnitOfVolumetricFlux.INCHES_PER_HOUR, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + suggested_display_precision=2, + ), + SensorEntityDescription( + key=TYPE_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_LASTRAIN, + translation_key="last_rain", + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_LIGHTNING_PER_DAY, + translation_key="lightning_strikes_per_day", + native_unit_of_measurement="strikes", + state_class=SensorStateClass.TOTAL, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_LIGHTNING_PER_HOUR, + translation_key="lightning_strikes_per_hour", + native_unit_of_measurement="strikes/hour", + state_class=SensorStateClass.TOTAL, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_LIGHTNING_DISTANCE, + translation_key="lightning_distance", + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_MAXDAILYGUST, + translation_key="max_daily_gust", + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_MONTHLYRAININ, + translation_key="monthly_rain", + native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_PM25_24H, + translation_key="pm25_24h_average", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_SOLARRADIATION, + native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, + device_class=SensorDeviceClass.IRRADIANCE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_TEMPF, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_UV, + translation_key="uv_index", + native_unit_of_measurement="index", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_WEEKLYRAININ, + translation_key="weekly_rain", + native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_WINDDIR, + translation_key="wind_direction", + native_unit_of_measurement=DEGREE, + suggested_display_precision=0, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=TYPE_WINDGUSTMPH, + translation_key="wind_gust", + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_WINDSPEEDMPH, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SensorEntityDescription( + key=TYPE_YEARLYRAININ, + translation_key="yearly_rain", + native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, + device_class=SensorDeviceClass.PRECIPITATION, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Ambient Network sensor entities.""" + + coordinator: AmbientNetworkDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + if coordinator.config_entry is not None: + async_add_entities( + AmbientNetworkSensor( + coordinator, + description, + coordinator.config_entry.data[CONF_MAC], + ) + for description in SENSOR_DESCRIPTIONS + if coordinator.data.get(description.key) is not None + ) + + +class AmbientNetworkSensor(AmbientNetworkEntity, SensorEntity): + """A sensor implementation for an Ambient Weather Network sensor.""" + + def __init__( + self, + coordinator: AmbientNetworkDataUpdateCoordinator, + description: SensorEntityDescription, + mac_address: str, + ) -> None: + """Initialize a sensor object.""" + + super().__init__(coordinator, description, mac_address) + + def _update_attrs(self) -> None: + """Update sensor attributes.""" + + value = self.coordinator.data.get(self.entity_description.key) + + # Treatments for special units. + if value is not None and self.device_class == SensorDeviceClass.TIMESTAMP: + value = datetime.fromtimestamp(value / 1000, tz=dt_util.DEFAULT_TIME_ZONE) + + self._attr_available = value is not None + self._attr_native_value = value diff --git a/homeassistant/components/ambient_network/strings.json b/homeassistant/components/ambient_network/strings.json new file mode 100644 index 00000000000..7d18c40d902 --- /dev/null +++ b/homeassistant/components/ambient_network/strings.json @@ -0,0 +1,87 @@ +{ + "config": { + "step": { + "user": { + "title": "Select region", + "description": "Choose the region you want to survey in order to locate Ambient personal weather stations." + }, + "station": { + "title": "Select station", + "description": "Select the weather station you want to add to Home Assistant.", + "data": { + "station": "Station" + } + } + }, + "error": { + "no_stations_found": "Did not find any stations in the selected region." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "pm25_24h_average": { + "name": "PM2.5 (24 hour average)" + }, + "pm25_aqi": { + "name": "PM2.5 AQI" + }, + "pm25_aqi_24h_average": { + "name": "PM2.5 AQI (24 hour average)" + }, + "absolute_pressure": { + "name": "Absolute pressure" + }, + "relative_pressure": { + "name": "Relative pressure" + }, + "daily_rain": { + "name": "Daily rain" + }, + "dew_point": { + "name": "Dew point" + }, + "feels_like": { + "name": "Feels like" + }, + "hourly_rain": { + "name": "Hourly rain" + }, + "last_rain": { + "name": "Last rain" + }, + "lightning_strikes_per_day": { + "name": "Lightning strikes per day" + }, + "lightning_strikes_per_hour": { + "name": "Lightning strikes per hour" + }, + "lightning_distance": { + "name": "Lightning distance" + }, + "max_daily_gust": { + "name": "Max daily gust" + }, + "monthly_rain": { + "name": "Monthly rain" + }, + "uv_index": { + "name": "UV index" + }, + "weekly_rain": { + "name": "Weekly rain" + }, + "wind_direction": { + "name": "Wind direction" + }, + "wind_gust": { + "name": "Wind gust" + }, + "yearly_rain": { + "name": "Yearly rain" + } + } + } +} diff --git a/homeassistant/components/ambient_station/entity.py b/homeassistant/components/ambient_station/entity.py index a1a81d97c3f..24dfab438d8 100644 --- a/homeassistant/components/ambient_station/entity.py +++ b/homeassistant/components/ambient_station/entity.py @@ -49,7 +49,7 @@ class AmbientWeatherEntity(Entity): last_data = self._ambient.stations[self._mac_address][ATTR_LAST_DATA] key = self.entity_description.key available_key = TYPE_SOLARRADIATION if key == TYPE_SOLARRADIATION_LX else key - self._attr_available = last_data[available_key] is not None + self._attr_available = last_data.get(available_key) is not None self.update_from_latest_data() self.async_write_ha_state() diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index 822a9c3306a..aed2c0ae3f0 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -5,10 +5,14 @@ from collections.abc import Iterable import logging from typing import Any +from pyatv.const import InputAction + from homeassistant.components.remote import ( ATTR_DELAY_SECS, + ATTR_HOLD_SECS, ATTR_NUM_REPEATS, DEFAULT_DELAY_SECS, + DEFAULT_HOLD_SECS, RemoteEntity, ) from homeassistant.config_entries import ConfigEntry @@ -29,7 +33,6 @@ COMMAND_TO_ATTRIBUTE = { "turn_off": ("power", "turn_off"), "volume_up": ("audio", "volume_up"), "volume_down": ("audio", "volume_down"), - "home_hold": ("remote_control", "home"), } @@ -66,6 +69,7 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): """Send a command to one device.""" num_repeats = kwargs[ATTR_NUM_REPEATS] delay = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + hold_secs = kwargs.get(ATTR_HOLD_SECS, DEFAULT_HOLD_SECS) if not self.atv: _LOGGER.error("Unable to send commands, not connected to %s", self.name) @@ -84,5 +88,10 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): raise ValueError("Command not found. Exiting sequence") _LOGGER.info("Sending command %s", single_command) - await attr_value() + + if hold_secs >= 1: + await attr_value(action=InputAction.Hold) + else: + await attr_value() + await asyncio.sleep(delay) diff --git a/homeassistant/components/aranet/strings.json b/homeassistant/components/aranet/strings.json index 918cfc1d384..ac8d1907770 100644 --- a/homeassistant/components/aranet/strings.json +++ b/homeassistant/components/aranet/strings.json @@ -11,7 +11,7 @@ "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" } }, - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "error": { "unknown": "[%key:common::config_flow::error::unknown%]" }, diff --git a/homeassistant/components/arris_tg2492lg/device_tracker.py b/homeassistant/components/arris_tg2492lg/device_tracker.py index 4f674a13c0e..3975109e07a 100644 --- a/homeassistant/components/arris_tg2492lg/device_tracker.py +++ b/homeassistant/components/arris_tg2492lg/device_tracker.py @@ -2,6 +2,7 @@ from __future__ import annotations +from aiohttp.client_exceptions import ClientResponseError from arris_tg2492lg import ConnectBox, Device import voluptuous as vol @@ -12,6 +13,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -25,12 +27,21 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( ) -def get_scanner(hass: HomeAssistant, config: ConfigType) -> ArrisDeviceScanner: - """Return the Arris device scanner.""" +async def async_get_scanner( + hass: HomeAssistant, config: ConfigType +) -> ArrisDeviceScanner | None: + """Return the Arris device scanner if successful.""" conf = config[DOMAIN] url = f"http://{conf[CONF_HOST]}" - connect_box = ConnectBox(url, conf[CONF_PASSWORD]) - return ArrisDeviceScanner(connect_box) + websession = async_get_clientsession(hass) + connect_box = ConnectBox(websession, url, conf[CONF_PASSWORD]) + + try: + await connect_box.async_login() + + return ArrisDeviceScanner(connect_box) + except ClientResponseError: + return None class ArrisDeviceScanner(DeviceScanner): @@ -41,22 +52,22 @@ class ArrisDeviceScanner(DeviceScanner): self.connect_box = connect_box self.last_results: list[Device] = [] - def scan_devices(self) -> list[str]: + async def async_scan_devices(self) -> list[str]: """Scan for new devices and return a list with found device IDs.""" - self._update_info() + await self._async_update_info() return [device.mac for device in self.last_results if device.mac] - def get_device_name(self, device: str) -> str | None: + async def async_get_device_name(self, device: str) -> str | None: """Return the name of the given device or None if we don't know.""" return next( (result.hostname for result in self.last_results if result.mac == device), None, ) - def _update_info(self) -> None: + async def _async_update_info(self) -> None: """Ensure the information from the Arris TG2492LG router is up to date.""" - result = self.connect_box.get_connected_devices() + result = await self.connect_box.async_get_connected_devices() last_results: list[Device] = [] mac_addresses: set[str | None] = set() diff --git a/homeassistant/components/arris_tg2492lg/manifest.json b/homeassistant/components/arris_tg2492lg/manifest.json index 0134ea9077d..fa7673b4276 100644 --- a/homeassistant/components/arris_tg2492lg/manifest.json +++ b/homeassistant/components/arris_tg2492lg/manifest.json @@ -2,8 +2,10 @@ "domain": "arris_tg2492lg", "name": "Arris TG2492LG", "codeowners": ["@vanbalken"], + "dependencies": [], "documentation": "https://www.home-assistant.io/integrations/arris_tg2492lg", + "integration_type": "hub", "iot_class": "local_polling", "loggers": ["arris_tg2492lg"], - "requirements": ["arris-tg2492lg==1.2.1"] + "requirements": ["arris-tg2492lg==2.2.0"] } diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index afc8f9aba10..89a2817e236 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -331,17 +331,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_get_blueprints(hass).async_reset_cache() if (conf := await component.async_prepare_reload(skip_reset=True)) is None: return - await _async_process_config(hass, conf, component) + if automation_id := service_call.data.get(CONF_ID): + await _async_process_single_config(hass, conf, component, automation_id) + else: + await _async_process_config(hass, conf, component) hass.bus.async_fire(EVENT_AUTOMATION_RELOADED, context=service_call.context) - reload_helper = ReloadServiceHelper(reload_service_handler) + def reload_targets(service_call: ServiceCall) -> set[str | None]: + if automation_id := service_call.data.get(CONF_ID): + return {automation_id} + return {automation.unique_id for automation in component.entities} + + reload_helper = ReloadServiceHelper(reload_service_handler, reload_targets) async_register_admin_service( hass, DOMAIN, SERVICE_RELOAD, reload_helper.execute_service, - schema=vol.Schema({}), + schema=vol.Schema({vol.Optional(CONF_ID): str}), ) websocket_api.async_register_command(hass, websocket_config) @@ -859,6 +867,7 @@ class AutomationEntityConfig: async def _prepare_automation_config( hass: HomeAssistant, config: ConfigType, + wanted_automation_id: str | None, ) -> list[AutomationEntityConfig]: """Parse configuration and prepare automation entity configuration.""" automation_configs: list[AutomationEntityConfig] = [] @@ -866,6 +875,10 @@ async def _prepare_automation_config( conf: list[ConfigType] = config[DOMAIN] for list_no, config_block in enumerate(conf): + automation_id: str | None = config_block.get(CONF_ID) + if wanted_automation_id is not None and automation_id != wanted_automation_id: + continue + raw_config = cast(AutomationConfig, config_block).raw_config raw_blueprint_inputs = cast(AutomationConfig, config_block).raw_blueprint_inputs validation_failed = cast(AutomationConfig, config_block).validation_failed @@ -1025,7 +1038,7 @@ async def _async_process_config( return automation_matches, config_matches - automation_configs = await _prepare_automation_config(hass, config) + automation_configs = await _prepare_automation_config(hass, config, None) automations: list[BaseAutomationEntity] = list(component.entities) # Find automations and configurations which have matches @@ -1049,6 +1062,41 @@ async def _async_process_config( await component.async_add_entities(entities) +def _automation_matches_config( + automation: BaseAutomationEntity | None, config: AutomationEntityConfig | None +) -> bool: + """Return False if an automation's config has been changed.""" + if not automation: + return False + if not config: + return False + name = _automation_name(config) + return automation.name == name and automation.raw_config == config.raw_config + + +async def _async_process_single_config( + hass: HomeAssistant, + config: dict[str, Any], + component: EntityComponent[BaseAutomationEntity], + automation_id: str, +) -> None: + """Process config and add a single automation.""" + + automation_configs = await _prepare_automation_config(hass, config, automation_id) + automation = next( + (x for x in component.entities if x.unique_id == automation_id), None + ) + automation_config = automation_configs[0] if automation_configs else None + + if _automation_matches_config(automation, automation_config): + return + + if automation: + await automation.async_remove() + entities = await _create_automation_entities(hass, automation_configs) + await component.async_add_entities(entities) + + async def _async_process_if( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> IfAction | None: diff --git a/homeassistant/components/bluemaestro/strings.json b/homeassistant/components/bluemaestro/strings.json index 9dc500980a6..8f84456d3a7 100644 --- a/homeassistant/components/bluemaestro/strings.json +++ b/homeassistant/components/bluemaestro/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 3273080d88b..560fb0663a8 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -53,7 +53,6 @@ from homeassistant.loader import async_get_bluetooth from . import models, passive_update_processor from .api import ( - _get_manager, async_address_present, async_ble_device_from_address, async_discovered_service_info, @@ -130,13 +129,6 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -async def _async_get_adapter_from_address( - hass: HomeAssistant, address: str -) -> str | None: - """Get an adapter by the address.""" - return await _get_manager(hass).async_get_adapter_from_address(address) - - async def _async_start_adapter_discovery( hass: HomeAssistant, manager: HomeAssistantBluetoothManager, @@ -204,6 +196,17 @@ async def _async_start_adapter_discovery( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the bluetooth integration.""" + if platform.system() == "Linux": + # Remove any config entries that are using the default address + # that were created from discovering adapters in a crashed state + # + # DEFAULT_ADDRESS is perfectly valid on MacOS but on + # Linux it means the adapter is not yet configured + # or crashed + for entry in list(hass.config_entries.async_entries(DOMAIN)): + if entry.unique_id == DEFAULT_ADDRESS: + await hass.config_entries.async_remove(entry.entry_id) + bluetooth_adapters = get_adapters() bluetooth_storage = BluetoothStorage(hass) slot_manager = BleakSlotManager() @@ -265,13 +268,19 @@ async def async_discover_adapters( adapters: dict[str, AdapterDetails], ) -> None: """Discover adapters and start flows.""" - if platform.system() == "Windows": + system = platform.system() + if system == "Windows": # We currently do not have a good way to detect if a bluetooth device is # available on Windows. We will just assume that it is not unless they # actively add it. return for adapter, details in adapters.items(): + if system == "Linux" and details[ADAPTER_ADDRESS] == DEFAULT_ADDRESS: + # DEFAULT_ADDRESS is perfectly valid on MacOS but on + # Linux it means the adapter is not yet configured + # or crashed so we should not try to start a flow for it. + continue discovery_flow.async_create_flow( hass, DOMAIN, @@ -303,28 +312,24 @@ async def async_update_device( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry for a bluetooth scanner.""" + manager: HomeAssistantBluetoothManager = hass.data[DATA_MANAGER] address = entry.unique_id assert address is not None - adapter = await _async_get_adapter_from_address(hass, address) + adapter = await manager.async_get_adapter_from_address_or_recover(address) if adapter is None: raise ConfigEntryNotReady( f"Bluetooth adapter {adapter} with address {address} not found" ) - passive = entry.options.get(CONF_PASSIVE) mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE - manager: HomeAssistantBluetoothManager = hass.data[DATA_MANAGER] scanner = HaScanner(mode, adapter, address) + scanner.async_setup() try: - scanner.async_setup() - except RuntimeError as err: + await scanner.async_start() + except (RuntimeError, ScannerStartError) as err: raise ConfigEntryNotReady( f"{adapter_human_name(adapter, address)}: {err}" ) from err - try: - await scanner.async_start() - except ScannerStartError as err: - raise ConfigEntryNotReady from err adapters = await manager.async_get_bluetooth_adapters() details = adapters[adapter] slots: int = details.get(ADAPTER_CONNECTION_SLOTS) or DEFAULT_CONNECTION_SLOTS @@ -332,6 +337,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await async_update_device(hass, entry, adapter, details) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = scanner entry.async_on_unload(entry.add_update_listener(async_update_listener)) + entry.async_on_unload(scanner.async_stop) return True diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index 2b5980fbcd6..87038d48151 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -2,12 +2,16 @@ from __future__ import annotations +import platform from typing import Any, cast from bluetooth_adapters import ( ADAPTER_ADDRESS, + ADAPTER_MANUFACTURER, + DEFAULT_ADDRESS, AdapterDetails, adapter_human_name, + adapter_model, adapter_unique_name, get_adapters, ) @@ -35,6 +39,22 @@ OPTIONS_FLOW = { } +def adapter_display_info(adapter: str, details: AdapterDetails) -> str: + """Return the adapter display info.""" + name = adapter_human_name(adapter, details[ADAPTER_ADDRESS]) + model = adapter_model(details) + manufacturer = details[ADAPTER_MANUFACTURER] or "Unknown" + return f"{name} {manufacturer} {model}" + + +def adapter_title(adapter: str, details: AdapterDetails) -> str: + """Return the adapter title.""" + unique_name = adapter_unique_name(adapter, details[ADAPTER_ADDRESS]) + model = adapter_model(details) + manufacturer = details[ADAPTER_MANUFACTURER] or "Unknown" + return f"{manufacturer} {model} ({unique_name})" + + class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow for Bluetooth.""" @@ -45,6 +65,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): self._adapter: str | None = None self._details: AdapterDetails | None = None self._adapters: dict[str, AdapterDetails] = {} + self._placeholders: dict[str, str] = {} async def async_step_integration_discovery( self, discovery_info: DiscoveryInfoType @@ -54,11 +75,23 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): self._details = cast(AdapterDetails, discovery_info[CONF_DETAILS]) await self.async_set_unique_id(self._details[ADAPTER_ADDRESS]) self._abort_if_unique_id_configured() - self.context["title_placeholders"] = { - "name": adapter_human_name(self._adapter, self._details[ADAPTER_ADDRESS]) - } + details = self._details + self._async_set_adapter_info(self._adapter, details) return await self.async_step_single_adapter() + @callback + def _async_set_adapter_info(self, adapter: str, details: AdapterDetails) -> None: + """Set the adapter info.""" + name = adapter_human_name(adapter, details[ADAPTER_ADDRESS]) + model = adapter_model(details) + manufacturer = details[ADAPTER_MANUFACTURER] + self._placeholders = { + "name": name, + "model": model, + "manufacturer": manufacturer or "Unknown", + } + self.context["title_placeholders"] = self._placeholders + async def async_step_single_adapter( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -67,6 +100,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): details = self._details assert adapter is not None assert details is not None + assert self._placeholders is not None address = details[ADAPTER_ADDRESS] @@ -74,12 +108,12 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(address, raise_on_progress=False) self._abort_if_unique_id_configured() return self.async_create_entry( - title=adapter_unique_name(adapter, address), data={} + title=adapter_title(adapter, details), data={} ) return self.async_show_form( step_id="single_adapter", - description_placeholders={"name": adapter_human_name(adapter, address)}, + description_placeholders=self._placeholders, ) async def async_step_multiple_adapters( @@ -89,21 +123,27 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: assert self._adapters is not None adapter = user_input[CONF_ADAPTER] - address = self._adapters[adapter][ADAPTER_ADDRESS] + details = self._adapters[adapter] + address = details[ADAPTER_ADDRESS] await self.async_set_unique_id(address, raise_on_progress=False) self._abort_if_unique_id_configured() return self.async_create_entry( - title=adapter_unique_name(adapter, address), data={} + title=adapter_title(adapter, details), data={} ) configured_addresses = self._async_current_ids() bluetooth_adapters = get_adapters() await bluetooth_adapters.refresh() self._adapters = bluetooth_adapters.adapters + system = platform.system() unconfigured_adapters = [ adapter for adapter, details in self._adapters.items() if details[ADAPTER_ADDRESS] not in configured_addresses + # DEFAULT_ADDRESS is perfectly valid on MacOS but on + # Linux it means the adapter is not yet configured + # or crashed + and not (system == "Linux" and details[ADAPTER_ADDRESS] == DEFAULT_ADDRESS) ] if not unconfigured_adapters: ignored_adapters = len( @@ -116,6 +156,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): if len(unconfigured_adapters) == 1: self._adapter = list(self._adapters)[0] self._details = self._adapters[self._adapter] + self._async_set_adapter_info(self._adapter, self._details) return await self.async_step_single_adapter() return self.async_show_form( @@ -124,8 +165,8 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN): { vol.Required(CONF_ADAPTER): vol.In( { - adapter: adapter_human_name( - adapter, self._adapters[adapter][ADAPTER_ADDRESS] + adapter: adapter_display_info( + adapter, self._adapters[adapter] ) for adapter in sorted(unconfigured_adapters) } diff --git a/homeassistant/components/bluetooth/diagnostics.py b/homeassistant/components/bluetooth/diagnostics.py index a45500265cf..1c9c9a56b2e 100644 --- a/homeassistant/components/bluetooth/diagnostics.py +++ b/homeassistant/components/bluetooth/diagnostics.py @@ -10,7 +10,7 @@ from bluetooth_adapters import get_dbus_managed_objects from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import _get_manager +from .api import _get_manager async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 58009216464..b41c344bdf2 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -17,9 +17,9 @@ "bleak==0.21.1", "bleak-retry-connector==3.5.0", "bluetooth-adapters==0.18.0", - "bluetooth-auto-recovery==1.4.0", + "bluetooth-auto-recovery==1.4.1", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.1", - "habluetooth==2.4.2" + "habluetooth==2.8.0" ] } diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index 4b168126251..c28bd3cc65e 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "{name}", + "flow_title": "{name} {manufacturer} {model}", "step": { "user": { "description": "Choose a device to set up", @@ -18,7 +18,7 @@ } }, "single_adapter": { - "description": "Do you want to set up the Bluetooth adapter {name}?" + "description": "Do you want to set up the Bluetooth adapter {name} {manufacturer} {model}?" } }, "abort": { diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py index 49990977f71..5374b52e684 100644 --- a/homeassistant/components/bmw_connected_drive/const.py +++ b/homeassistant/components/bmw_connected_drive/const.py @@ -28,3 +28,10 @@ SCAN_INTERVALS = { "north_america": 600, "rest_of_world": 300, } + +CLIMATE_ACTIVITY_STATE: list[str] = [ + "cooling", + "heating", + "inactive", + "standby", +] diff --git a/homeassistant/components/bmw_connected_drive/icons.json b/homeassistant/components/bmw_connected_drive/icons.json index a4eb37b369a..fc30b87ed3f 100644 --- a/homeassistant/components/bmw_connected_drive/icons.json +++ b/homeassistant/components/bmw_connected_drive/icons.json @@ -85,6 +85,9 @@ }, "remaining_fuel_percent": { "default": "mdi:gas-station" + }, + "climate_status": { + "default": "mdi:fan" } }, "switch": { diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index e1ed398cfec..d3366543c55 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import BMWBaseEntity -from .const import DOMAIN, UNIT_MAP +from .const import CLIMATE_ACTIVITY_STATE, DOMAIN, UNIT_MAP from .coordinator import BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -153,6 +153,15 @@ SENSOR_TYPES: list[BMWSensorEntityDescription] = [ state_class=SensorStateClass.MEASUREMENT, is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain, ), + BMWSensorEntityDescription( + key="activity", + translation_key="climate_status", + key_class="climate", + device_class=SensorDeviceClass.ENUM, + options=CLIMATE_ACTIVITY_STATE, + value=lambda x, _: x.lower() if x != "UNKNOWN" else None, + is_available=lambda v: v.is_remote_climate_stop_enabled, + ), ] diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index 69abd97ddfe..539c281a1a5 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -122,6 +122,15 @@ }, "remaining_fuel_percent": { "name": "Remaining fuel percent" + }, + "climate_status": { + "name": "Climate status", + "state": { + "cooling": "Cooling", + "heating": "Heating", + "inactive": "Inactive", + "standby": "Standby" + } } }, "switch": { diff --git a/homeassistant/components/broadlink/const.py b/homeassistant/components/broadlink/const.py index 91d4358a077..41c4964c2b3 100644 --- a/homeassistant/components/broadlink/const.py +++ b/homeassistant/components/broadlink/const.py @@ -9,6 +9,7 @@ DOMAINS_AND_TYPES = { Platform.REMOTE: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"}, Platform.SENSOR: { "A1", + "MP1S", "RM4MINI", "RM4PRO", "RMPRO", @@ -20,6 +21,7 @@ DOMAINS_AND_TYPES = { Platform.SWITCH: { "BG1", "MP1", + "MP1S", "RM4MINI", "RM4PRO", "RMMINI", diff --git a/homeassistant/components/broadlink/manifest.json b/homeassistant/components/broadlink/manifest.json index 7fd925a2ff4..bf5dfb16584 100644 --- a/homeassistant/components/broadlink/manifest.json +++ b/homeassistant/components/broadlink/manifest.json @@ -38,5 +38,5 @@ "documentation": "https://www.home-assistant.io/integrations/broadlink", "iot_class": "local_polling", "loggers": ["broadlink"], - "requirements": ["broadlink==0.18.3"] + "requirements": ["broadlink==0.19.0"] } diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index f8d903c51eb..55368e5ff59 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -373,7 +373,7 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): start_time = dt_util.utcnow() while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) - found = await device.async_request(device.api.check_frequency) + found = await device.async_request(device.api.check_frequency)[0] if found: break else: diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index f61e726b1d5..9cf7e3391fa 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -129,7 +129,7 @@ async def async_setup_entry( elif device.api.type == "BG1": switches.extend(BroadlinkBG1Slot(device, slot) for slot in range(1, 3)) - elif device.api.type == "MP1": + elif device.api.type in {"MP1", "MP1S"}: switches.extend(BroadlinkMP1Slot(device, slot) for slot in range(1, 5)) async_add_entities(switches) diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index 20b241b0d89..f678af0105f 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -21,6 +21,7 @@ def get_update_manager(device): "LB1": BroadlinkLB1UpdateManager, "LB2": BroadlinkLB1UpdateManager, "MP1": BroadlinkMP1UpdateManager, + "MP1S": BroadlinkMP1SUpdateManager, "RM4MINI": BroadlinkRMUpdateManager, "RM4PRO": BroadlinkRMUpdateManager, "RMMINI": BroadlinkRMUpdateManager, @@ -112,6 +113,16 @@ class BroadlinkMP1UpdateManager(BroadlinkUpdateManager): return await self.device.async_request(self.device.api.check_power) +class BroadlinkMP1SUpdateManager(BroadlinkUpdateManager): + """Manages updates for Broadlink MP1 devices.""" + + async def async_fetch_data(self): + """Fetch data from the device.""" + power = await self.device.async_request(self.device.api.check_power) + sensors = await self.device.async_request(self.device.api.get_state) + return {**power, **sensors} + + class BroadlinkRMUpdateManager(BroadlinkUpdateManager): """Manages updates for Broadlink remotes.""" diff --git a/homeassistant/components/bthome/strings.json b/homeassistant/components/bthome/strings.json index 50c5c7bada6..c64028229b3 100644 --- a/homeassistant/components/bthome/strings.json +++ b/homeassistant/components/bthome/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index a5a010c00a6..ccc36dc4430 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -26,7 +26,9 @@ def async_setup(hass: HomeAssistant) -> bool: async def hook(action: str, config_key: str) -> None: """post_write_hook for Config View that reloads automations.""" if action != ACTION_DELETE: - await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + await hass.services.async_call( + DOMAIN, SERVICE_RELOAD, {CONF_ID: config_key} + ) return ent_reg = er.async_get(hass) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 71e89797c05..5c7139d6290 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -480,15 +480,30 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def _get_toggle_function( self, fns: dict[str, Callable[_P, _R]] ) -> Callable[_P, _R]: + # If we are opening or closing and we support stopping, then we should stop if self.supported_features & CoverEntityFeature.STOP and ( self.is_closing or self.is_opening ): return fns["stop"] - if self.is_closed: + + # If we are fully closed or in the process of closing, then we should open + if self.is_closed or self.is_closing: return fns["open"] - if self._cover_is_last_toggle_direction_open: + + # If we are fully open or in the process of opening, then we should close + if self.current_cover_position == 100 or self.is_opening: return fns["close"] - return fns["open"] + + # We are any of: + # * fully open but do not report `current_cover_position` + # * stopped partially open + # * either opening or closing, but do not report them + # If we previously reported opening/closing, we should move in the opposite direction. + # Otherwise, we must assume we are (partially) open and should always close. + # Note: _cover_is_last_toggle_direction_open will always remain True if we never report opening/closing. + return ( + fns["close"] if self._cover_is_last_toggle_direction_open else fns["open"] + ) # These can be removed if no deprecated constant are in this module anymore diff --git a/homeassistant/components/device_sun_light_trigger/__init__.py b/homeassistant/components/device_sun_light_trigger/__init__.py index 861a634eda7..6781b9afaf7 100644 --- a/homeassistant/components/device_sun_light_trigger/__init__.py +++ b/homeassistant/components/device_sun_light_trigger/__init__.py @@ -1,6 +1,7 @@ """Support to turn on lights based on the states.""" from datetime import timedelta +from functools import partial import logging import voluptuous as vol @@ -27,11 +28,11 @@ from homeassistant.const import ( SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import ( async_track_point_in_utc_time, - async_track_state_change, + async_track_state_change_event, ) from homeassistant.helpers.sun import get_astral_event_next, is_up from homeassistant.helpers.typing import ConfigType @@ -195,8 +196,20 @@ async def activate_automation( # noqa: C901 schedule_light_turn_on(None) @callback - def check_light_on_dev_state_change(entity, old_state, new_state): + def check_light_on_dev_state_change( + from_state: str, to_state: str, event: Event[EventStateChangedData] + ) -> None: """Handle tracked device state changes.""" + event_data = event.data + if ( + (old_state := event_data["old_state"]) is None + or (new_state := event_data["new_state"]) is None + or old_state.state != from_state + or new_state.state != to_state + ): + return + + entity = event_data["entity_id"] lights_are_on = any_light_on() light_needed = not (lights_are_on or is_up(hass)) @@ -237,12 +250,10 @@ async def activate_automation( # noqa: C901 # will all the following then, break. break - async_track_state_change( + async_track_state_change_event( hass, device_entity_ids, - check_light_on_dev_state_change, - STATE_NOT_HOME, - STATE_HOME, + partial(check_light_on_dev_state_change, STATE_NOT_HOME, STATE_HOME), ) if disable_turn_off: @@ -266,12 +277,10 @@ async def activate_automation( # noqa: C901 ) ) - async_track_state_change( + async_track_state_change_event( hass, device_entity_ids, - turn_off_lights_when_all_leave, - STATE_HOME, - STATE_NOT_HOME, + partial(turn_off_lights_when_all_leave, STATE_HOME, STATE_NOT_HOME), ) return diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 9d86b127d77..97348c5c43c 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -50,7 +50,7 @@ }, "image": { "image_guest_wifi": { - "name": "Guest Wifi credentials as QR code" + "name": "Guest Wi-Fi credentials as QR code" } }, "sensor": { @@ -58,10 +58,10 @@ "name": "Connected PLC devices" }, "connected_wifi_clients": { - "name": "Connected Wifi clients" + "name": "Connected Wi-Fi clients" }, "neighboring_wifi_networks": { - "name": "Neighboring Wifi networks" + "name": "Neighboring Wi-Fi networks" }, "plc_rx_rate": { "name": "PLC downlink PHY rate" @@ -72,7 +72,7 @@ }, "switch": { "switch_guest_wifi": { - "name": "Enable guest Wifi" + "name": "Enable guest Wi-Fi" }, "switch_leds": { "name": "Enable LEDs" diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index 480f021b126..1fdc7cb359f 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/dsmr_reader/diagnostics.py b/homeassistant/components/dsmr_reader/diagnostics.py new file mode 100644 index 00000000000..554d90cc5dd --- /dev/null +++ b/homeassistant/components/dsmr_reader/diagnostics.py @@ -0,0 +1,27 @@ +"""Diagnostics support for DSMR Reader.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for the config entry.""" + ent_reg = er.async_get(hass) + entities = [ + entity.entity_id + for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id) + ] + + entity_states = {entity: hass.states.get(entity) for entity in entities} + + return { + "entry": entry.as_dict(), + "entities": entity_states, + } diff --git a/homeassistant/components/emulated_hue/manifest.json b/homeassistant/components/emulated_hue/manifest.json index ff3591e0066..14baa5b5d04 100644 --- a/homeassistant/components/emulated_hue/manifest.json +++ b/homeassistant/components/emulated_hue/manifest.json @@ -6,6 +6,5 @@ "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/emulated_hue", "iot_class": "local_push", - "quality_scale": "internal", - "requirements": ["aiohttp_cors==0.7.0"] + "quality_scale": "internal" } diff --git a/homeassistant/components/emulated_roku/manifest.json b/homeassistant/components/emulated_roku/manifest.json index 739f3b04ec0..214658b7c0e 100644 --- a/homeassistant/components/emulated_roku/manifest.json +++ b/homeassistant/components/emulated_roku/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/emulated_roku", "iot_class": "local_push", "loggers": ["emulated_roku"], - "requirements": ["emulated-roku==0.2.1"] + "requirements": ["emulated-roku==0.3.0"] } diff --git a/homeassistant/components/enigma2/__init__.py b/homeassistant/components/enigma2/__init__.py index 11cd4d9a804..241ca7444fb 100644 --- a/homeassistant/components/enigma2/__init__.py +++ b/homeassistant/components/enigma2/__init__.py @@ -1 +1,48 @@ """Support for Enigma2 devices.""" + +from openwebif.api import OpenWebIfDevice +from yarl import URL + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .const import DOMAIN + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Enigma2 from a config entry.""" + base_url = URL.build( + scheme="http" if not entry.data[CONF_SSL] else "https", + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + user=entry.data.get(CONF_USERNAME), + password=entry.data.get(CONF_PASSWORD), + ) + + session = async_create_clientsession( + hass, verify_ssl=entry.data[CONF_VERIFY_SSL], base_url=base_url + ) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = OpenWebIfDevice(session) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/enigma2/config_flow.py b/homeassistant/components/enigma2/config_flow.py new file mode 100644 index 00000000000..ac57bd9d0fa --- /dev/null +++ b/homeassistant/components/enigma2/config_flow.py @@ -0,0 +1,165 @@ +"""Config flow for Enigma2.""" + +from typing import Any + +from aiohttp.client_exceptions import ClientError +from openwebif.api import OpenWebIfDevice +from openwebif.error import InvalidAuthError +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import ( + CONF_DEEP_STANDBY, + CONF_SOURCE_BOUQUET, + CONF_USE_CHANNEL_ICON, + DEFAULT_PORT, + DEFAULT_SSL, + DEFAULT_VERIFY_SSL, + DOMAIN, +) + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): selector.TextSelector(), + vol.Required(CONF_PORT, default=DEFAULT_PORT): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=65535, mode=selector.NumberSelectorMode.BOX + ) + ), + vol.Coerce(int), + ), + vol.Optional(CONF_USERNAME): selector.TextSelector(), + vol.Optional(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Required(CONF_SSL, default=DEFAULT_SSL): selector.BooleanSelector(), + vol.Required( + CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL + ): selector.BooleanSelector(), + } +) + + +class Enigma2ConfigFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Enigma2.""" + + DATA_KEYS = ( + CONF_HOST, + CONF_PORT, + CONF_USERNAME, + CONF_PASSWORD, + CONF_SSL, + CONF_VERIFY_SSL, + ) + OPTIONS_KEYS = (CONF_DEEP_STANDBY, CONF_SOURCE_BOUQUET, CONF_USE_CHANNEL_ICON) + + async def validate_user_input( + self, user_input: dict[str, Any] + ) -> dict[str, str] | None: + """Validate user input.""" + + errors = None + + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + + base_url = URL.build( + scheme="http" if not user_input[CONF_SSL] else "https", + host=user_input[CONF_HOST], + port=user_input[CONF_PORT], + user=user_input.get(CONF_USERNAME), + password=user_input.get(CONF_PASSWORD), + ) + + session = async_create_clientsession( + self.hass, verify_ssl=user_input[CONF_VERIFY_SSL], base_url=base_url + ) + + try: + about = await OpenWebIfDevice(session).get_about() + except InvalidAuthError: + errors = {"base": "invalid_auth"} + except ClientError: + errors = {"base": "cannot_connect"} + except Exception: # pylint: disable=broad-except + errors = {"base": "unknown"} + else: + await self.async_set_unique_id(about["info"]["ifaces"][0]["mac"]) + self._abort_if_unique_id_configured() + + return errors + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + if user_input is None: + return self.async_show_form(step_id=SOURCE_USER, data_schema=CONFIG_SCHEMA) + + if errors := await self.validate_user_input(user_input): + return self.async_show_form( + step_id=SOURCE_USER, data_schema=CONFIG_SCHEMA, errors=errors + ) + return self.async_create_entry(data=user_input, title=user_input[CONF_HOST]) + + async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult: + """Handle the import step.""" + if CONF_PORT not in user_input: + user_input[CONF_PORT] = DEFAULT_PORT + if CONF_SSL not in user_input: + user_input[CONF_SSL] = DEFAULT_SSL + user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL + + data = {key: user_input[key] for key in user_input if key in self.DATA_KEYS} + options = { + key: user_input[key] for key in user_input if key in self.OPTIONS_KEYS + } + + if errors := await self.validate_user_input(user_input): + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_yaml_{DOMAIN}_import_issue_{errors["base"]}", + breaks_in_ha_version="2024.11.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{errors["base"]}", + translation_placeholders={ + "url": "/config/integrations/dashboard/add?domain=enigma2" + }, + ) + return self.async_abort(reason=errors["base"]) + + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.11.0", + is_fixable=False, + is_persistent=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Enigma2", + }, + ) + return self.async_create_entry( + data=data, title=data[CONF_HOST], options=options + ) diff --git a/homeassistant/components/enigma2/const.py b/homeassistant/components/enigma2/const.py index 277efad50eb..d7508fee64e 100644 --- a/homeassistant/components/enigma2/const.py +++ b/homeassistant/components/enigma2/const.py @@ -16,3 +16,4 @@ DEFAULT_PASSWORD = "dreambox" DEFAULT_DEEP_STANDBY = False DEFAULT_SOURCE_BOUQUET = "" DEFAULT_MAC_ADDRESS = "" +DEFAULT_VERIFY_SSL = False diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json index 0de4adc13b8..ef08314e541 100644 --- a/homeassistant/components/enigma2/manifest.json +++ b/homeassistant/components/enigma2/manifest.json @@ -2,7 +2,9 @@ "domain": "enigma2", "name": "Enigma2 (OpenWebif)", "codeowners": ["@autinerd"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/enigma2", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["openwebif"], "requirements": ["openwebifpy==4.2.4"] diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index afe8a426c72..037d82cd6c0 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -9,7 +9,6 @@ from aiohttp.client_exceptions import ClientConnectorError, ServerDisconnectedEr from openwebif.api import OpenWebIfDevice from openwebif.enums import PowerState, RemoteControlCodes, SetVolumeOption import voluptuous as vol -from yarl import URL from homeassistant.components.media_player import ( MediaPlayerEntity, @@ -17,6 +16,7 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -26,10 +26,9 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -47,6 +46,7 @@ from .const import ( DEFAULT_SSL, DEFAULT_USE_CHANNEL_ICON, DEFAULT_USERNAME, + DOMAIN, ) ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" @@ -81,49 +81,44 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up of an enigma2 media player.""" - if discovery_info: - # Discovery gives us the streaming service port (8001) - # which is not useful as OpenWebif never runs on that port. - # So use the default port instead. - config[CONF_PORT] = DEFAULT_PORT - config[CONF_NAME] = discovery_info["hostname"] - config[CONF_HOST] = discovery_info["host"] - config[CONF_USERNAME] = DEFAULT_USERNAME - config[CONF_PASSWORD] = DEFAULT_PASSWORD - config[CONF_SSL] = DEFAULT_SSL - config[CONF_USE_CHANNEL_ICON] = DEFAULT_USE_CHANNEL_ICON - config[CONF_MAC_ADDRESS] = DEFAULT_MAC_ADDRESS - config[CONF_DEEP_STANDBY] = DEFAULT_DEEP_STANDBY - config[CONF_SOURCE_BOUQUET] = DEFAULT_SOURCE_BOUQUET - base_url = URL.build( - scheme="https" if config[CONF_SSL] else "http", - host=config[CONF_HOST], - port=config.get(CONF_PORT), - user=config.get(CONF_USERNAME), - password=config.get(CONF_PASSWORD), + entry_data = { + CONF_HOST: config[CONF_HOST], + CONF_PORT: config[CONF_PORT], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_SSL: config[CONF_SSL], + CONF_USE_CHANNEL_ICON: config[CONF_USE_CHANNEL_ICON], + CONF_DEEP_STANDBY: config[CONF_DEEP_STANDBY], + CONF_SOURCE_BOUQUET: config[CONF_SOURCE_BOUQUET], + } + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry_data + ) ) - session = async_create_clientsession(hass, verify_ssl=False, base_url=base_url) - device = OpenWebIfDevice( - host=session, - turn_off_to_deep=config.get(CONF_DEEP_STANDBY, False), - source_bouquet=config.get(CONF_SOURCE_BOUQUET), - ) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Enigma2 media player platform.""" - try: - about = await device.get_about() - except ClientConnectorError as err: - raise PlatformNotReady from err - - async_add_entities([Enigma2Device(config[CONF_NAME], device, about)]) + device: OpenWebIfDevice = hass.data[DOMAIN][entry.entry_id] + about = await device.get_about() + device.mac_address = about["info"]["ifaces"][0]["mac"] + entity = Enigma2Device(entry, device, about) + async_add_entities([entity]) class Enigma2Device(MediaPlayerEntity): """Representation of an Enigma2 box.""" _attr_has_entity_name = True + _attr_name = None _attr_media_content_type = MediaType.TVSHOW _attr_supported_features = ( @@ -139,14 +134,23 @@ class Enigma2Device(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, name: str, device: OpenWebIfDevice, about: dict) -> None: + def __init__( + self, entry: ConfigEntry, device: OpenWebIfDevice, about: dict + ) -> None: """Initialize the Enigma2 device.""" self._device: OpenWebIfDevice = device - self._device.mac_address = about["info"]["ifaces"][0]["mac"] + self._entry = entry - self._attr_name = name self._attr_unique_id = device.mac_address + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.mac_address)}, + manufacturer=about["info"]["brand"], + model=about["info"]["model"], + configuration_url=device.base, + name=entry.data[CONF_HOST], + ) + async def async_turn_off(self) -> None: """Turn off media player.""" if self._device.turn_off_to_deep: diff --git a/homeassistant/components/enigma2/strings.json b/homeassistant/components/enigma2/strings.json new file mode 100644 index 00000000000..ddeb59ea6d5 --- /dev/null +++ b/homeassistant/components/enigma2/strings.json @@ -0,0 +1,43 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "Please enter the connection details of your device.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_unknown": { + "title": "The Enigma2 YAML configuration import failed", + "description": "Configuring Enigma2 using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure connection to the device works, the authentication details are correct and restart Home Assistant to try again or remove the Enigma2 YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_invalid_auth": { + "title": "The Enigma2 YAML configuration import failed", + "description": "Configuring Enigma2 using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure the authentication details are correct and restart Home Assistant to try again or remove the Enigma2 YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Enigma2 YAML configuration import failed", + "description": "Configuring Enigma2 using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure connection to the device works and restart Home Assistant to try again or remove the Enigma2 YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 005963db872..52dc1f17ad6 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -36,6 +36,7 @@ from aioesphomeapi import ( TextSensorInfo, TimeInfo, UserService, + ValveInfo, build_unique_id, ) from aioesphomeapi.model import ButtonInfo @@ -78,6 +79,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = { TextInfo: Platform.TEXT, TextSensorInfo: Platform.SENSOR, TimeInfo: Platform.TIME, + ValveInfo: Platform.VALVE, } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 4d5636a6f26..e700dddbb96 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "requirements": [ - "aioesphomeapi==24.0.0", + "aioesphomeapi==24.1.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==1.0.0" ], diff --git a/homeassistant/components/esphome/valve.py b/homeassistant/components/esphome/valve.py new file mode 100644 index 00000000000..5798d38803f --- /dev/null +++ b/homeassistant/components/esphome/valve.py @@ -0,0 +1,103 @@ +"""Support for ESPHome valves.""" + +from __future__ import annotations + +from typing import Any + +from aioesphomeapi import EntityInfo, ValveInfo, ValveOperation, ValveState + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.enum import try_parse_enum + +from .entity import ( + EsphomeEntity, + convert_api_error_ha_error, + esphome_state_property, + platform_async_setup_entry, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up ESPHome valves based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + info_type=ValveInfo, + entity_type=EsphomeValve, + state_type=ValveState, + ) + + +class EsphomeValve(EsphomeEntity[ValveInfo, ValveState], ValveEntity): + """A valve implementation for ESPHome.""" + + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info + flags = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + if static_info.supports_stop: + flags |= ValveEntityFeature.STOP + if static_info.supports_position: + flags |= ValveEntityFeature.SET_POSITION + self._attr_supported_features = flags + self._attr_device_class = try_parse_enum( + ValveDeviceClass, static_info.device_class + ) + self._attr_assumed_state = static_info.assumed_state + self._attr_reports_position = static_info.supports_position + + @property + @esphome_state_property + def is_closed(self) -> bool: + """Return if the valve is closed or not.""" + return self._state.position == 0.0 + + @property + @esphome_state_property + def is_opening(self) -> bool: + """Return if the valve is opening or not.""" + return self._state.current_operation is ValveOperation.IS_OPENING + + @property + @esphome_state_property + def is_closing(self) -> bool: + """Return if the valve is closing or not.""" + return self._state.current_operation is ValveOperation.IS_CLOSING + + @property + @esphome_state_property + def current_valve_position(self) -> int | None: + """Return current position of valve. 0 is closed, 100 is open.""" + return round(self._state.position * 100.0) + + @convert_api_error_ha_error + async def async_open_valve(self, **kwargs: Any) -> None: + """Open the valve.""" + self._client.valve_command(key=self._key, position=1.0) + + @convert_api_error_ha_error + async def async_close_valve(self, **kwargs: Any) -> None: + """Close valve.""" + self._client.valve_command(key=self._key, position=0.0) + + @convert_api_error_ha_error + async def async_stop_valve(self, **kwargs: Any) -> None: + """Stop the valve.""" + self._client.valve_command(key=self._key, stop=True) + + @convert_api_error_ha_error + async def async_set_valve_position(self, position: float) -> None: + """Move the valve to a specific position.""" + self._client.valve_command(key=self._key, position=position / 100) diff --git a/homeassistant/components/eufylife_ble/strings.json b/homeassistant/components/eufylife_ble/strings.json index aaeeeb85f67..72f0e7b5973 100644 --- a/homeassistant/components/eufylife_ble/strings.json +++ b/homeassistant/components/eufylife_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/govee_ble/strings.json b/homeassistant/components/govee_ble/strings.json index 4003debbbeb..4e12a84b653 100644 --- a/homeassistant/components/govee_ble/strings.json +++ b/homeassistant/components/govee_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index 70fc024c005..b32e5ebcd53 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -2,7 +2,7 @@ "domain": "hassio", "name": "Home Assistant Supervisor", "codeowners": ["@home-assistant/supervisor"], - "dependencies": ["http"], + "dependencies": ["http", "repairs"], "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal" diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 63c1da4bfd8..6abf9ca6334 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -52,14 +52,14 @@ "fix_flow": { "step": { "fix_menu": { - "description": "`{reference}` is a filesystem with the name hassos-data and is not the active data disk. This can cause Home Assistant to choose the wrong data disk at system reboot.\n\nUse the 'Rename' option to rename the filesystem to prevent this. Use the 'Adopt' option to make that your data disk and rename the existing one. Alternatively you can move the data disk to the drive (overwriting its contents) or remove the drive from the system.", + "description": "At `{reference}`, we detected another active data disk (containing a file system `hassos-data` from another Home Assistant installation).\n\nYou need to decide what to do with it. Otherwise Home Assistant might choose the wrong data disk at system reboot.\n\nIf you don't want to use this data disk, unplug it from your system. If you leave it plugged in, choose one of the following options:", "menu_options": { - "system_rename_data_disk": "Rename", - "system_adopt_data_disk": "Adopt" + "system_rename_data_disk": "Mark as inactive data disk (rename file system)", + "system_adopt_data_disk": "Use the detected data disk instead of the current system" } }, "system_adopt_data_disk": { - "description": "This fix will initiate a system reboot which will make Home Assistant and all the Add-ons inaccessible for a brief period. After the reboot `{reference}` will be the data disk of Home Assistant and your existing data disk will be renamed and ignored." + "description": "Select submit to make `{reference}` the active data disk. The one and only.\n\nYou won't have access anymore to the current Home Assistant data (will be marked as inactive data disk). After reboot, your system will be in the state of the Home Assistant data on `{reference}`." } }, "abort": { @@ -187,6 +187,10 @@ "unsupported_systemd_resolved": { "title": "Unsupported system - Systemd-Resolved issues", "description": "System is unsupported because Systemd Resolved is missing, inactive or misconfigured. Use the link to learn more and how to fix this." + }, + "unsupported_virtualization_image": { + "title": "Unsupported system - Incorrect OS image for virtualization", + "description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. Use the link to learn more and how to fix this." } }, "entity": { diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 37604c0e18e..d46a2e50bfd 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -191,6 +191,18 @@ }, "service_not_found": { "message": "Service {domain}.{service} not found." + }, + "service_does_not_supports_reponse": { + "message": "A service which does not return responses can't be called with {return_response}." + }, + "service_lacks_response_request": { + "message": "The service call requires responses and must be called with {return_response}." + }, + "service_reponse_invalid": { + "message": "Failed to process the returned service response data, expected a dictionary, but got {response_data_type}." + }, + "service_should_be_blocking": { + "message": "A non blocking service call with argument {non_blocking_argument} can't be used together with argument {return_response}." } } } diff --git a/homeassistant/components/homeworks/config_flow.py b/homeassistant/components/homeworks/config_flow.py index b2fe4e0e022..b9515c306d6 100644 --- a/homeassistant/components/homeworks/config_flow.py +++ b/homeassistant/components/homeworks/config_flow.py @@ -93,7 +93,7 @@ BUTTON_EDIT = { } -validate_addr = cv.matches_regex(r"\[\d\d:\d\d:\d\d:\d\d\]") +validate_addr = cv.matches_regex(r"\[(?:\d\d:)?\d\d:\d\d:\d\d\]") async def validate_add_controller( @@ -565,15 +565,7 @@ class HomeworksConfigFlowHandler(ConfigFlow, domain=DOMAIN): CONF_KEYPADS: [ { CONF_ADDR: keypad[CONF_ADDR], - CONF_BUTTONS: [ - { - CONF_LED: button[CONF_LED], - CONF_NAME: button[CONF_NAME], - CONF_NUMBER: button[CONF_NUMBER], - CONF_RELEASE_DELAY: button[CONF_RELEASE_DELAY], - } - for button in keypad[CONF_BUTTONS] - ], + CONF_BUTTONS: [], CONF_NAME: keypad[CONF_NAME], } for keypad in config[CONF_KEYPADS] diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index bd32ee0a23d..ff63d66230d 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -34,7 +34,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -361,15 +361,18 @@ class HoneywellUSThermostat(ClimateEntity): if mode in ["heat", "emheat"]: await self._device.set_setpoint_heat(temperature) - except UnexpectedResponse as err: + except (AscConnectionError, UnexpectedResponse) as err: raise HomeAssistantError( - "Honeywell set temperature failed: Invalid Response" + translation_domain=DOMAIN, + translation_key="temp_failed", ) from err except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) - raise ValueError( - f"Honeywell set temperature failed: invalid temperature {temperature}." + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="temp_failed_value", + translation_placeholders={"temp": temperature}, ) from err async def async_set_temperature(self, **kwargs: Any) -> None: @@ -382,30 +385,41 @@ class HoneywellUSThermostat(ClimateEntity): if temperature := kwargs.get(ATTR_TARGET_TEMP_LOW): await self._device.set_setpoint_heat(temperature) - except UnexpectedResponse as err: + except (AscConnectionError, UnexpectedResponse) as err: raise HomeAssistantError( - "Honeywell set temperature failed: Invalid Response" + translation_domain=DOMAIN, + translation_key="temp_failed", ) from err except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) - raise ValueError( - f"Honeywell set temperature failed: invalid temperature: {temperature}." + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="temp_failed_value", + translation_placeholders={"temp": str(temperature)}, ) from err async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" try: await self._device.set_fan_mode(self._fan_mode_map[fan_mode]) + except SomeComfortError as err: - raise HomeAssistantError("Honeywell could not set fan mode.") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="fan_mode_failed", + ) from err async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" try: await self._device.set_system_mode(self._hvac_mode_map[hvac_mode]) + except SomeComfortError as err: - raise HomeAssistantError("Honeywell could not set system mode.") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="sys_mode_failed", + ) from err async def _turn_away_mode_on(self) -> None: """Turn away on. @@ -425,6 +439,12 @@ class HoneywellUSThermostat(ClimateEntity): if mode in HEATING_MODES: await self._device.set_hold_heat(True, self._heat_away_temp) + except (AscConnectionError, UnexpectedResponse) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="away_mode_failed", + ) from err + except SomeComfortError as err: _LOGGER.error( "Temperature out of range. Mode: %s, Heat Temperature: %.1f, Cool Temperature: %.1f", @@ -432,8 +452,14 @@ class HoneywellUSThermostat(ClimateEntity): self._heat_away_temp, self._cool_away_temp, ) - raise ValueError( - f"Honeywell set temperature failed: temperature out of range. Mode: {mode}, Heat Temperuature: {self._heat_away_temp}, Cool Temperature: {self._cool_away_temp}." + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="temp_failed_range", + translation_placeholders={ + "heat": str(self._heat_away_temp), + "cool": str(self._cool_away_temp), + "mode": mode, + }, ) from err async def _turn_hold_mode_on(self) -> None: @@ -452,11 +478,16 @@ class HoneywellUSThermostat(ClimateEntity): except SomeComfortError as err: _LOGGER.error("Couldn't set permanent hold") raise HomeAssistantError( - "Honeywell couldn't set permanent hold." + translation_domain=DOMAIN, + translation_key="set_hold_failed", ) from err else: _LOGGER.error("Invalid system mode returned: %s", mode) - raise HomeAssistantError(f"Honeywell invalid system mode returned {mode}.") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_mode_failed", + translation_placeholders={"mode": mode}, + ) async def _turn_away_mode_off(self) -> None: """Turn away/hold off.""" @@ -465,9 +496,13 @@ class HoneywellUSThermostat(ClimateEntity): # Disabling all hold modes await self._device.set_hold_cool(False) await self._device.set_hold_heat(False) + except SomeComfortError as err: _LOGGER.error("Can not stop hold mode") - raise HomeAssistantError("Honeywell could not stop hold mode") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="stop_hold_failed", + ) from err async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" @@ -493,9 +528,11 @@ class HoneywellUSThermostat(ClimateEntity): ) try: await self._device.set_system_mode("emheat") + except SomeComfortError as err: raise HomeAssistantError( - "Honeywell could not set system mode to aux heat." + translation_domain=DOMAIN, + translation_key="set_aux_failed", ) from err async def async_turn_aux_heat_off(self) -> None: @@ -517,8 +554,12 @@ class HoneywellUSThermostat(ClimateEntity): await self.async_set_hvac_mode(HVACMode.HEAT) else: await self.async_set_hvac_mode(HVACMode.OFF) + except HomeAssistantError as err: - raise HomeAssistantError("Honeywell could turn off aux heat mode.") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="disable_aux_failed", + ) from err async def async_update(self) -> None: """Get the latest state from the service.""" diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index 7506a7fda7c..d3bc1924e28 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -61,6 +61,39 @@ } }, "exceptions": { + "temp_failed": { + "message": "Honeywell set temperature failed" + }, + "sys_mode_failed": { + "message": "Honeywell could not set system mode" + }, + "fan_mode_failed": { + "message": "Honeywell could not set fan mode" + }, + "away_mode_failed": { + "message": "Honeywell set away mode failed" + }, + "temp_failed_value": { + "message": "Honeywell set temperature failed: invalid temperature {temperature}" + }, + "temp_failed_range": { + "message": "Honeywell set temperature failed: temperature out of range. Mode: {mode}, Heat Temperuature: {heat}, Cool Temperature: {cool}" + }, + "set_hold_failed": { + "message": "Honeywell could not set permanent hold" + }, + "set_mode_failed": { + "message": "Honeywell invalid system mode returned {mode}" + }, + "stop_hold_failed": { + "message": "Honeywell could not stop hold mode" + }, + "set_aux_failed": { + "message": "Honeywell could not set system mode to aux heat" + }, + "disable_aux_failed": { + "message": "Honeywell could turn off aux heat mode" + }, "switch_failed_off": { "message": "Honeywell could turn off emergency heat mode." }, diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 3e5f7333cbc..f9532b90ce6 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -21,7 +21,7 @@ from aiohttp.typedefs import JSONDecoder, StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection from aiohttp.web_protocol import RequestHandler from aiohttp_fast_url_dispatcher import FastUrlDispatcher, attach_fast_url_dispatcher -from aiohttp_zlib_ng import enable_zlib_ng +from aiohttp_isal import enable_isal from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -202,7 +202,7 @@ class ApiConfig: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HTTP API and debug interface.""" - enable_zlib_ng() + enable_isal() conf: ConfData | None = config.get(DOMAIN) diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py index ebae2480589..d97ac9922a2 100644 --- a/homeassistant/components/http/cors.py +++ b/homeassistant/components/http/cors.py @@ -13,6 +13,7 @@ from aiohttp.web_urldispatcher import ( ResourceRoute, StaticResource, ) +import aiohttp_cors from homeassistant.const import HTTP_HEADER_X_REQUESTED_WITH from homeassistant.core import callback @@ -35,11 +36,6 @@ VALID_CORS_TYPES: Final = (Resource, ResourceRoute, StaticResource) @callback def setup_cors(app: Application, origins: list[str]) -> None: """Set up CORS.""" - # This import should remain here. That way the HTTP integration can always - # be imported by other integrations without it's requirements being installed. - # pylint: disable-next=import-outside-toplevel - import aiohttp_cors - cors = aiohttp_cors.setup( app, defaults={ diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index 647b7e42a3a..fb804251edc 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -5,10 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/http", "integration_type": "system", "iot_class": "local_push", - "quality_scale": "internal", - "requirements": [ - "aiohttp_cors==0.7.0", - "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.3.1" - ] + "quality_scale": "internal" } diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index 29576e9fc10..868b5a32c67 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -6,11 +6,13 @@ import logging from typing import Any from iaqualink.device import AqualinkThermostat +from iaqualink.systems.iaqua.device import AqualinkState from homeassistant.components.climate import ( DOMAIN as CLIMATE_DOMAIN, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.config_entries import ConfigEntry @@ -82,6 +84,16 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): else: _LOGGER.warning("Unknown operation mode: %s", hvac_mode) + @property + def hvac_action(self) -> HVACAction: + """Return the current HVAC action.""" + state = AqualinkState(self.dev._heater.state) + if state == AqualinkState.ON: + return HVACAction.HEATING + if state == AqualinkState.ENABLED: + return HVACAction.IDLE + return HVACAction.OFF + @property def target_temperature(self) -> float: """Return the current target temperature.""" diff --git a/homeassistant/components/improv_ble/strings.json b/homeassistant/components/improv_ble/strings.json index b5713910134..be157b8070d 100644 --- a/homeassistant/components/improv_ble/strings.json +++ b/homeassistant/components/improv_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/inkbird/strings.json b/homeassistant/components/inkbird/strings.json index 4003debbbeb..4e12a84b653 100644 --- a/homeassistant/components/inkbird/strings.json +++ b/homeassistant/components/inkbird/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py index 529ac20df52..0ec2434bc82 100644 --- a/homeassistant/components/insteon/__init__.py +++ b/homeassistant/components/insteon/__init__.py @@ -16,6 +16,7 @@ from homeassistant.helpers.typing import ConfigType from . import api from .const import ( CONF_CAT, + CONF_DEV_PATH, CONF_DIM_STEPS, CONF_HOUSECODE, CONF_OVERRIDE, @@ -84,6 +85,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up an Insteon entry.""" + if dev_path := entry.options.get(CONF_DEV_PATH): + hass.data[DOMAIN] = {} + hass.data[DOMAIN][CONF_DEV_PATH] = dev_path + + api.async_load_api(hass) + await api.async_register_insteon_frontend(hass) + if not devices.modem: try: await async_connect(**entry.data) @@ -149,9 +157,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: create_insteon_device(hass, devices.modem, entry.entry_id) - api.async_load_api(hass) - await api.async_register_insteon_frontend(hass) - entry.async_create_background_task( hass, async_get_device_config(hass, entry), "insteon-get-device-config" ) diff --git a/homeassistant/components/insteon/api/__init__.py b/homeassistant/components/insteon/api/__init__.py index fa006c6a6d9..1f671aa1343 100644 --- a/homeassistant/components/insteon/api/__init__.py +++ b/homeassistant/components/insteon/api/__init__.py @@ -16,10 +16,19 @@ from .aldb import ( websocket_reset_aldb, websocket_write_aldb, ) +from .config import ( + websocket_add_device_override, + websocket_get_config, + websocket_get_modem_schema, + websocket_remove_device_override, + websocket_update_modem_config, +) from .device import ( websocket_add_device, + websocket_add_x10_device, websocket_cancel_add_device, websocket_get_device, + websocket_remove_device, ) from .properties import ( websocket_change_properties_record, @@ -58,6 +67,8 @@ def async_load_api(hass): websocket_api.async_register_command(hass, websocket_reset_aldb) websocket_api.async_register_command(hass, websocket_add_default_links) websocket_api.async_register_command(hass, websocket_notify_on_aldb_status) + websocket_api.async_register_command(hass, websocket_add_x10_device) + websocket_api.async_register_command(hass, websocket_remove_device) websocket_api.async_register_command(hass, websocket_get_properties) websocket_api.async_register_command(hass, websocket_change_properties_record) @@ -65,6 +76,12 @@ def async_load_api(hass): websocket_api.async_register_command(hass, websocket_load_properties) websocket_api.async_register_command(hass, websocket_reset_properties) + websocket_api.async_register_command(hass, websocket_get_config) + websocket_api.async_register_command(hass, websocket_get_modem_schema) + websocket_api.async_register_command(hass, websocket_update_modem_config) + websocket_api.async_register_command(hass, websocket_add_device_override) + websocket_api.async_register_command(hass, websocket_remove_device_override) + async def async_register_insteon_frontend(hass: HomeAssistant): """Register the Insteon frontend configuration panel.""" @@ -80,8 +97,7 @@ async def async_register_insteon_frontend(hass: HomeAssistant): hass=hass, frontend_url_path=DOMAIN, webcomponent_name="insteon-frontend", - sidebar_title=DOMAIN.capitalize(), - sidebar_icon="mdi:power", + config_panel_domain=DOMAIN, module_url=f"{URL_BASE}/entrypoint-{build_id}.js", embed_iframe=True, require_admin=True, diff --git a/homeassistant/components/insteon/api/config.py b/homeassistant/components/insteon/api/config.py new file mode 100644 index 00000000000..8a617911d1e --- /dev/null +++ b/homeassistant/components/insteon/api/config.py @@ -0,0 +1,272 @@ +"""API calls to manage Insteon configuration changes.""" + +from __future__ import annotations + +from typing import Any, TypedDict + +from pyinsteon import async_close, async_connect, devices +from pyinsteon.address import Address +import voluptuous as vol +import voluptuous_serialize + +from homeassistant.components import websocket_api +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_DEVICE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from ..const import ( + CONF_HOUSECODE, + CONF_OVERRIDE, + CONF_UNITCODE, + CONF_X10, + DEVICE_ADDRESS, + DOMAIN, + ID, + SIGNAL_ADD_DEVICE_OVERRIDE, + SIGNAL_ADD_X10_DEVICE, + SIGNAL_REMOVE_DEVICE_OVERRIDE, + TYPE, +) +from ..schemas import ( + build_device_override_schema, + build_hub_schema, + build_plm_manual_schema, + build_plm_schema, +) +from ..utils import async_get_usb_ports + +HUB_V1_SCHEMA = build_hub_schema(hub_version=1) +HUB_V2_SCHEMA = build_hub_schema(hub_version=2) +PLM_SCHEMA = build_plm_manual_schema() +DEVICE_OVERRIDE_SCHEMA = build_device_override_schema() +OVERRIDE = "override" + + +class X10DeviceConfig(TypedDict): + """X10 Device Configuration Definition.""" + + housecode: str + unitcode: int + platform: str + dim_steps: int + + +class DeviceOverride(TypedDict): + """X10 Device Configuration Definition.""" + + address: Address | str + cat: int + subcat: str + + +def get_insteon_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Return the Insteon configuration entry.""" + return hass.config_entries.async_entries(DOMAIN)[0] + + +def add_x10_device(hass: HomeAssistant, x10_device: X10DeviceConfig): + """Add an X10 device to the Insteon integration.""" + + config_entry = get_insteon_config_entry(hass) + x10_config = config_entry.options.get(CONF_X10, []) + if any( + device[CONF_HOUSECODE] == x10_device["housecode"] + and device[CONF_UNITCODE] == x10_device["unitcode"] + for device in x10_config + ): + raise ValueError("Duplicate X10 device") + + hass.config_entries.async_update_entry( + entry=config_entry, + options=config_entry.options | {CONF_X10: [*x10_config, x10_device]}, + ) + async_dispatcher_send(hass, SIGNAL_ADD_X10_DEVICE, x10_device) + + +def remove_x10_device(hass: HomeAssistant, housecode: str, unitcode: int): + """Remove an X10 device from the config.""" + + config_entry = get_insteon_config_entry(hass) + new_options = {**config_entry.options} + new_x10 = [ + existing_device + for existing_device in config_entry.options.get(CONF_X10, []) + if existing_device[CONF_HOUSECODE].lower() != housecode.lower() + or existing_device[CONF_UNITCODE] != unitcode + ] + + new_options[CONF_X10] = new_x10 + hass.config_entries.async_update_entry(entry=config_entry, options=new_options) + + +def add_device_overide(hass: HomeAssistant, override: DeviceOverride): + """Add an Insteon device override.""" + + config_entry = get_insteon_config_entry(hass) + override_config = config_entry.options.get(CONF_OVERRIDE, []) + address = Address(override[CONF_ADDRESS]) + if any( + Address(existing_override[CONF_ADDRESS]) == address + for existing_override in override_config + ): + raise ValueError("Duplicate override") + + hass.config_entries.async_update_entry( + entry=config_entry, + options=config_entry.options | {CONF_OVERRIDE: [*override_config, override]}, + ) + async_dispatcher_send(hass, SIGNAL_ADD_DEVICE_OVERRIDE, override) + + +def remove_device_override(hass: HomeAssistant, address: Address): + """Remove a device override from config.""" + + config_entry = get_insteon_config_entry(hass) + new_options = {**config_entry.options} + + new_overrides = [ + existing_override + for existing_override in config_entry.options.get(CONF_OVERRIDE, []) + if Address(existing_override[CONF_ADDRESS]) != address + ] + new_options[CONF_OVERRIDE] = new_overrides + hass.config_entries.async_update_entry(entry=config_entry, options=new_options) + + +async def _async_connect(**kwargs): + """Connect to the Insteon modem.""" + if devices.modem: + await async_close() + try: + await async_connect(**kwargs) + except ConnectionError: + return False + return True + + +@websocket_api.websocket_command({vol.Required(TYPE): "insteon/config/get"}) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_config( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get Insteon configuration.""" + config_entry = get_insteon_config_entry(hass) + modem_config = config_entry.data + options_config = config_entry.options + x10_config = options_config.get(CONF_X10) + override_config = options_config.get(CONF_OVERRIDE) + connection.send_result( + msg[ID], + { + "modem_config": {**modem_config}, + "x10_config": x10_config, + "override_config": override_config, + }, + ) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/config/get_modem_schema", + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_modem_schema( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get the schema for the modem configuration.""" + config_entry = get_insteon_config_entry(hass) + config_data = config_entry.data + if device := config_data.get(CONF_DEVICE): + ports = await async_get_usb_ports(hass=hass) + plm_schema = voluptuous_serialize.convert( + build_plm_schema(ports=ports, device=device) + ) + connection.send_result(msg[ID], plm_schema) + else: + hub_schema = voluptuous_serialize.convert(build_hub_schema(**config_data)) + connection.send_result(msg[ID], hub_schema) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/config/update_modem_config", + vol.Required("config"): vol.Any(PLM_SCHEMA, HUB_V2_SCHEMA, HUB_V1_SCHEMA), + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_update_modem_config( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get the schema for the modem configuration.""" + config = msg["config"] + config_entry = get_insteon_config_entry(hass) + is_connected = devices.modem.connected + + if not await _async_connect(**config): + connection.send_error( + msg_id=msg[ID], code="connection_failed", message="Connection failed" + ) + # Try to reconnect using old info + if is_connected: + await _async_connect(**config_entry.data) + return + + hass.config_entries.async_update_entry( + entry=config_entry, + data=config, + ) + connection.send_result(msg[ID], {"status": "success"}) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/config/device_override/add", + vol.Required(OVERRIDE): DEVICE_OVERRIDE_SCHEMA, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_add_device_override( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get the schema for the modem configuration.""" + override = msg[OVERRIDE] + try: + add_device_overide(hass, override) + except ValueError: + connection.send_error(msg[ID], "duplicate", "Duplicate device address") + + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/config/device_override/remove", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_remove_device_override( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get the schema for the modem configuration.""" + address = Address(msg[DEVICE_ADDRESS]) + remove_device_override(hass, address) + async_dispatcher_send(hass, SIGNAL_REMOVE_DEVICE_OVERRIDE, address) + connection.send_result(msg[ID]) diff --git a/homeassistant/components/insteon/api/device.py b/homeassistant/components/insteon/api/device.py index d48d87fa347..e8bd08bc4ee 100644 --- a/homeassistant/components/insteon/api/device.py +++ b/homeassistant/components/insteon/api/device.py @@ -3,12 +3,14 @@ from typing import Any from pyinsteon import devices +from pyinsteon.address import Address from pyinsteon.constants import DeviceAction import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send from ..const import ( DEVICE_ADDRESS, @@ -18,8 +20,17 @@ from ..const import ( ID, INSTEON_DEVICE_NOT_FOUND, MULTIPLE, + SIGNAL_REMOVE_HA_DEVICE, + SIGNAL_REMOVE_INSTEON_DEVICE, + SIGNAL_REMOVE_X10_DEVICE, TYPE, ) +from ..schemas import build_x10_schema +from .config import add_x10_device, remove_device_override, remove_x10_device + +X10_DEVICE = "x10_device" +X10_DEVICE_SCHEMA = build_x10_schema() +REMOVE_ALL_REFS = "remove_all_refs" def compute_device_name(ha_device): @@ -139,3 +150,61 @@ async def websocket_cancel_add_device( """Cancel the Insteon all-linking process.""" await devices.async_cancel_all_linking() connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/device/remove", + vol.Required(DEVICE_ADDRESS): str, + vol.Required(REMOVE_ALL_REFS): bool, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_remove_device( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Remove an Insteon device.""" + + address = msg[DEVICE_ADDRESS] + remove_all_refs = msg[REMOVE_ALL_REFS] + if address.startswith("X10"): + _, housecode, unitcode = address.split(".") + unitcode = int(unitcode) + async_dispatcher_send(hass, SIGNAL_REMOVE_X10_DEVICE, housecode, unitcode) + remove_x10_device(hass, housecode, unitcode) + else: + address = Address(address) + remove_device_override(hass, address) + async_dispatcher_send(hass, SIGNAL_REMOVE_HA_DEVICE, address) + async_dispatcher_send( + hass, SIGNAL_REMOVE_INSTEON_DEVICE, address, remove_all_refs + ) + + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/device/add_x10", + vol.Required(X10_DEVICE): X10_DEVICE_SCHEMA, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_add_x10_device( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get the schema for the X10 devices configuration.""" + x10_device = msg[X10_DEVICE] + try: + add_x10_device(hass, x10_device) + except ValueError: + connection.send_error(msg[ID], code="duplicate", message="Duplicate X10 device") + return + + connection.send_result(msg[ID]) diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index 44aa1e18646..baf06b13860 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -4,52 +4,19 @@ from __future__ import annotations import logging -from pyinsteon import async_close, async_connect, devices +from pyinsteon import async_connect from homeassistant.components import dhcp, usb from homeassistant.config_entries import ( DEFAULT_DISCOVERY_UNIQUE_ID, - ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, ) -from homeassistant.const import ( - CONF_ADDRESS, - CONF_DEVICE, - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, -) -from homeassistant.core import callback +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_NAME from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import ( - CONF_HOUSECODE, - CONF_HUB_VERSION, - CONF_OVERRIDE, - CONF_UNITCODE, - CONF_X10, - DOMAIN, - SIGNAL_ADD_DEVICE_OVERRIDE, - SIGNAL_ADD_X10_DEVICE, - SIGNAL_REMOVE_DEVICE_OVERRIDE, - SIGNAL_REMOVE_X10_DEVICE, -) -from .schemas import ( - add_device_override, - add_x10_device, - build_device_override_schema, - build_hub_schema, - build_plm_manual_schema, - build_plm_schema, - build_remove_override_schema, - build_remove_x10_schema, - build_x10_schema, -) +from .const import CONF_HUB_VERSION, DOMAIN +from .schemas import build_hub_schema, build_plm_manual_schema, build_plm_schema from .utils import async_get_usb_ports STEP_PLM = "plm" @@ -80,41 +47,6 @@ async def _async_connect(**kwargs): return True -def _remove_override(address, options): - """Remove a device override from config.""" - new_options = {} - if options.get(CONF_X10): - new_options[CONF_X10] = options.get(CONF_X10) - new_overrides = [ - override - for override in options[CONF_OVERRIDE] - if override[CONF_ADDRESS] != address - ] - if new_overrides: - new_options[CONF_OVERRIDE] = new_overrides - return new_options - - -def _remove_x10(device, options): - """Remove an X10 device from the config.""" - housecode = device[11].lower() - unitcode = int(device[24:]) - new_options = {} - if options.get(CONF_OVERRIDE): - new_options[CONF_OVERRIDE] = options.get(CONF_OVERRIDE) - new_x10 = [ - existing_device - for existing_device in options[CONF_X10] - if ( - existing_device[CONF_HOUSECODE].lower() != housecode - or existing_device[CONF_UNITCODE] != unitcode - ) - ] - if new_x10: - new_options[CONF_X10] = new_x10 - return new_options, housecode, unitcode - - class InsteonFlowHandler(ConfigFlow, domain=DOMAIN): """Insteon config flow handler.""" @@ -122,14 +54,6 @@ class InsteonFlowHandler(ConfigFlow, domain=DOMAIN): _device_name: str | None = None discovered_conf: dict[str, str] = {} - @staticmethod - @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> InsteonOptionsFlowHandler: - """Define the config flow to handle options.""" - return InsteonOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): """Init the config flow.""" if self._async_current_entries(): @@ -237,140 +161,3 @@ class InsteonFlowHandler(ConfigFlow, domain=DOMAIN): } await self.async_set_unique_id(format_mac(discovery_info.macaddress)) return await self.async_step_user() - - -class InsteonOptionsFlowHandler(OptionsFlow): - """Handle an Insteon options flow.""" - - def __init__(self, config_entry: ConfigEntry) -> None: - """Init the InsteonOptionsFlowHandler class.""" - self.config_entry = config_entry - - async def async_step_init(self, user_input=None) -> ConfigFlowResult: - """Init the options config flow.""" - menu_options = [STEP_ADD_OVERRIDE, STEP_ADD_X10] - - if self.config_entry.data.get(CONF_HOST): - menu_options.append(STEP_CHANGE_HUB_CONFIG) - else: - menu_options.append(STEP_CHANGE_PLM_CONFIG) - - options = {**self.config_entry.options} - if options.get(CONF_OVERRIDE): - menu_options.append(STEP_REMOVE_OVERRIDE) - if options.get(CONF_X10): - menu_options.append(STEP_REMOVE_X10) - - return self.async_show_menu(step_id="init", menu_options=menu_options) - - async def async_step_change_hub_config(self, user_input=None) -> ConfigFlowResult: - """Change the Hub configuration.""" - errors = {} - if user_input is not None: - data = { - **self.config_entry.data, - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input[CONF_PORT], - } - if self.config_entry.data[CONF_HUB_VERSION] == 2: - data[CONF_USERNAME] = user_input[CONF_USERNAME] - data[CONF_PASSWORD] = user_input[CONF_PASSWORD] - if devices.modem: - await async_close() - - if await _async_connect(**data): - self.hass.config_entries.async_update_entry( - self.config_entry, data=data - ) - return self.async_create_entry(data={**self.config_entry.options}) - errors["base"] = "cannot_connect" - data_schema = build_hub_schema(**self.config_entry.data) - return self.async_show_form( - step_id=STEP_CHANGE_HUB_CONFIG, data_schema=data_schema, errors=errors - ) - - async def async_step_change_plm_config(self, user_input=None) -> ConfigFlowResult: - """Change the PLM configuration.""" - errors = {} - if user_input is not None: - data = { - **self.config_entry.data, - CONF_DEVICE: user_input[CONF_DEVICE], - } - if devices.modem: - await async_close() - if await _async_connect(**data): - self.hass.config_entries.async_update_entry( - self.config_entry, data=data - ) - return self.async_create_entry(data={**self.config_entry.options}) - errors["base"] = "cannot_connect" - - ports = await async_get_usb_ports(self.hass) - data_schema = build_plm_schema(ports, **self.config_entry.data) - return self.async_show_form( - step_id=STEP_CHANGE_PLM_CONFIG, data_schema=data_schema, errors=errors - ) - - async def async_step_add_override(self, user_input=None) -> ConfigFlowResult: - """Add a device override.""" - errors = {} - if user_input is not None: - try: - data = add_device_override({**self.config_entry.options}, user_input) - async_dispatcher_send(self.hass, SIGNAL_ADD_DEVICE_OVERRIDE, user_input) - return self.async_create_entry(data=data) - except ValueError: - errors["base"] = "input_error" - schema_defaults = user_input if user_input is not None else {} - data_schema = build_device_override_schema(**schema_defaults) - return self.async_show_form( - step_id=STEP_ADD_OVERRIDE, data_schema=data_schema, errors=errors - ) - - async def async_step_add_x10(self, user_input=None) -> ConfigFlowResult: - """Add an X10 device.""" - errors: dict[str, str] = {} - if user_input is not None: - options = add_x10_device({**self.config_entry.options}, user_input) - async_dispatcher_send(self.hass, SIGNAL_ADD_X10_DEVICE, user_input) - return self.async_create_entry(data=options) - schema_defaults: dict[str, str] = user_input if user_input is not None else {} - data_schema = build_x10_schema(**schema_defaults) - return self.async_show_form( - step_id=STEP_ADD_X10, data_schema=data_schema, errors=errors - ) - - async def async_step_remove_override(self, user_input=None) -> ConfigFlowResult: - """Remove a device override.""" - errors: dict[str, str] = {} - options = self.config_entry.options - if user_input is not None: - options = _remove_override(user_input[CONF_ADDRESS], options) - async_dispatcher_send( - self.hass, - SIGNAL_REMOVE_DEVICE_OVERRIDE, - user_input[CONF_ADDRESS], - ) - return self.async_create_entry(data=options) - - data_schema = build_remove_override_schema(options[CONF_OVERRIDE]) - return self.async_show_form( - step_id=STEP_REMOVE_OVERRIDE, data_schema=data_schema, errors=errors - ) - - async def async_step_remove_x10(self, user_input=None) -> ConfigFlowResult: - """Remove an X10 device.""" - errors: dict[str, str] = {} - options = self.config_entry.options - if user_input is not None: - options, housecode, unitcode = _remove_x10(user_input[CONF_DEVICE], options) - async_dispatcher_send( - self.hass, SIGNAL_REMOVE_X10_DEVICE, housecode, unitcode - ) - return self.async_create_entry(data=options) - - data_schema = build_remove_x10_schema(options[CONF_X10]) - return self.async_show_form( - step_id=STEP_REMOVE_X10, data_schema=data_schema, errors=errors - ) diff --git a/homeassistant/components/insteon/const.py b/homeassistant/components/insteon/const.py index b7e6e6055e1..11e1943aa73 100644 --- a/homeassistant/components/insteon/const.py +++ b/homeassistant/components/insteon/const.py @@ -101,6 +101,8 @@ SIGNAL_SAVE_DEVICES = "save_devices" SIGNAL_ADD_ENTITIES = "insteon_add_entities" SIGNAL_ADD_DEFAULT_LINKS = "add_default_links" SIGNAL_ADD_DEVICE_OVERRIDE = "add_device_override" +SIGNAL_REMOVE_HA_DEVICE = "insteon_remove_ha_device" +SIGNAL_REMOVE_INSTEON_DEVICE = "insteon_remove_insteon_device" SIGNAL_REMOVE_DEVICE_OVERRIDE = "insteon_remove_device_override" SIGNAL_REMOVE_ENTITY = "insteon_remove_entity" SIGNAL_ADD_X10_DEVICE = "insteon_add_x10_device" diff --git a/homeassistant/components/insteon/insteon_entity.py b/homeassistant/components/insteon/insteon_entity.py index f81298dfe48..79e5c18a934 100644 --- a/homeassistant/components/insteon/insteon_entity.py +++ b/homeassistant/components/insteon/insteon_entity.py @@ -95,6 +95,7 @@ class InsteonEntity(Entity): f" {self._insteon_device.engine_version}" ), via_device=(DOMAIN, str(devices.modem.address)), + configuration_url=f"homeassistant://insteon/device/config/{self._insteon_device.id}", ) @callback diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index cf210963841..7d12436d0fb 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -18,7 +18,7 @@ "loggers": ["pyinsteon", "pypubsub"], "requirements": [ "pyinsteon==1.5.3", - "insteon-frontend-home-assistant==0.4.0" + "insteon-frontend-home-assistant==0.5.0" ], "usb": [ { diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index e277281c240..837c6224014 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -2,9 +2,6 @@ from __future__ import annotations -from binascii import Error as HexError, unhexlify - -from pyinsteon.address import Address from pyinsteon.constants import HC_LOOKUP import voluptuous as vol @@ -25,10 +22,8 @@ from .const import ( CONF_CAT, CONF_DIM_STEPS, CONF_HOUSECODE, - CONF_OVERRIDE, CONF_SUBCAT, CONF_UNITCODE, - CONF_X10, HOUSECODES, PORT_HUB_V1, PORT_HUB_V2, @@ -76,76 +71,6 @@ TRIGGER_SCENE_SCHEMA = vol.Schema( ADD_DEFAULT_LINKS_SCHEMA = vol.Schema({vol.Required(CONF_ENTITY_ID): cv.entity_id}) -def normalize_byte_entry_to_int(entry: int | bytes | str): - """Format a hex entry value.""" - if isinstance(entry, int): - if entry in range(256): - return entry - raise ValueError("Must be single byte") - if isinstance(entry, str): - if entry[0:2].lower() == "0x": - entry = entry[2:] - if len(entry) != 2: - raise ValueError("Not a valid hex code") - try: - entry = unhexlify(entry) - except HexError as err: - raise ValueError("Not a valid hex code") from err - return int.from_bytes(entry, byteorder="big") - - -def add_device_override(config_data, new_override): - """Add a new device override.""" - try: - address = str(Address(new_override[CONF_ADDRESS])) - cat = normalize_byte_entry_to_int(new_override[CONF_CAT]) - subcat = normalize_byte_entry_to_int(new_override[CONF_SUBCAT]) - except ValueError as err: - raise ValueError("Incorrect values") from err - - overrides = [ - override - for override in config_data.get(CONF_OVERRIDE, []) - if override[CONF_ADDRESS] != address - ] - overrides.append( - { - CONF_ADDRESS: address, - CONF_CAT: cat, - CONF_SUBCAT: subcat, - } - ) - - new_config = {} - if config_data.get(CONF_X10): - new_config[CONF_X10] = config_data[CONF_X10] - new_config[CONF_OVERRIDE] = overrides - return new_config - - -def add_x10_device(config_data, new_x10): - """Add a new X10 device to X10 device list.""" - x10_devices = [ - x10_device - for x10_device in config_data.get(CONF_X10, []) - if x10_device[CONF_HOUSECODE] != new_x10[CONF_HOUSECODE] - or x10_device[CONF_UNITCODE] != new_x10[CONF_UNITCODE] - ] - x10_devices.append( - { - CONF_HOUSECODE: new_x10[CONF_HOUSECODE], - CONF_UNITCODE: new_x10[CONF_UNITCODE], - CONF_PLATFORM: new_x10[CONF_PLATFORM], - CONF_DIM_STEPS: new_x10[CONF_DIM_STEPS], - } - ) - new_config = {} - if config_data.get(CONF_OVERRIDE): - new_config[CONF_OVERRIDE] = config_data[CONF_OVERRIDE] - new_config[CONF_X10] = x10_devices - return new_config - - def build_device_override_schema( address=vol.UNDEFINED, cat=vol.UNDEFINED, @@ -169,12 +94,16 @@ def build_x10_schema( dim_steps=22, ): """Build the X10 schema for config flow.""" + if platform == "light": + dim_steps_schema = vol.Required(CONF_DIM_STEPS, default=dim_steps) + else: + dim_steps_schema = vol.Optional(CONF_DIM_STEPS, default=dim_steps) return vol.Schema( { vol.Required(CONF_HOUSECODE, default=housecode): vol.In(HC_LOOKUP.keys()), vol.Required(CONF_UNITCODE, default=unitcode): vol.In(range(1, 17)), vol.Required(CONF_PLATFORM, default=platform): vol.In(X10_PLATFORMS), - vol.Optional(CONF_DIM_STEPS, default=dim_steps): vol.In(range(1, 255)), + dim_steps_schema: vol.Range(min=0, max=255), } ) @@ -219,18 +148,3 @@ def build_hub_schema( schema[vol.Required(CONF_USERNAME, default=username)] = str schema[vol.Required(CONF_PASSWORD, default=password)] = str return vol.Schema(schema) - - -def build_remove_override_schema(data): - """Build the schema to remove device overrides in config flow options.""" - selection = [override[CONF_ADDRESS] for override in data] - return vol.Schema({vol.Required(CONF_ADDRESS): vol.In(selection)}) - - -def build_remove_x10_schema(data): - """Build the schema to remove an X10 device in config flow options.""" - selection = [ - f"Housecode: {device[CONF_HOUSECODE].upper()}, Unitcode: {device[CONF_UNITCODE]}" - for device in data - ] - return vol.Schema({vol.Required(CONF_DEVICE): vol.In(selection)}) diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index 272018ea507..db25d8c97a9 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -65,6 +65,8 @@ from .const import ( SIGNAL_PRINT_ALDB, SIGNAL_REMOVE_DEVICE_OVERRIDE, SIGNAL_REMOVE_ENTITY, + SIGNAL_REMOVE_HA_DEVICE, + SIGNAL_REMOVE_INSTEON_DEVICE, SIGNAL_REMOVE_X10_DEVICE, SIGNAL_SAVE_DEVICES, SRV_ADD_ALL_LINK, @@ -179,7 +181,7 @@ def register_new_device_callback(hass): @callback -def async_register_services(hass): +def async_register_services(hass): # noqa: C901 """Register services used by insteon component.""" save_lock = asyncio.Lock() @@ -270,14 +272,14 @@ def async_register_services(hass): async def async_add_device_override(override): """Remove an Insten device and associated entities.""" address = Address(override[CONF_ADDRESS]) - await async_remove_device(address) + await async_remove_ha_device(address) devices.set_id(address, override[CONF_CAT], override[CONF_SUBCAT], 0) await async_srv_save_devices() async def async_remove_device_override(address): """Remove an Insten device and associated entities.""" address = Address(address) - await async_remove_device(address) + await async_remove_ha_device(address) devices.set_id(address, None, None, None) await devices.async_identify_device(address) await async_srv_save_devices() @@ -304,9 +306,9 @@ def async_register_services(hass): """Remove an X10 device and associated entities.""" address = create_x10_address(housecode, unitcode) devices.pop(address) - await async_remove_device(address) + await async_remove_ha_device(address) - async def async_remove_device(address): + async def async_remove_ha_device(address: Address, remove_all_refs: bool = False): """Remove the device and all entities from hass.""" signal = f"{address.id}_{SIGNAL_REMOVE_ENTITY}" async_dispatcher_send(hass, signal) @@ -315,6 +317,15 @@ def async_register_services(hass): if device: dev_registry.async_remove_device(device.id) + async def async_remove_insteon_device( + address: Address, remove_all_refs: bool = False + ): + """Remove the underlying Insteon device from the network.""" + await devices.async_remove_device( + address=address, force=False, remove_all_refs=remove_all_refs + ) + await async_srv_save_devices() + hass.services.async_register( DOMAIN, SRV_ADD_ALL_LINK, async_srv_add_all_link, schema=ADD_ALL_LINK_SCHEMA ) @@ -368,6 +379,10 @@ def async_register_services(hass): ) async_dispatcher_connect(hass, SIGNAL_ADD_X10_DEVICE, async_add_x10_device) async_dispatcher_connect(hass, SIGNAL_REMOVE_X10_DEVICE, async_remove_x10_device) + async_dispatcher_connect(hass, SIGNAL_REMOVE_HA_DEVICE, async_remove_ha_device) + async_dispatcher_connect( + hass, SIGNAL_REMOVE_INSTEON_DEVICE, async_remove_insteon_device + ) _LOGGER.debug("Insteon Services registered") diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index 1aad6ae6b21..8d3b97d0ca5 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -120,7 +120,10 @@ async def async_setup_entry( ATTR_MARKER_TYPE: marker.marker_type, }, ), - value_fn=_get_marker_value_fn(index, lambda marker: marker.level), + value_fn=_get_marker_value_fn( + index, + lambda marker: marker.level if marker.level >= 0 else None, + ), ), ) ) diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index 12730c9be08..2db89183499 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -4,8 +4,6 @@ from __future__ import annotations from typing import Any -from prayer_times_calculator import InvalidResponseError, PrayerTimesCalculator -from requests.exceptions import ConnectionError as ConnError import voluptuous as vol from homeassistant.config_entries import ( @@ -15,7 +13,7 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.helpers.selector import ( LocationSelector, SelectSelector, @@ -23,7 +21,6 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, TextSelector, ) -import homeassistant.util.dt as dt_util from .const import ( CALC_METHODS, @@ -43,26 +40,6 @@ from .const import ( ) -async def async_validate_location( - hass: HomeAssistant, lat: float, lon: float -) -> dict[str, str]: - """Check if the selected location is valid.""" - errors = {} - calc = PrayerTimesCalculator( - latitude=lat, - longitude=lon, - calculation_method=DEFAULT_CALC_METHOD, - date=str(dt_util.now().date()), - ) - try: - await hass.async_add_executor_job(calc.fetch_prayer_times) - except InvalidResponseError: - errors["base"] = "invalid_location" - except ConnError: - errors["base"] = "conn_error" - return errors - - class IslamicPrayerFlowHandler(ConfigFlow, domain=DOMAIN): """Handle the Islamic Prayer config flow.""" @@ -81,7 +58,6 @@ class IslamicPrayerFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - errors = {} if user_input is not None: lat: float = user_input[CONF_LOCATION][CONF_LATITUDE] @@ -89,14 +65,13 @@ class IslamicPrayerFlowHandler(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(f"{lat}-{lon}") self._abort_if_unique_id_configured() - if not (errors := await async_validate_location(self.hass, lat, lon)): - return self.async_create_entry( - title=user_input[CONF_NAME], - data={ - CONF_LATITUDE: lat, - CONF_LONGITUDE: lon, - }, - ) + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_LATITUDE: lat, + CONF_LONGITUDE: lon, + }, + ) home_location = { CONF_LATITUDE: self.hass.config.latitude, @@ -112,7 +87,6 @@ class IslamicPrayerFlowHandler(ConfigFlow, domain=DOMAIN): ): LocationSelector(), } ), - errors=errors, ) diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index d70d0e2f4fe..2785f69534c 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -6,14 +6,13 @@ from datetime import datetime, timedelta import logging from typing import Any, cast -from prayer_times_calculator import PrayerTimesCalculator, exceptions -from requests.exceptions import ConnectionError as ConnError +from prayer_times_calculator_offline import PrayerTimesCalculator from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.event import async_call_later, async_track_point_in_time -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.event import async_track_point_in_time +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator import homeassistant.util.dt as dt_util from .const import ( @@ -142,13 +141,7 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim async def _async_update_data(self) -> dict[str, datetime]: """Update sensors with new prayer times.""" - try: - prayer_times = await self.hass.async_add_executor_job( - self.get_new_prayer_times - ) - except (exceptions.InvalidResponseError, ConnError) as err: - async_call_later(self.hass, 60, self.async_request_update) - raise UpdateFailed from err + prayer_times = self.get_new_prayer_times() # introduced in prayer-times-calculator 0.0.8 prayer_times.pop("date", None) diff --git a/homeassistant/components/islamic_prayer_times/manifest.json b/homeassistant/components/islamic_prayer_times/manifest.json index 5f7e52dd3db..cae3d31feb2 100644 --- a/homeassistant/components/islamic_prayer_times/manifest.json +++ b/homeassistant/components/islamic_prayer_times/manifest.json @@ -1,10 +1,10 @@ { "domain": "islamic_prayer_times", "name": "Islamic Prayer Times", - "codeowners": ["@engrbm87"], + "codeowners": ["@engrbm87", "@cpfair"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/islamic_prayer_times", - "iot_class": "cloud_polling", + "iot_class": "calculated", "loggers": ["prayer_times_calculator"], - "requirements": ["prayer-times-calculator==0.0.12"] + "requirements": ["prayer-times-calculator-offline==1.0.3"] } diff --git a/homeassistant/components/kegtron/strings.json b/homeassistant/components/kegtron/strings.json index d1d544c2381..16a80220a20 100644 --- a/homeassistant/components/kegtron/strings.json +++ b/homeassistant/components/kegtron/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/leaone/strings.json b/homeassistant/components/leaone/strings.json index 6391c754dec..bb684941147 100644 --- a/homeassistant/components/leaone/strings.json +++ b/homeassistant/components/leaone/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/lg_netcast/__init__.py b/homeassistant/components/lg_netcast/__init__.py index 232d7bd10b8..f6fb834ab11 100644 --- a/homeassistant/components/lg_netcast/__init__.py +++ b/homeassistant/components/lg_netcast/__init__.py @@ -1 +1,33 @@ """The lg_netcast component.""" + +from typing import Final + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN + +PLATFORMS: Final[list[Platform]] = [Platform.MEDIA_PLAYER] + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + + return unload_ok diff --git a/homeassistant/components/lg_netcast/config_flow.py b/homeassistant/components/lg_netcast/config_flow.py new file mode 100644 index 00000000000..3c1d3d73e0f --- /dev/null +++ b/homeassistant/components/lg_netcast/config_flow.py @@ -0,0 +1,217 @@ +"""Config flow to configure the LG Netcast TV integration.""" + +from __future__ import annotations + +import contextlib +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any + +from pylgnetcast import AccessTokenError, LgNetCastClient, SessionIdError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_HOST, + CONF_ID, + CONF_MODEL, + CONF_NAME, +) +from homeassistant.core import CALLBACK_TYPE, DOMAIN as HOMEASSISTANT_DOMAIN, callback +from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.util.network import is_host_valid + +from .const import DEFAULT_NAME, DOMAIN +from .helpers import LGNetCastDetailDiscoveryError, async_discover_netcast_details + +DISPLAY_ACCESS_TOKEN_INTERVAL = timedelta(seconds=1) + + +class LGNetCast(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for LG Netcast TV integration.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize config flow.""" + self.client: LgNetCastClient | None = None + self.device_config: dict[str, Any] = {} + self._discovered_devices: dict[str, Any] = {} + self._track_interval: CALLBACK_TYPE | None = None + + def create_client(self) -> None: + """Create LG Netcast client from config.""" + host = self.device_config[CONF_HOST] + access_token = self.device_config.get(CONF_ACCESS_TOKEN) + self.client = LgNetCastClient(host, access_token) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + host = user_input[CONF_HOST] + if is_host_valid(host): + self.device_config[CONF_HOST] = host + return await self.async_step_authorize() + + errors[CONF_HOST] = "invalid_host" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, + ) + + async def async_step_import(self, config: dict[str, Any]) -> ConfigFlowResult: + """Import configuration from yaml.""" + self.device_config = { + CONF_HOST: config[CONF_HOST], + CONF_NAME: config[CONF_NAME], + } + + def _create_issue(): + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.11.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "LG Netcast", + }, + ) + + try: + result: ConfigFlowResult = await self.async_step_authorize(config) + except AbortFlow as err: + if err.reason != "already_configured": + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_import_issue_{err.reason}", + breaks_in_ha_version="2024.11.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{err.reason}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "LG Netcast", + "error_type": err.reason, + }, + ) + else: + _create_issue() + raise + + _create_issue() + + return result + + async def async_discover_client(self): + """Handle Discovery step.""" + self.create_client() + + if TYPE_CHECKING: + assert self.client is not None + + if self.device_config.get(CONF_ID): + return + + try: + details = await async_discover_netcast_details(self.hass, self.client) + except LGNetCastDetailDiscoveryError as err: + raise AbortFlow("cannot_connect") from err + + if (unique_id := details["uuid"]) is None: + raise AbortFlow("invalid_host") + + self.device_config[CONF_ID] = unique_id + self.device_config[CONF_MODEL] = details["model_name"] + + if CONF_NAME not in self.device_config: + self.device_config[CONF_NAME] = details["friendly_name"] or DEFAULT_NAME + + async def async_step_authorize( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle Authorize step.""" + errors: dict[str, str] = {} + self.async_stop_display_access_token() + + if user_input is not None and user_input.get(CONF_ACCESS_TOKEN) is not None: + self.device_config[CONF_ACCESS_TOKEN] = user_input[CONF_ACCESS_TOKEN] + + await self.async_discover_client() + assert self.client is not None + + await self.async_set_unique_id(self.device_config[CONF_ID]) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self.device_config[CONF_HOST]} + ) + + try: + await self.hass.async_add_executor_job( + self.client._get_session_id # pylint: disable=protected-access + ) + except AccessTokenError: + if user_input is not None: + errors[CONF_ACCESS_TOKEN] = "invalid_access_token" + except SessionIdError: + errors["base"] = "cannot_connect" + else: + return await self.async_create_device() + + self._track_interval = async_track_time_interval( + self.hass, + self.async_display_access_token, + DISPLAY_ACCESS_TOKEN_INTERVAL, + cancel_on_shutdown=True, + ) + + return self.async_show_form( + step_id="authorize", + data_schema=vol.Schema( + { + vol.Optional(CONF_ACCESS_TOKEN): vol.All(str, vol.Length(max=6)), + } + ), + errors=errors, + ) + + async def async_display_access_token(self, _: datetime | None = None): + """Display access token on screen.""" + assert self.client is not None + with contextlib.suppress(AccessTokenError, SessionIdError): + await self.hass.async_add_executor_job( + self.client._get_session_id # pylint: disable=protected-access + ) + + @callback + def async_remove(self): + """Terminate Access token display if flow is removed.""" + self.async_stop_display_access_token() + + def async_stop_display_access_token(self): + """Stop Access token request if running.""" + if self._track_interval is not None: + self._track_interval() + self._track_interval = None + + async def async_create_device(self) -> ConfigFlowResult: + """Create LG Netcast TV Device from config.""" + assert self.client + + return self.async_create_entry( + title=self.device_config[CONF_NAME], data=self.device_config + ) diff --git a/homeassistant/components/lg_netcast/const.py b/homeassistant/components/lg_netcast/const.py index 0344ad6f177..aca01c9b870 100644 --- a/homeassistant/components/lg_netcast/const.py +++ b/homeassistant/components/lg_netcast/const.py @@ -1,3 +1,9 @@ """Constants for the lg_netcast component.""" +from typing import Final + +ATTR_MANUFACTURER: Final = "LG" + +DEFAULT_NAME: Final = "LG Netcast TV" + DOMAIN = "lg_netcast" diff --git a/homeassistant/components/lg_netcast/device_trigger.py b/homeassistant/components/lg_netcast/device_trigger.py new file mode 100644 index 00000000000..51c5ec53004 --- /dev/null +++ b/homeassistant/components/lg_netcast/device_trigger.py @@ -0,0 +1,88 @@ +"""Provides device triggers for LG Netcast.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.const import CONF_DEVICE_ID, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import trigger +from .const import DOMAIN +from .helpers import async_get_device_entry_by_device_id +from .triggers.turn_on import ( + PLATFORM_TYPE as TURN_ON_PLATFORM_TYPE, + async_get_turn_on_trigger, +) + +TRIGGER_TYPES = {TURN_ON_PLATFORM_TYPE} + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + } +) + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + if config[CONF_TYPE] == TURN_ON_PLATFORM_TYPE: + device_id = config[CONF_DEVICE_ID] + + try: + device = async_get_device_entry_by_device_id(hass, device_id) + except ValueError as err: + raise InvalidDeviceAutomationConfig(err) from err + + if DOMAIN in hass.data: + for config_entry_id in device.config_entries: + if hass.data[DOMAIN].get(config_entry_id): + break + else: + raise InvalidDeviceAutomationConfig( + f"Device {device.id} is not from an existing {DOMAIN} config entry" + ) + + return config + + +async def async_get_triggers( + _hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: + """List device triggers for LG Netcast devices.""" + return [async_get_turn_on_trigger(device_id)] + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + if (trigger_type := config[CONF_TYPE]) == TURN_ON_PLATFORM_TYPE: + trigger_config = { + CONF_PLATFORM: trigger_type, + CONF_DEVICE_ID: config[CONF_DEVICE_ID], + } + trigger_config = await trigger.async_validate_trigger_config( + hass, trigger_config + ) + return await trigger.async_attach_trigger( + hass, trigger_config, action, trigger_info + ) + + raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") diff --git a/homeassistant/components/lg_netcast/helpers.py b/homeassistant/components/lg_netcast/helpers.py new file mode 100644 index 00000000000..7cfc0d50271 --- /dev/null +++ b/homeassistant/components/lg_netcast/helpers.py @@ -0,0 +1,59 @@ +"""Helper functions for LG Netcast TV.""" + +from typing import TypedDict +import xml.etree.ElementTree as ET + +from pylgnetcast import LgNetCastClient +from requests import RequestException + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import DOMAIN + + +class LGNetCastDetailDiscoveryError(Exception): + """Unable to retrieve details from Netcast TV.""" + + +class NetcastDetails(TypedDict): + """Netcast TV Details.""" + + uuid: str + model_name: str + friendly_name: str + + +async def async_discover_netcast_details( + hass: HomeAssistant, client: LgNetCastClient +) -> NetcastDetails: + """Discover UUID and Model Name from Netcast Tv.""" + try: + resp = await hass.async_add_executor_job(client.query_device_info) + except RequestException as err: + raise LGNetCastDetailDiscoveryError( + f"Error in connecting to {client.url}" + ) from err + except ET.ParseError as err: + raise LGNetCastDetailDiscoveryError("Invalid XML") from err + + if resp is None: + raise LGNetCastDetailDiscoveryError("Empty response received") + + return resp + + +@callback +def async_get_device_entry_by_device_id( + hass: HomeAssistant, device_id: str +) -> DeviceEntry: + """Get Device Entry from Device Registry by device ID. + + Raises ValueError if device ID is invalid. + """ + device_reg = dr.async_get(hass) + if (device := device_reg.async_get(device_id)) is None: + raise ValueError(f"Device {device_id} is not a valid {DOMAIN} device.") + + return device diff --git a/homeassistant/components/lg_netcast/manifest.json b/homeassistant/components/lg_netcast/manifest.json index 8a63e064b41..cf91374feb7 100644 --- a/homeassistant/components/lg_netcast/manifest.json +++ b/homeassistant/components/lg_netcast/manifest.json @@ -1,9 +1,12 @@ { "domain": "lg_netcast", "name": "LG Netcast", - "codeowners": ["@Drafteed"], + "codeowners": ["@Drafteed", "@splinter98"], + "config_flow": true, + "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/lg_netcast", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["pylgnetcast"], - "requirements": ["pylgnetcast==0.3.7"] + "requirements": ["pylgnetcast==0.3.9"] } diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py index 9f6e88dc614..3fc07cab12b 100644 --- a/homeassistant/components/lg_netcast/media_player.py +++ b/homeassistant/components/lg_netcast/media_player.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any +from typing import TYPE_CHECKING, Any from pylgnetcast import LG_COMMAND, LgNetCastClient, LgNetCastError from requests import RequestException @@ -17,14 +17,19 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.script import Script +from homeassistant.helpers.trigger import PluggableAction from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN +from .const import ATTR_MANUFACTURER, DOMAIN +from .triggers.turn_on import async_get_turn_on_trigger DEFAULT_NAME = "LG TV Remote" @@ -54,23 +59,45 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a LG Netcast Media Player from a config_entry.""" + + host = config_entry.data[CONF_HOST] + access_token = config_entry.data[CONF_ACCESS_TOKEN] + unique_id = config_entry.unique_id + name = config_entry.data.get(CONF_NAME, DEFAULT_NAME) + model = config_entry.data[CONF_MODEL] + + client = LgNetCastClient(host, access_token) + + hass.data[DOMAIN][config_entry.entry_id] = client + + async_add_entities([LgTVDevice(client, name, model, unique_id=unique_id)]) + + +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the LG TV platform.""" host = config.get(CONF_HOST) - access_token = config.get(CONF_ACCESS_TOKEN) - name = config[CONF_NAME] - on_action = config.get(CONF_ON_ACTION) - client = LgNetCastClient(host, access_token) - on_action_script = Script(hass, on_action, name, DOMAIN) if on_action else None + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) - add_entities([LgTVDevice(client, name, on_action_script)], True) + if ( + result.get("type") == FlowResultType.ABORT + and result.get("reason") == "cannot_connect" + ): + raise PlatformNotReady(f"Connection error while connecting to {host}") class LgTVDevice(MediaPlayerEntity): @@ -79,19 +106,42 @@ class LgTVDevice(MediaPlayerEntity): _attr_assumed_state = True _attr_device_class = MediaPlayerDeviceClass.TV _attr_media_content_type = MediaType.CHANNEL + _attr_has_entity_name = True + _attr_name = None - def __init__(self, client, name, on_action_script): + def __init__(self, client, name, model, unique_id): """Initialize the LG TV device.""" self._client = client - self._name = name self._muted = False - self._on_action_script = on_action_script + self._turn_on = PluggableAction(self.async_write_ha_state) self._volume = 0 self._channel_id = None self._channel_name = "" self._program_name = "" self._sources = {} self._source_names = [] + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer=ATTR_MANUFACTURER, + name=name, + model=model, + ) + + async def async_added_to_hass(self) -> None: + """Connect and subscribe to dispatcher signals and state updates.""" + await super().async_added_to_hass() + + entry = self.registry_entry + + if TYPE_CHECKING: + assert entry is not None and entry.device_id is not None + + self.async_on_remove( + self._turn_on.async_register( + self.hass, async_get_turn_on_trigger(entry.device_id) + ) + ) def send_command(self, command): """Send remote control commands to the TV.""" @@ -151,11 +201,6 @@ class LgTVDevice(MediaPlayerEntity): self._volume = volume self._muted = muted - @property - def name(self): - """Return the name of the device.""" - return self._name - @property def is_volume_muted(self): """Boolean if volume is currently muted.""" @@ -194,7 +239,7 @@ class LgTVDevice(MediaPlayerEntity): @property def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" - if self._on_action_script: + if self._turn_on: return SUPPORT_LGTV | MediaPlayerEntityFeature.TURN_ON return SUPPORT_LGTV @@ -209,10 +254,9 @@ class LgTVDevice(MediaPlayerEntity): """Turn off media player.""" self.send_command(LG_COMMAND.POWER) - def turn_on(self) -> None: + async def async_turn_on(self) -> None: """Turn on the media player.""" - if self._on_action_script: - self._on_action_script.run(context=self._context) + await self._turn_on.async_run(self.hass, self._context) def volume_up(self) -> None: """Volume up the media player.""" diff --git a/homeassistant/components/lg_netcast/strings.json b/homeassistant/components/lg_netcast/strings.json new file mode 100644 index 00000000000..77003f60f43 --- /dev/null +++ b/homeassistant/components/lg_netcast/strings.json @@ -0,0 +1,46 @@ +{ + "config": { + "step": { + "user": { + "description": "Ensure that your TV is turned on before trying to set it up.\nIf you leave the host empty, discovery will be used to find devices.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the LG Netcast TV to control." + } + }, + "authorize": { + "title": "Authorize LG Netcast TV", + "description": "Enter the Pairing Key displayed on the TV", + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + } + } + }, + "error": { + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The {integration_title} is not online for YAML migration to complete", + "description": "Migrating {integration_title} from YAML cannot complete until the TV is online.\n\nPlease turn on your TV for migration to complete." + }, + "deprecated_yaml_import_issue_invalid_host": { + "title": "The {integration_title} YAML configuration has an invalid host.", + "description": "Configuring {integration_title} using YAML is being removed but the device returned an invalid response.\n\nPlease check or manually remove the YAML configuration." + } + }, + "device_automation": { + "trigger_type": { + "lg_netcast.turn_on": "Device is requested to turn on" + } + } +} diff --git a/homeassistant/components/lg_netcast/trigger.py b/homeassistant/components/lg_netcast/trigger.py new file mode 100644 index 00000000000..8dfbe309e03 --- /dev/null +++ b/homeassistant/components/lg_netcast/trigger.py @@ -0,0 +1,49 @@ +"""LG Netcast TV trigger dispatcher.""" + +from __future__ import annotations + +from typing import cast + +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.trigger import ( + TriggerActionType, + TriggerInfo, + TriggerProtocol, +) +from homeassistant.helpers.typing import ConfigType + +from .triggers import turn_on + +TRIGGERS = { + "turn_on": turn_on, +} + + +def _get_trigger_platform(config: ConfigType) -> TriggerProtocol: + """Return trigger platform.""" + platform_split = config[CONF_PLATFORM].split(".", maxsplit=1) + if len(platform_split) < 2 or platform_split[1] not in TRIGGERS: + raise ValueError( + f"Unknown LG Netcast TV trigger platform {config[CONF_PLATFORM]}" + ) + return cast(TriggerProtocol, TRIGGERS[platform_split[1]]) + + +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + platform = _get_trigger_platform(config) + return cast(ConfigType, platform.TRIGGER_SCHEMA(config)) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach trigger of specified platform.""" + platform = _get_trigger_platform(config) + return await platform.async_attach_trigger(hass, config, action, trigger_info) diff --git a/homeassistant/components/lg_netcast/triggers/__init__.py b/homeassistant/components/lg_netcast/triggers/__init__.py new file mode 100644 index 00000000000..d352620118e --- /dev/null +++ b/homeassistant/components/lg_netcast/triggers/__init__.py @@ -0,0 +1 @@ +"""LG Netcast triggers.""" diff --git a/homeassistant/components/lg_netcast/triggers/turn_on.py b/homeassistant/components/lg_netcast/triggers/turn_on.py new file mode 100644 index 00000000000..118ed89797e --- /dev/null +++ b/homeassistant/components/lg_netcast/triggers/turn_on.py @@ -0,0 +1,115 @@ +"""LG Netcast TV device turn on trigger.""" + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.trigger import ( + PluggableAction, + TriggerActionType, + TriggerInfo, +) +from homeassistant.helpers.typing import ConfigType + +from ..const import DOMAIN +from ..helpers import async_get_device_entry_by_device_id + +PLATFORM_TYPE = f"{DOMAIN}.{__name__.rsplit('.', maxsplit=1)[-1]}" + +TRIGGER_SCHEMA = vol.All( + cv.TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): PLATFORM_TYPE, + vol.Optional(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + }, + ), + cv.has_at_least_one_key(ATTR_ENTITY_ID, ATTR_DEVICE_ID), +) + + +def async_get_turn_on_trigger(device_id: str) -> dict[str, str]: + """Return data for a turn on trigger.""" + + return { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: PLATFORM_TYPE, + } + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, + *, + platform_type: str = PLATFORM_TYPE, +) -> CALLBACK_TYPE | None: + """Attach a trigger.""" + device_ids = set() + if ATTR_DEVICE_ID in config: + device_ids.update(config.get(ATTR_DEVICE_ID, [])) + + if ATTR_ENTITY_ID in config: + ent_reg = er.async_get(hass) + + def _get_device_id_from_entity_id(entity_id): + entity_entry = ent_reg.async_get(entity_id) + + if ( + entity_entry is None + or entity_entry.device_id is None + or entity_entry.platform != DOMAIN + ): + raise ValueError(f"Entity {entity_id} is not a valid {DOMAIN} entity.") + + return entity_entry.device_id + + device_ids.update( + { + _get_device_id_from_entity_id(entity_id) + for entity_id in config.get(ATTR_ENTITY_ID, []) + } + ) + + trigger_data = trigger_info["trigger_data"] + + unsubs = [] + + for device_id in device_ids: + device = async_get_device_entry_by_device_id(hass, device_id) + device_name = device.name_by_user or device.name + + variables = { + **trigger_data, + CONF_PLATFORM: platform_type, + ATTR_DEVICE_ID: device_id, + "description": f"lg netcast turn on trigger for {device_name}", + } + + turn_on_trigger = async_get_turn_on_trigger(device_id) + + unsubs.append( + PluggableAction.async_attach_trigger( + hass, turn_on_trigger, action, {"trigger": variables} + ) + ) + + @callback + def async_remove() -> None: + """Remove state listeners async.""" + for unsub in unsubs: + unsub() + unsubs.clear() + + return async_remove diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index 84ef3a2b7db..349e4f871a3 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -12,6 +12,7 @@ from aiolyric import Lyric from aiolyric.exceptions import LyricAuthenticationException, LyricException from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation +from aiolyric.objects.priority import LyricAccessories, LyricRoom from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -77,6 +78,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: async with asyncio.timeout(60): await lyric.get_locations() + await asyncio.gather( + *( + lyric.get_thermostat_rooms(location.locationID, device.deviceID) + for location in lyric.locations + for device in location.devices + if device.deviceClass == "Thermostat" + ) + ) + except LyricAuthenticationException as exception: # Attempt to refresh the token before failing. # Honeywell appear to have issues keeping tokens saved. @@ -159,8 +169,43 @@ class LyricDeviceEntity(LyricEntity): def device_info(self) -> DeviceInfo: """Return device information about this Honeywell Lyric instance.""" return DeviceInfo( + identifiers={(dr.CONNECTION_NETWORK_MAC, self._mac_id)}, connections={(dr.CONNECTION_NETWORK_MAC, self._mac_id)}, manufacturer="Honeywell", model=self.device.deviceModel, - name=self.device.name, + name=f"{self.device.name} Thermostat", + ) + + +class LyricAccessoryEntity(LyricDeviceEntity): + """Defines a Honeywell Lyric accessory entity, a sub-device of a thermostat.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[Lyric], + location: LyricLocation, + device: LyricDevice, + room: LyricRoom, + accessory: LyricAccessories, + key: str, + ) -> None: + """Initialize the Honeywell Lyric accessory entity.""" + super().__init__(coordinator, location, device, key) + self._room = room + self._accessory = accessory + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this Honeywell Lyric instance.""" + return DeviceInfo( + identifiers={ + ( + f"{dr.CONNECTION_NETWORK_MAC}_room_accessory", + f"{self._mac_id}_room{self._room.id}_accessory{self._accessory.id}", + ) + }, + manufacturer="Honeywell", + model="RCHTSENSOR", + name=f"{self._room.roomName} Sensor", + via_device=(dr.CONNECTION_NETWORK_MAC, self._mac_id), ) diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index 276336e02cc..64f60fa6611 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta from aiolyric import Lyric from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation +from aiolyric.objects.priority import LyricAccessories, LyricRoom from homeassistant.components.sensor import ( SensorDeviceClass, @@ -24,7 +25,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util -from . import LyricDeviceEntity +from . import LyricAccessoryEntity, LyricDeviceEntity from .const import ( DOMAIN, PRESET_HOLD_UNTIL, @@ -50,6 +51,14 @@ class LyricSensorEntityDescription(SensorEntityDescription): suitable_fn: Callable[[LyricDevice], bool] +@dataclass(frozen=True, kw_only=True) +class LyricSensorAccessoryEntityDescription(SensorEntityDescription): + """Class describing Honeywell Lyric room sensor entities.""" + + value_fn: Callable[[LyricRoom, LyricAccessories], StateType | datetime] + suitable_fn: Callable[[LyricRoom, LyricAccessories], bool] + + DEVICE_SENSORS: list[LyricSensorEntityDescription] = [ LyricSensorEntityDescription( key="indoor_temperature", @@ -109,6 +118,26 @@ DEVICE_SENSORS: list[LyricSensorEntityDescription] = [ ), ] +ACCESSORY_SENSORS: list[LyricSensorAccessoryEntityDescription] = [ + LyricSensorAccessoryEntityDescription( + key="room_temperature", + translation_key="room_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda _, accessory: accessory.temperature, + suitable_fn=lambda _, accessory: accessory.type == "IndoorAirSensor", + ), + LyricSensorAccessoryEntityDescription( + key="room_humidity", + translation_key="room_humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda room, _: room.roomAvgHumidity, + suitable_fn=lambda _, accessory: accessory.type == "IndoorAirSensor", + ), +] + def get_setpoint_status(status: str, time: str) -> str | None: """Get status of the setpoint.""" @@ -147,6 +176,18 @@ async def async_setup_entry( if device_sensor.suitable_fn(device) ) + async_add_entities( + LyricAccessorySensor( + coordinator, accessory_sensor, location, device, room, accessory + ) + for location in coordinator.data.locations + for device in location.devices + for room in coordinator.data.rooms_dict.get(device.macID, {}).values() + for accessory in room.accessories + for accessory_sensor in ACCESSORY_SENSORS + if accessory_sensor.suitable_fn(room, accessory) + ) + class LyricSensor(LyricDeviceEntity, SensorEntity): """Define a Honeywell Lyric sensor.""" @@ -178,3 +219,40 @@ class LyricSensor(LyricDeviceEntity, SensorEntity): def native_value(self) -> StateType | datetime: """Return the state.""" return self.entity_description.value_fn(self.device) + + +class LyricAccessorySensor(LyricAccessoryEntity, SensorEntity): + """Define a Honeywell Lyric sensor.""" + + entity_description: LyricSensorAccessoryEntityDescription + + def __init__( + self, + coordinator: DataUpdateCoordinator[Lyric], + description: LyricSensorAccessoryEntityDescription, + location: LyricLocation, + parentDevice: LyricDevice, + room: LyricRoom, + accessory: LyricAccessories, + ) -> None: + """Initialize.""" + super().__init__( + coordinator, + location, + parentDevice, + room, + accessory, + f"{parentDevice.macID}_room{room.id}_acc{accessory.id}_{description.key}", + ) + self.room = room + self.entity_description = description + if description.device_class == SensorDeviceClass.TEMPERATURE: + if parentDevice.units == "Fahrenheit": + self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT + else: + self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + + @property + def native_value(self) -> StateType | datetime: + """Return the state.""" + return self.entity_description.value_fn(self._room, self._accessory) diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 68bb6292f9e..739ad7fad68 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -41,6 +41,12 @@ }, "setpoint_status": { "name": "Setpoint status" + }, + "room_temperature": { + "name": "Room temperature" + }, + "room_humidity": { + "name": "Room humidity" } } }, diff --git a/homeassistant/components/medcom_ble/strings.json b/homeassistant/components/medcom_ble/strings.json index 56cfb5a1dd7..4f2b29b7269 100644 --- a/homeassistant/components/medcom_ble/strings.json +++ b/homeassistant/components/medcom_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index 888265e8d3c..228a012a04f 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -17,18 +17,29 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, ) from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +from .const import ( + ATTR_FORMAT_QUERY, + ATTR_URL, + DEFAULT_STREAM_QUERY, + DOMAIN, + SERVICE_EXTRACT_MEDIA_URL, +) + _LOGGER = logging.getLogger(__name__) CONF_CUSTOMIZE_ENTITIES = "customize" CONF_DEFAULT_STREAM_QUERY = "default_query" -DEFAULT_STREAM_QUERY = "best" -DOMAIN = "media_extractor" - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -47,10 +58,62 @@ CONFIG_SCHEMA = vol.Schema( def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the media extractor service.""" + async def extract_media_url(call: ServiceCall) -> ServiceResponse: + """Extract media url.""" + youtube_dl = YoutubeDL( + {"quiet": True, "logger": _LOGGER, "format": call.data[ATTR_FORMAT_QUERY]} + ) + + def extract_info() -> dict[str, Any]: + return cast( + dict[str, Any], + youtube_dl.extract_info( + call.data[ATTR_URL], download=False, process=False + ), + ) + + result = await hass.async_add_executor_job(extract_info) + if "entries" in result: + _LOGGER.warning("Playlists are not supported, looking for the first video") + entries = list(result["entries"]) + if entries: + selected_media = entries[0] + else: + raise HomeAssistantError("Playlist is empty") + else: + selected_media = result + if "formats" in selected_media: + if selected_media["extractor"] == "youtube": + url = get_best_stream_youtube(selected_media["formats"]) + else: + url = get_best_stream(selected_media["formats"]) + else: + url = cast(str, selected_media["url"]) + return {"url": url} + def play_media(call: ServiceCall) -> None: """Get stream URL and send it to the play_media service.""" MediaExtractor(hass, config[DOMAIN], call.data).extract_and_send() + default_format_query = config.get(DOMAIN, {}).get( + CONF_DEFAULT_STREAM_QUERY, DEFAULT_STREAM_QUERY + ) + + hass.services.async_register( + DOMAIN, + SERVICE_EXTRACT_MEDIA_URL, + extract_media_url, + schema=vol.Schema( + { + vol.Required(ATTR_URL): cv.string, + vol.Optional( + ATTR_FORMAT_QUERY, default=default_format_query + ): cv.string, + } + ), + supports_response=SupportsResponse.ONLY, + ) + hass.services.register( DOMAIN, SERVICE_PLAY_MEDIA, diff --git a/homeassistant/components/media_extractor/const.py b/homeassistant/components/media_extractor/const.py new file mode 100644 index 00000000000..009ab37602c --- /dev/null +++ b/homeassistant/components/media_extractor/const.py @@ -0,0 +1,9 @@ +"""Constants for media_extractor.""" + +DEFAULT_STREAM_QUERY = "best" +DOMAIN = "media_extractor" + +ATTR_URL = "url" +ATTR_FORMAT_QUERY = "format_query" + +SERVICE_EXTRACT_MEDIA_URL = "extract_media_url" diff --git a/homeassistant/components/media_extractor/icons.json b/homeassistant/components/media_extractor/icons.json index 71b65e7c4a6..7abc4410b19 100644 --- a/homeassistant/components/media_extractor/icons.json +++ b/homeassistant/components/media_extractor/icons.json @@ -1,5 +1,6 @@ { "services": { - "play_media": "mdi:play" + "play_media": "mdi:play", + "extract_media_url": "mdi:link" } } diff --git a/homeassistant/components/media_extractor/services.yaml b/homeassistant/components/media_extractor/services.yaml index 8af2d12d0e9..abfe52dc4f5 100644 --- a/homeassistant/components/media_extractor/services.yaml +++ b/homeassistant/components/media_extractor/services.yaml @@ -19,3 +19,14 @@ play_media: - "MUSIC" - "TVSHOW" - "VIDEO" +extract_media_url: + fields: + url: + required: true + example: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + selector: + text: + format_query: + example: "best" + selector: + text: diff --git a/homeassistant/components/media_extractor/strings.json b/homeassistant/components/media_extractor/strings.json index 0cdffd5d508..1af84b5b8c8 100644 --- a/homeassistant/components/media_extractor/strings.json +++ b/homeassistant/components/media_extractor/strings.json @@ -13,6 +13,20 @@ "description": "The type of the content to play. Must be one of MUSIC, TVSHOW, VIDEO, EPISODE, CHANNEL or PLAYLIST MUSIC." } } + }, + "extract_media_url": { + "name": "Get Media URL", + "description": "Extract media url from a service.", + "fields": { + "url": { + "name": "Media URL", + "description": "URL where the media can be found." + }, + "format_query": { + "name": "Format query", + "description": "Youtube-dl query to select the quality of the result." + } + } } } } diff --git a/homeassistant/components/moat/strings.json b/homeassistant/components/moat/strings.json index 4003debbbeb..4e12a84b653 100644 --- a/homeassistant/components/moat/strings.json +++ b/homeassistant/components/moat/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index 1cac62ce964..f1f7b592621 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -17,6 +17,7 @@ from .const import ( ATTR_SENSOR_ENTITY_CATEGORY, ATTR_SENSOR_ICON, ATTR_SENSOR_STATE, + ATTR_SENSOR_STATE_CLASS, SIGNAL_SENSOR_UPDATE, ) from .helpers import device_info @@ -44,6 +45,7 @@ class MobileAppEntity(RestoreEntity): """Update the entity from the config.""" config = self._config self._attr_device_class = config.get(ATTR_SENSOR_DEVICE_CLASS) + self._attr_state_class = config.get(ATTR_SENSOR_STATE_CLASS) self._attr_extra_state_attributes = config[ATTR_SENSOR_ATTRIBUTES] self._attr_icon = config[ATTR_SENSOR_ICON] self._attr_entity_category = config.get(ATTR_SENSOR_ENTITY_CATEGORY) diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index fd93185b891..72d7a3ec5f1 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -88,7 +88,7 @@ }, "duplicate_entity_entry": { "title": "Modbus {sub_1} address {sub_2} is duplicate, second entry not loaded.", - "description": "An address can only be associated with on entity, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." + "description": "An address can only be associated with one entity, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue." }, "duplicate_entity_name": { "title": "Modbus {sub_1} is duplicate, second entry not loaded.", diff --git a/homeassistant/components/mopeka/strings.json b/homeassistant/components/mopeka/strings.json index d1d544c2381..16a80220a20 100644 --- a/homeassistant/components/mopeka/strings.json +++ b/homeassistant/components/mopeka/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 2500923ca9b..7244a41e975 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -45,6 +45,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( Platform.LAWN_MOWER.value: vol.All(cv.ensure_list, [dict]), Platform.LIGHT.value: vol.All(cv.ensure_list, [dict]), Platform.LOCK.value: vol.All(cv.ensure_list, [dict]), + Platform.NOTIFY.value: vol.All(cv.ensure_list, [dict]), Platform.NUMBER.value: vol.All(cv.ensure_list, [dict]), Platform.SCENE.value: vol.All(cv.ensure_list, [dict]), Platform.SELECT.value: vol.All(cv.ensure_list, [dict]), diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 82320cd2f11..7eca266edfa 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -157,6 +157,7 @@ RELOADABLE_PLATFORMS = [ Platform.LIGHT, Platform.LAWN_MOWER, Platform.LOCK, + Platform.NOTIFY, Platform.NUMBER, Platform.SCENE, Platform.SELECT, diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 43f4f8cfd46..e330cd9b44b 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -66,6 +66,7 @@ SUPPORTED_COMPONENTS = { "lawn_mower", "light", "lock", + "notify", "number", "scene", "siren", diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py new file mode 100644 index 00000000000..b7a17f07f7f --- /dev/null +++ b/homeassistant/components/mqtt/notify.py @@ -0,0 +1,95 @@ +"""Support for MQTT notify.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components import notify +from homeassistant.components.notify import NotifyEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType + +from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA +from .const import ( + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_ENCODING, + CONF_QOS, + CONF_RETAIN, +) +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entity_entry_helper, +) +from .models import MqttCommandTemplate +from .util import valid_publish_topic + +DEFAULT_NAME = "MQTT notify" + +PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( + { + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Required(CONF_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT notify through YAML and through MQTT discovery.""" + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttNotify, + notify.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + ) + + +class MqttNotify(MqttEntity, NotifyEntity): + """Representation of a notification entity service that can send messages using MQTT.""" + + _default_name = DEFAULT_NAME + _entity_id_format = notify.ENTITY_ID_FORMAT + + @staticmethod + def config_schema() -> vol.Schema: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._command_template = MqttCommandTemplate( + config.get(CONF_COMMAND_TEMPLATE), entity=self + ).async_render + + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + + async def async_send_message(self, message: str) -> None: + """Send a message.""" + payload = self._command_template(message) + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 89244642207..354066e2d87 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -20,5 +20,5 @@ "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], "quality_scale": "platinum", - "requirements": ["google-nest-sdm==3.0.3"] + "requirements": ["google-nest-sdm==3.0.4"] } diff --git a/homeassistant/components/oncue/__init__.py b/homeassistant/components/oncue/__init__.py index b3d59f50321..f960b1a8b81 100644 --- a/homeassistant/components/oncue/__init__.py +++ b/homeassistant/components/oncue/__init__.py @@ -5,12 +5,12 @@ from __future__ import annotations from datetime import timedelta import logging -from aiooncue import LoginFailedException, Oncue +from aiooncue import LoginFailedException, Oncue, OncueDevice from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -29,17 +29,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await client.async_login() except CONNECTION_EXCEPTIONS as ex: - raise ConfigEntryNotReady(ex) from ex + raise ConfigEntryNotReady from ex except LoginFailedException as ex: - _LOGGER.error("Failed to login to oncue service: %s", ex) - return False + raise ConfigEntryAuthFailed from ex + + async def _async_update() -> dict[str, OncueDevice]: + """Fetch data from Oncue.""" + try: + return await client.async_fetch_all() + except LoginFailedException as ex: + raise ConfigEntryAuthFailed from ex coordinator = DataUpdateCoordinator( hass, _LOGGER, name=f"Oncue {entry.data[CONF_USERNAME]}", update_interval=timedelta(minutes=10), - update_method=client.async_fetch_all, + update_method=_async_update, always_update=False, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/oncue/config_flow.py b/homeassistant/components/oncue/config_flow.py index ba672dcc588..e423ba08105 100644 --- a/homeassistant/components/oncue/config_flow.py +++ b/homeassistant/components/oncue/config_flow.py @@ -2,13 +2,14 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from aiooncue import LoginFailedException, Oncue import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -22,30 +23,26 @@ class OncueConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize the oncue config flow.""" + self.reauth_entry: ConfigEntry | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: - try: - await Oncue( - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - async_get_clientsession(self.hass), - ).async_login() - except CONNECTION_EXCEPTIONS: - errors["base"] = "cannot_connect" - except LoginFailedException: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + if not (errors := await self._async_validate_or_error(user_input)): normalized_username = user_input[CONF_USERNAME].lower() await self.async_set_unique_id(normalized_username) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured( + updates={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + ) return self.async_create_entry( title=normalized_username, data=user_input ) @@ -60,3 +57,54 @@ class OncueConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def _async_validate_or_error(self, config: dict[str, Any]) -> dict[str, str]: + """Validate the user input.""" + errors: dict[str, str] = {} + try: + await Oncue( + config[CONF_USERNAME], + config[CONF_PASSWORD], + async_get_clientsession(self.hass), + ).async_login() + except CONNECTION_EXCEPTIONS: + errors["base"] = "cannot_connect" + except LoginFailedException: + errors[CONF_PASSWORD] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return errors + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth.""" + entry_id = self.context["entry_id"] + self.reauth_entry = self.hass.config_entries.async_get_entry(entry_id) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth input.""" + errors: dict[str, str] = {} + existing_entry = self.reauth_entry + assert existing_entry + existing_data = existing_entry.data + description_placeholders: dict[str, str] = { + CONF_USERNAME: existing_data[CONF_USERNAME] + } + if user_input is not None: + new_config = {**existing_data, CONF_PASSWORD: user_input[CONF_PASSWORD]} + if not (errors := await self._async_validate_or_error(new_config)): + return self.async_update_reload_and_abort( + existing_entry, data=new_config + ) + + return self.async_show_form( + description_placeholders=description_placeholders, + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + errors=errors, + ) diff --git a/homeassistant/components/oncue/strings.json b/homeassistant/components/oncue/strings.json index f7a539fe0e6..ce7561962a2 100644 --- a/homeassistant/components/oncue/strings.json +++ b/homeassistant/components/oncue/strings.json @@ -6,6 +6,12 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "description": "Re-authenticate Oncue account {username}", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -14,7 +20,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/homeassistant/components/oralb/strings.json b/homeassistant/components/oralb/strings.json index f60fd56a9a4..775bbedac74 100644 --- a/homeassistant/components/oralb/strings.json +++ b/homeassistant/components/oralb/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/pi_hole/manifest.json b/homeassistant/components/pi_hole/manifest.json index 99439ba3a17..975d8a1494c 100644 --- a/homeassistant/components/pi_hole/manifest.json +++ b/homeassistant/components/pi_hole/manifest.json @@ -1,7 +1,7 @@ { "domain": "pi_hole", "name": "Pi-hole", - "codeowners": ["@johnluetke", "@shenxn"], + "codeowners": ["@shenxn"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/pi_hole", "iot_class": "local_polling", diff --git a/homeassistant/components/private_ble_device/strings.json b/homeassistant/components/private_ble_device/strings.json index 9e20a9476ec..c35775a4843 100644 --- a/homeassistant/components/private_ble_device/strings.json +++ b/homeassistant/components/private_ble_device/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "What is the IRK (Identity Resolving Key) of the BLE device you want to track?", diff --git a/homeassistant/components/qingping/strings.json b/homeassistant/components/qingping/strings.json index d1d544c2381..16a80220a20 100644 --- a/homeassistant/components/qingping/strings.json +++ b/homeassistant/components/qingping/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index eb7a84867ab..e6248b2c93b 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -2,6 +2,7 @@ from abc import abstractmethod import logging +from typing import Any from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -15,16 +16,21 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( DOMAIN as DOMAIN_RACHIO, + KEY_BATTERY_STATUS, KEY_DEVICE_ID, + KEY_LOW, KEY_RAIN_SENSOR_TRIPPED, + KEY_REPORTED_STATE, + KEY_STATE, KEY_STATUS, KEY_SUBTYPE, SIGNAL_RACHIO_CONTROLLER_UPDATE, SIGNAL_RACHIO_RAIN_SENSOR_UPDATE, STATUS_ONLINE, ) +from .coordinator import RachioUpdateCoordinator from .device import RachioPerson -from .entity import RachioDevice +from .entity import RachioDevice, RachioHoseTimerEntity from .webhooks import ( SUBTYPE_COLD_REBOOT, SUBTYPE_OFFLINE, @@ -52,6 +58,11 @@ def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Ent for controller in person.controllers: entities.append(RachioControllerOnlineBinarySensor(controller)) entities.append(RachioRainSensor(controller)) + entities.extend( + RachioHoseTimerBattery(valve, base_station.coordinator) + for base_station in person.base_stations + for valve in base_station.coordinator.data.values() + ) return entities @@ -140,3 +151,24 @@ class RachioRainSensor(RachioControllerBinarySensor): self._async_handle_any_update, ) ) + + +class RachioHoseTimerBattery(RachioHoseTimerEntity, BinarySensorEntity): + """Represents a battery sensor for a smart hose timer.""" + + _attr_device_class = BinarySensorDeviceClass.BATTERY + + def __init__( + self, data: dict[str, Any], coordinator: RachioUpdateCoordinator + ) -> None: + """Initialize a smart hose timer battery sensor.""" + super().__init__(data, coordinator) + self._attr_unique_id = f"{self.id}-battery" + + @callback + def _update_attr(self) -> None: + """Handle updated coordinator data.""" + data = self.coordinator.data[self.id] + + self._static_attrs = data[KEY_STATE][KEY_REPORTED_STATE] + self._attr_is_on = self._static_attrs[KEY_BATTERY_STATUS] == KEY_LOW diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py index 22c92be2b74..b9b16c0cd87 100644 --- a/homeassistant/components/rachio/const.py +++ b/homeassistant/components/rachio/const.py @@ -57,6 +57,7 @@ KEY_CONNECTED = "connected" KEY_CURRENT_STATUS = "lastWateringAction" KEY_DETECT_FLOW = "detectFlow" KEY_BATTERY_STATUS = "batteryStatus" +KEY_LOW = "LOW" KEY_REASON = "reason" KEY_DEFAULT_RUNTIME = "defaultRuntimeSeconds" KEY_DURATION_SECONDS = "durationSeconds" diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index c018d7e6f86..09f7eaf1b06 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -350,11 +350,9 @@ class RachioBaseStation: def __init__( self, rachio: Rachio, data: dict[str, Any], coordinator: RachioUpdateCoordinator ) -> None: - """Initialize a hose time base station.""" + """Initialize a smart hose timer base station.""" self.rachio = rachio self._id = data[KEY_ID] - self.serial_number = data[KEY_SERIAL_NUMBER] - self.mac_address = data[KEY_MAC_ADDRESS] self.coordinator = coordinator def start_watering(self, valve_id: str, duration: int) -> None: diff --git a/homeassistant/components/rachio/entity.py b/homeassistant/components/rachio/entity.py index fc0dc1f1aae..056abe9145b 100644 --- a/homeassistant/components/rachio/entity.py +++ b/homeassistant/components/rachio/entity.py @@ -1,10 +1,24 @@ """Adapter to wrap the rachiopy api for home assistant.""" +from abc import abstractmethod +from typing import Any + +from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_NAME, DOMAIN +from .const import ( + DEFAULT_NAME, + DOMAIN, + KEY_CONNECTED, + KEY_ID, + KEY_NAME, + KEY_REPORTED_STATE, + KEY_STATE, +) +from .coordinator import RachioUpdateCoordinator from .device import RachioIro @@ -35,3 +49,45 @@ class RachioDevice(Entity): manufacturer=DEFAULT_NAME, configuration_url="https://app.rach.io", ) + + +class RachioHoseTimerEntity(CoordinatorEntity[RachioUpdateCoordinator]): + """Base class for smart hose timer entities.""" + + _attr_has_entity_name = True + + def __init__( + self, data: dict[str, Any], coordinator: RachioUpdateCoordinator + ) -> None: + """Initialize a Rachio smart hose timer entity.""" + super().__init__(coordinator) + self.id = data[KEY_ID] + self._name = data[KEY_NAME] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.id)}, + model="Smart Hose Timer", + name=self._name, + manufacturer=DEFAULT_NAME, + configuration_url="https://app.rach.io", + ) + self._update_attr() + + @property + def available(self) -> bool: + """Return if the entity is available.""" + return ( + super().available + and self.coordinator.data[self.id][KEY_STATE][KEY_REPORTED_STATE][ + KEY_CONNECTED + ] + ) + + @abstractmethod + def _update_attr(self) -> None: + """Update the state and attributes.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attr() + super()._handle_coordinator_update() diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index fe3d455df3c..1a8dbe42904 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -13,25 +13,17 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, ATTR_ID from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_platform, -) -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import as_timestamp, now, parse_datetime, utc_from_timestamp from .const import ( CONF_MANUAL_RUN_MINS, DEFAULT_MANUAL_RUN_MINS, - DEFAULT_NAME, DOMAIN as DOMAIN_RACHIO, - KEY_CONNECTED, KEY_CURRENT_STATUS, KEY_CUSTOM_CROP, KEY_CUSTOM_SHADE, @@ -67,9 +59,8 @@ from .const import ( SLOPE_SLIGHT, SLOPE_STEEP, ) -from .coordinator import RachioUpdateCoordinator from .device import RachioPerson -from .entity import RachioDevice +from .entity import RachioDevice, RachioHoseTimerEntity from .webhooks import ( SUBTYPE_RAIN_DELAY_OFF, SUBTYPE_RAIN_DELAY_ON, @@ -546,39 +537,17 @@ class RachioSchedule(RachioSwitch): ) -class RachioValve(CoordinatorEntity[RachioUpdateCoordinator], SwitchEntity): +class RachioValve(RachioHoseTimerEntity, SwitchEntity): """Representation of one smart hose timer valve.""" - def __init__( - self, person, base, data, coordinator: RachioUpdateCoordinator - ) -> None: + _attr_name = None + + def __init__(self, person, base, data, coordinator) -> None: """Initialize a new smart hose valve.""" - super().__init__(coordinator) + super().__init__(data, coordinator) self._person = person self._base = base - self.id = data[KEY_ID] - self._attr_name = data[KEY_NAME] self._attr_unique_id = f"{self.id}-valve" - self._static_attrs = data[KEY_STATE][KEY_REPORTED_STATE] - self._attr_is_on = KEY_CURRENT_STATUS in self._static_attrs - self._attr_device_info = DeviceInfo( - identifiers={ - ( - DOMAIN_RACHIO, - self.id, - ) - }, - connections={(dr.CONNECTION_NETWORK_MAC, self._base.mac_address)}, - manufacturer=DEFAULT_NAME, - model="Smart Hose Timer", - name=self._attr_name, - configuration_url="https://app.rach.io", - ) - - @property - def available(self) -> bool: - """Return if the valve is available.""" - return super().available and self._static_attrs[KEY_CONNECTED] def turn_on(self, **kwargs: Any) -> None: """Turn on this valve.""" @@ -594,20 +563,19 @@ class RachioValve(CoordinatorEntity[RachioUpdateCoordinator], SwitchEntity): self._base.start_watering(self.id, manual_run_time.seconds) self._attr_is_on = True self.schedule_update_ha_state(force_refresh=True) - _LOGGER.debug("Starting valve %s for %s", self.name, str(manual_run_time)) + _LOGGER.debug("Starting valve %s for %s", self._name, str(manual_run_time)) def turn_off(self, **kwargs: Any) -> None: """Turn off this valve.""" self._base.stop_watering(self.id) self._attr_is_on = False self.schedule_update_ha_state(force_refresh=True) - _LOGGER.debug("Stopping watering on valve %s", self.name) + _LOGGER.debug("Stopping watering on valve %s", self._name) @callback - def _handle_coordinator_update(self) -> None: + def _update_attr(self) -> None: """Handle updated coordinator data.""" data = self.coordinator.data[self.id] self._static_attrs = data[KEY_STATE][KEY_REPORTED_STATE] self._attr_is_on = KEY_CURRENT_STATUS in self._static_attrs - super()._handle_coordinator_update() diff --git a/homeassistant/components/rapt_ble/strings.json b/homeassistant/components/rapt_ble/strings.json index 4003debbbeb..4e12a84b653 100644 --- a/homeassistant/components/rapt_ble/strings.json +++ b/homeassistant/components/rapt_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 98e1c8b1e7c..9891c838950 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "platinum", - "requirements": ["renault-api==0.2.1"] + "requirements": ["renault-api==0.2.2"] } diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 1c33b4592df..b7cdee2e039 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -45,6 +45,7 @@ from homeassistant.util.async_ import create_eager_task from .const import ( CONF_ENCODING, + CONF_PAYLOAD_TEMPLATE, CONF_SSL_CIPHER_LIST, COORDINATOR, DEFAULT_SSL_CIPHER_LIST, @@ -108,8 +109,11 @@ async def _async_process_config(hass: HomeAssistant, config: ConfigType) -> bool for rest_idx, conf in enumerate(rest_config): scan_interval: timedelta = conf.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) resource_template: template.Template | None = conf.get(CONF_RESOURCE_TEMPLATE) + payload_template: template.Template | None = conf.get(CONF_PAYLOAD_TEMPLATE) rest = create_rest_data_from_config(hass, conf) - coordinator = _rest_coordinator(hass, rest, resource_template, scan_interval) + coordinator = _rest_coordinator( + hass, rest, resource_template, payload_template, scan_interval + ) refresh_coroutines.append(coordinator.async_refresh()) hass.data[DOMAIN][REST_DATA].append({REST: rest, COORDINATOR: coordinator}) @@ -156,16 +160,20 @@ def _rest_coordinator( hass: HomeAssistant, rest: RestData, resource_template: template.Template | None, + payload_template: template.Template | None, update_interval: timedelta, ) -> DataUpdateCoordinator[None]: """Wrap a DataUpdateCoordinator around the rest object.""" - if resource_template: + if resource_template or payload_template: - async def _async_refresh_with_resource_template() -> None: - rest.set_url(resource_template.async_render(parse_result=False)) + async def _async_refresh_with_templates() -> None: + if resource_template: + rest.set_url(resource_template.async_render(parse_result=False)) + if payload_template: + rest.set_payload(payload_template.async_render(parse_result=False)) await rest.async_update() - update_method = _async_refresh_with_resource_template + update_method = _async_refresh_with_templates else: update_method = rest.async_update @@ -184,6 +192,7 @@ def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> Res resource_template: template.Template | None = config.get(CONF_RESOURCE_TEMPLATE) method: str = config[CONF_METHOD] payload: str | None = config.get(CONF_PAYLOAD) + payload_template: template.Template | None = config.get(CONF_PAYLOAD_TEMPLATE) verify_ssl: bool = config[CONF_VERIFY_SSL] ssl_cipher_list: str = config.get(CONF_SSL_CIPHER_LIST, DEFAULT_SSL_CIPHER_LIST) username: str | None = config.get(CONF_USERNAME) @@ -196,6 +205,10 @@ def create_rest_data_from_config(hass: HomeAssistant, config: ConfigType) -> Res resource_template.hass = hass resource = resource_template.async_render(parse_result=False) + if payload_template is not None: + payload_template.hass = hass + payload = payload_template.async_render(parse_result=False) + if not resource: raise HomeAssistantError("Resource not set for RestData") diff --git a/homeassistant/components/rest/const.py b/homeassistant/components/rest/const.py index 8fb08f766fa..d10b3f3f74e 100644 --- a/homeassistant/components/rest/const.py +++ b/homeassistant/components/rest/const.py @@ -33,3 +33,5 @@ XML_MIME_TYPES = ( "application/xml", "text/xml", ) + +CONF_PAYLOAD_TEMPLATE = "payload_template" diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 06be7a4f6ff..4c9667e7651 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -56,6 +56,10 @@ class RestData: self.last_exception: Exception | None = None self.headers: httpx.Headers | None = None + def set_payload(self, payload: str) -> None: + """Set request data.""" + self._request_data = payload + @property def url(self) -> str: """Get url.""" diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index d6011a43efd..f7fd8a36113 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -38,6 +38,7 @@ from .const import ( CONF_ENCODING, CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH, + CONF_PAYLOAD_TEMPLATE, CONF_SSL_CIPHER_LIST, DEFAULT_ENCODING, DEFAULT_FORCE_UPDATE, @@ -60,7 +61,8 @@ RESOURCE_SCHEMA = { vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(METHODS), vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PAYLOAD): cv.string, + vol.Exclusive(CONF_PAYLOAD, CONF_PAYLOAD): cv.string, + vol.Exclusive(CONF_PAYLOAD_TEMPLATE, CONF_PAYLOAD): cv.template, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, vol.Optional( CONF_SSL_CIPHER_LIST, diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index f421b6da7ef..46a3f021122 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -149,7 +149,7 @@ SENSOR_TYPES = ( translation_key="total_energy_usage", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, ), RfxtrxSensorEntityDescription( key="Voltage", diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index d03aa68f1a6..0646f8ee083 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -8,6 +8,6 @@ "loggers": ["roborock"], "requirements": [ "python-roborock==2.0.0", - "vacuum-map-parser-roborock==0.1.1" + "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/homeassistant/components/ruuvitag_ble/strings.json b/homeassistant/components/ruuvitag_ble/strings.json index d1d544c2381..16a80220a20 100644 --- a/homeassistant/components/ruuvitag_ble/strings.json +++ b/homeassistant/components/ruuvitag_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/sanix/__init__.py b/homeassistant/components/sanix/__init__.py new file mode 100644 index 00000000000..c8c5567eedc --- /dev/null +++ b/homeassistant/components/sanix/__init__.py @@ -0,0 +1,37 @@ +"""The Sanix integration.""" + +from sanix import Sanix + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant + +from .const import CONF_SERIAL_NUMBER, DOMAIN +from .coordinator import SanixCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Sanix from a config entry.""" + + serial_no = entry.data[CONF_SERIAL_NUMBER] + token = entry.data[CONF_TOKEN] + + sanix_api = Sanix(serial_no, token) + coordinator = SanixCoordinator(hass, sanix_api) + + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/sanix/config_flow.py b/homeassistant/components/sanix/config_flow.py new file mode 100644 index 00000000000..57aa5a5293a --- /dev/null +++ b/homeassistant/components/sanix/config_flow.py @@ -0,0 +1,60 @@ +"""Config flow for Sanix integration.""" + +import logging +from typing import Any + +from sanix import Sanix +from sanix.exceptions import SanixException, SanixInvalidAuthException +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_TOKEN + +from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_SERIAL_NUMBER): str, + vol.Required(CONF_TOKEN): str, + } +) + + +class SanixConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sanix.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input: + await self.async_set_unique_id(user_input[CONF_SERIAL_NUMBER]) + self._abort_if_unique_id_configured() + + sanix_api = Sanix(user_input[CONF_SERIAL_NUMBER], user_input[CONF_TOKEN]) + + try: + await self.hass.async_add_executor_job(sanix_api.fetch_data) + except SanixInvalidAuthException: + errors["base"] = "invalid_auth" + except SanixException: + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=MANUFACTURER, + data=user_input, + ) + + return self.async_show_form( + step_id="user", + description_placeholders={"dashboard_url": "https://sanix.bitcomplex.pl/"}, + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/sanix/const.py b/homeassistant/components/sanix/const.py new file mode 100644 index 00000000000..22ab33823d6 --- /dev/null +++ b/homeassistant/components/sanix/const.py @@ -0,0 +1,8 @@ +"""Constants for the Sanix integration.""" + +CONF_SERIAL_NUMBER = "serial_number" + +DOMAIN = "sanix" +MANUFACTURER = "Sanix" + +SANIX_API_HOST = "https://sanix.bitcomplex.pl" diff --git a/homeassistant/components/sanix/coordinator.py b/homeassistant/components/sanix/coordinator.py new file mode 100644 index 00000000000..d6362337a38 --- /dev/null +++ b/homeassistant/components/sanix/coordinator.py @@ -0,0 +1,36 @@ +"""Sanix Coordinator.""" + +from datetime import timedelta +import logging + +from sanix import Sanix +from sanix.exceptions import SanixException +from sanix.models import Measurement + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + + +class SanixCoordinator(DataUpdateCoordinator[Measurement]): + """Sanix coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, sanix_api: Sanix) -> None: + """Initialize coordinator.""" + super().__init__( + hass, _LOGGER, name=MANUFACTURER, update_interval=timedelta(hours=1) + ) + self._sanix_api = sanix_api + + async def _async_update_data(self) -> Measurement: + """Fetch data from API endpoint.""" + try: + return await self.hass.async_add_executor_job(self._sanix_api.fetch_data) + except SanixException as err: + raise UpdateFailed("Error while communicating with the API") from err diff --git a/homeassistant/components/sanix/icons.json b/homeassistant/components/sanix/icons.json new file mode 100644 index 00000000000..2b49cf8ea20 --- /dev/null +++ b/homeassistant/components/sanix/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "fill_perc": { + "default": "mdi:water-percent" + } + } + } +} diff --git a/homeassistant/components/sanix/manifest.json b/homeassistant/components/sanix/manifest.json new file mode 100644 index 00000000000..4e1c6d56add --- /dev/null +++ b/homeassistant/components/sanix/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "sanix", + "name": "Sanix", + "codeowners": ["@tomaszsluszniak"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sanix", + "iot_class": "cloud_polling", + "requirements": ["sanix==1.0.5"] +} diff --git a/homeassistant/components/sanix/sensor.py b/homeassistant/components/sanix/sensor.py new file mode 100644 index 00000000000..39a1c593433 --- /dev/null +++ b/homeassistant/components/sanix/sensor.py @@ -0,0 +1,123 @@ +"""Platform for Sanix integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import date, datetime + +from sanix.const import ( + ATTR_API_BATTERY, + ATTR_API_DEVICE_NO, + ATTR_API_DISTANCE, + ATTR_API_FILL_PERC, + ATTR_API_SERVICE_DATE, + ATTR_API_SSID, +) +from sanix.models import Measurement + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfLength +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import SanixCoordinator + + +@dataclass(frozen=True, kw_only=True) +class SanixSensorEntityDescription(SensorEntityDescription): + """Class describing Sanix Sensor entities.""" + + native_value_fn: Callable[[Measurement], int | datetime | date | str] + + +SENSOR_TYPES: tuple[SanixSensorEntityDescription, ...] = ( + SanixSensorEntityDescription( + key=ATTR_API_BATTERY, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + native_value_fn=lambda data: data.battery, + ), + SanixSensorEntityDescription( + key=ATTR_API_DISTANCE, + native_unit_of_measurement=UnitOfLength.CENTIMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + native_value_fn=lambda data: data.distance, + ), + SanixSensorEntityDescription( + key=ATTR_API_SERVICE_DATE, + translation_key=ATTR_API_SERVICE_DATE, + device_class=SensorDeviceClass.DATE, + native_value_fn=lambda data: data.service_date, + ), + SanixSensorEntityDescription( + key=ATTR_API_FILL_PERC, + translation_key=ATTR_API_FILL_PERC, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_value_fn=lambda data: data.fill_perc, + ), + SanixSensorEntityDescription( + key=ATTR_API_SSID, + translation_key=ATTR_API_SSID, + entity_registry_enabled_default=False, + native_value_fn=lambda data: data.ssid, + ), + SanixSensorEntityDescription( + key=ATTR_API_DEVICE_NO, + translation_key=ATTR_API_DEVICE_NO, + entity_registry_enabled_default=False, + native_value_fn=lambda data: data.device_no, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Sanix Sensor entities based on a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + SanixSensorEntity(coordinator, description) for description in SENSOR_TYPES + ) + + +class SanixSensorEntity(CoordinatorEntity[SanixCoordinator], SensorEntity): + """Sanix Sensor entity.""" + + _attr_has_entity_name = True + entity_description: SanixSensorEntityDescription + + def __init__( + self, + coordinator: SanixCoordinator, + description: SanixSensorEntityDescription, + ) -> None: + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + serial_no = str(coordinator.config_entry.unique_id) + + self._attr_unique_id = f"{serial_no}-{description.key}" + self.entity_description = description + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_no)}, + entry_type=DeviceEntryType.SERVICE, + manufacturer=MANUFACTURER, + serial_number=serial_no, + ) + + @property + def native_value(self) -> int | datetime | date | str: + """Return the state of the sensor.""" + return self.entity_description.native_value_fn(self.coordinator.data) diff --git a/homeassistant/components/sanix/strings.json b/homeassistant/components/sanix/strings.json new file mode 100644 index 00000000000..6bff11e36af --- /dev/null +++ b/homeassistant/components/sanix/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "user": { + "description": "To get the Serial number and the Token you just have to sign in to the [Sanix Dashboard]({dashboard_url}) and open the Help -> System version page.", + "data": { + "serial_number": "Serial number", + "token": "[%key:common::config_flow::data::access_token%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "service_date": { + "name": "Service date" + }, + "fill_perc": { + "name": "Filled" + }, + "device_no": { + "name": "Device number" + }, + "ssid": { + "name": "SSID" + } + } + } +} diff --git a/homeassistant/components/sensirion_ble/strings.json b/homeassistant/components/sensirion_ble/strings.json index d1d544c2381..16a80220a20 100644 --- a/homeassistant/components/sensirion_ble/strings.json +++ b/homeassistant/components/sensirion_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/sensorpro/strings.json b/homeassistant/components/sensorpro/strings.json index d1d544c2381..16a80220a20 100644 --- a/homeassistant/components/sensorpro/strings.json +++ b/homeassistant/components/sensorpro/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/sensorpush/strings.json b/homeassistant/components/sensorpush/strings.json index 4003debbbeb..4e12a84b653 100644 --- a/homeassistant/components/sensorpush/strings.json +++ b/homeassistant/components/sensorpush/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/sms/gateway.py b/homeassistant/components/sms/gateway.py index e0cbf78dba4..1ed1f66570f 100644 --- a/homeassistant/components/sms/gateway.py +++ b/homeassistant/components/sms/gateway.py @@ -92,7 +92,6 @@ class Gateway: start = True entries = [] all_parts = -1 - all_parts_arrived = False _LOGGER.debug("Start remaining:%i", start_remaining) try: @@ -101,33 +100,31 @@ class Gateway: entry = state_machine.GetNextSMS(Folder=0, Start=True) all_parts = entry[0]["UDH"]["AllParts"] part_number = entry[0]["UDH"]["PartNumber"] - is_single_part = all_parts == 0 - is_multi_part = 0 <= all_parts < start_remaining + part_is_missing = all_parts > start_remaining _LOGGER.debug("All parts:%i", all_parts) _LOGGER.debug("Part Number:%i", part_number) _LOGGER.debug("Remaining:%i", remaining) - all_parts_arrived = is_multi_part or is_single_part - _LOGGER.debug("Start all_parts_arrived:%s", all_parts_arrived) + _LOGGER.debug("Start is_part_missing:%s", part_is_missing) start = False else: entry = state_machine.GetNextSMS( Folder=0, Location=entry[0]["Location"] ) - if all_parts_arrived or force: - remaining = remaining - 1 - entries.append(entry) - - # delete retrieved sms - _LOGGER.debug("Deleting message") - try: - state_machine.DeleteSMS(Folder=0, Location=entry[0]["Location"]) - except gammu.ERR_MEMORY_NOT_AVAILABLE: - _LOGGER.error("Error deleting SMS, memory not available") - else: + if part_is_missing and not force: _LOGGER.debug("Not all parts have arrived") break + remaining = remaining - 1 + entries.append(entry) + + # delete retrieved sms + _LOGGER.debug("Deleting message") + try: + state_machine.DeleteSMS(Folder=0, Location=entry[0]["Location"]) + except gammu.ERR_MEMORY_NOT_AVAILABLE: + _LOGGER.error("Error deleting SMS, memory not available") + except gammu.ERR_EMPTY: # error is raised if memory is empty (this induces wrong reported # memory status) diff --git a/homeassistant/components/snooz/config_flow.py b/homeassistant/components/snooz/config_flow.py index 9992a68ef69..3962a44d8b9 100644 --- a/homeassistant/components/snooz/config_flow.py +++ b/homeassistant/components/snooz/config_flow.py @@ -132,7 +132,7 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): """Wait for device to enter pairing mode.""" if not self._pairing_task: self._pairing_task = self.hass.async_create_task( - self._async_wait_for_pairing_mode(), eager_start=False + self._async_wait_for_pairing_mode() ) if not self._pairing_task.done(): diff --git a/homeassistant/components/snooz/strings.json b/homeassistant/components/snooz/strings.json index b38e105260c..5a31cea6cac 100644 --- a/homeassistant/components/snooz/strings.json +++ b/homeassistant/components/snooz/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 33dc65d5eaa..d3ce934ec51 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -11,9 +11,11 @@ from songpal import ( ContentChange, Device, PowerChange, + SettingChange, SongpalException, VolumeChange, ) +from songpal.containers import Setting import voluptuous as vol from homeassistant.components.media_player import ( @@ -99,6 +101,7 @@ class SongpalEntity(MediaPlayerEntity): | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SELECT_SOUND_MODE | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF ) @@ -124,6 +127,8 @@ class SongpalEntity(MediaPlayerEntity): self._active_source = None self._sources = {} + self._active_sound_mode = None + self._sound_modes = {} async def async_added_to_hass(self) -> None: """Run when entity is added to hass.""" @@ -133,6 +138,28 @@ class SongpalEntity(MediaPlayerEntity): """Run when entity will be removed from hass.""" await self._dev.stop_listen_notifications() + async def _get_sound_modes_info(self): + """Get available sound modes and the active one.""" + settings = await self._dev.get_sound_settings("soundField") + if isinstance(settings, Setting): + settings = [settings] + + sound_modes = {} + active_sound_mode = None + for setting in settings: + cur = setting.currentValue + for opt in setting.candidate: + if not opt.isAvailable: + continue + if opt.value == cur: + active_sound_mode = opt.value + sound_modes[opt.value] = opt + + _LOGGER.debug("Got sound modes: %s", sound_modes) + _LOGGER.debug("Active sound mode: %s", active_sound_mode) + + return active_sound_mode, sound_modes + async def async_activate_websocket(self): """Activate websocket for listening if wanted.""" _LOGGER.info("Activating websocket connection") @@ -152,6 +179,16 @@ class SongpalEntity(MediaPlayerEntity): else: _LOGGER.debug("Got non-handled content change: %s", content) + async def _setting_changed(setting: SettingChange): + _LOGGER.debug("Setting changed: %s", setting) + + if setting.target == "soundField": + self._active_sound_mode = setting.currentValue + _LOGGER.debug("New active sound mode: %s", self._active_sound_mode) + self.async_write_ha_state() + else: + _LOGGER.debug("Got non-handled setting change: %s", setting) + async def _power_changed(power: PowerChange): _LOGGER.debug("Power changed: %s", power) self._state = power.status @@ -192,6 +229,7 @@ class SongpalEntity(MediaPlayerEntity): self._dev.on_notification(VolumeChange, _volume_changed) self._dev.on_notification(ContentChange, _source_changed) self._dev.on_notification(PowerChange, _power_changed) + self._dev.on_notification(SettingChange, _setting_changed) self._dev.on_notification(ConnectChange, _try_reconnect) async def handle_stop(event): @@ -271,6 +309,11 @@ class SongpalEntity(MediaPlayerEntity): _LOGGER.debug("Active source: %s", self._active_source) + ( + self._active_sound_mode, + self._sound_modes, + ) = await self._get_sound_modes_info() + self._attr_available = True except SongpalException as ex: @@ -291,6 +334,27 @@ class SongpalEntity(MediaPlayerEntity): """Return list of available sources.""" return [src.title for src in self._sources.values()] + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Select sound mode.""" + for mode in self._sound_modes.values(): + if mode.title == sound_mode: + await self._dev.set_sound_settings("soundField", mode.value) + return + + _LOGGER.error("Unable to find sound mode: %s", sound_mode) + + @property + def sound_mode_list(self) -> list[str] | None: + """Return list of available sound modes. + + When active mode is None it means that sound mode is unavailable on the sound bar. + Can be due to incompatible sound bar or the sound bar is in a mode that does not + support sound mode changes. + """ + if not self._active_sound_mode: + return None + return [sound_mode.title for sound_mode in self._sound_modes.values()] + @property def state(self) -> MediaPlayerState: """Return current state.""" @@ -304,6 +368,12 @@ class SongpalEntity(MediaPlayerEntity): # Avoid a KeyError when _active_source is not (yet) populated return getattr(self._active_source, "title", None) + @property + def sound_mode(self) -> str | None: + """Return currently active sound_mode.""" + active_sound_mode = self._sound_modes.get(self._active_sound_mode) + return active_sound_mode.title if active_sound_mode else None + @property def volume_level(self): """Return volume level.""" diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index b6375eb7f16..ec5ef90a0c1 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco"], - "requirements": ["soco==0.30.2", "sonos-websocket==0.1.3"], + "requirements": ["soco==0.30.3", "sonos-websocket==0.1.3"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index dd44af89237..30d071f25af 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.29", "sqlparse==0.4.4"] + "requirements": ["SQLAlchemy==2.0.29", "sqlparse==0.5.0"] } diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index cb651e5c84f..2b50f39925f 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "iot_class": "cloud_polling", "loggers": ["switchbot-api"], - "requirements": ["switchbot-api==2.0.0"] + "requirements": ["switchbot-api==2.1.0"] } diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 6284a0e5368..cced1090e2a 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -449,6 +449,11 @@ class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity): """Initialize the sensor.""" super().__init__(vehicle, description.key) + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self._value + class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity): """Base class for Teslemetry vehicle metric sensors.""" diff --git a/homeassistant/components/thermobeacon/strings.json b/homeassistant/components/thermobeacon/strings.json index d1d544c2381..16a80220a20 100644 --- a/homeassistant/components/thermobeacon/strings.json +++ b/homeassistant/components/thermobeacon/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/thermopro/strings.json b/homeassistant/components/thermopro/strings.json index 4003debbbeb..4e12a84b653 100644 --- a/homeassistant/components/thermopro/strings.json +++ b/homeassistant/components/thermopro/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/tilt_ble/strings.json b/homeassistant/components/tilt_ble/strings.json index 4003debbbeb..4e12a84b653 100644 --- a/homeassistant/components/tilt_ble/strings.json +++ b/homeassistant/components/tilt_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 1f6b07365b5..d7563dd0401 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfPower, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import legacy_device_id @@ -171,8 +171,17 @@ class SmartPlugSensor(CoordinatedTPLinkEntity, SensorEntity): else: assert description.device_class self._attr_translation_key = f"{description.device_class.value}_child" + self._async_update_attrs() - @property - def native_value(self) -> float | None: - """Return the sensors state.""" - return async_emeter_from_device(self.device, self.entity_description) + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + self._attr_native_value = async_emeter_from_device( + self.device, self.entity_description + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_attrs() + super()._handle_coordinator_update() diff --git a/homeassistant/components/upc_connect/manifest.json b/homeassistant/components/upc_connect/manifest.json index 79ed768282a..02b852ec3a6 100644 --- a/homeassistant/components/upc_connect/manifest.json +++ b/homeassistant/components/upc_connect/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/upc_connect", "iot_class": "local_polling", "loggers": ["connect_box"], - "requirements": ["connect-box==0.2.8"] + "requirements": ["connect-box==0.3.1"] } diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index f92241ceace..c0564170274 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -49,6 +49,23 @@ class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysM stepping_getter: Callable[[PyViCareDevice], float | None] | None = None +DEVICE_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( + ViCareNumberEntityDescription( + key="dhw_secondary_temperature", + translation_key="dhw_secondary_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDomesticHotWaterConfiguredTemperature2(), + value_setter=lambda api, value: api.setDomesticHotWaterTemperature2(value), + # no getters for min, max, stepping exposed yet, using static values + native_min_value=10, + native_max_value=60, + native_step=1, + ), +) + + CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( ViCareNumberEntityDescription( key="heating curve shift", @@ -216,18 +233,32 @@ def _build_entities( ) -> list[ViCareNumber]: """Create ViCare number entities for a device.""" - return [ + entities: list[ViCareNumber] = [ ViCareNumber( - circuit, + device.api, device.config, description, ) for device in device_list - for circuit in get_circuits(device.api) - for description in CIRCUIT_ENTITY_DESCRIPTIONS - if is_supported(description.key, description, circuit) + for description in DEVICE_ENTITY_DESCRIPTIONS + if is_supported(description.key, description, device.api) ] + entities.extend( + [ + ViCareNumber( + circuit, + device.config, + description, + ) + for device in device_list + for circuit in get_circuits(device.api) + for description in CIRCUIT_ENTITY_DESCRIPTIONS + if is_supported(description.key, description, circuit) + ] + ) + return entities + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 5a69cae4d29..f81d01b71cf 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -89,6 +89,9 @@ }, "comfort_heating_temperature": { "name": "[%key:component::vicare::entity::number::comfort_temperature::name%]" + }, + "dhw_secondary_temperature": { + "name": "DHW secondary temperature" } }, "sensor": { diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 63b4418a19d..3c0743601dd 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Hashable from contextvars import ContextVar -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from aiohttp import web import voluptuous as vol @@ -65,9 +65,9 @@ class ActiveConnection: self.last_id = 0 self.can_coalesce = False self.supported_features: dict[str, float] = {} - self.handlers: dict[str, tuple[MessageHandler, vol.Schema]] = self.hass.data[ - const.DOMAIN - ] + self.handlers: dict[str, tuple[MessageHandler, vol.Schema | Literal[False]]] = ( + self.hass.data[const.DOMAIN] + ) self.binary_handlers: list[BinaryHandler | None] = [] current_connection.set(self) @@ -185,6 +185,7 @@ class ActiveConnection: or ( not (cur_id := msg.get("id")) or type(cur_id) is not int # noqa: E721 + or cur_id < 0 or not (type_ := msg.get("type")) or type(type_) is not str # noqa: E721 ) @@ -220,7 +221,12 @@ class ActiveConnection: handler, schema = handler_schema try: - handler(self.hass, self, schema(msg)) + if schema is False: + if len(msg) > 2: + raise vol.Invalid("extra keys not allowed") + handler(self.hass, self, msg) + else: + handler(self.hass, self, schema(msg)) except Exception as err: # pylint: disable=broad-except self.async_handle_exception(msg, err) diff --git a/homeassistant/components/websocket_api/decorators.py b/homeassistant/components/websocket_api/decorators.py index 51643752a0f..0ed8be30139 100644 --- a/homeassistant/components/websocket_api/decorators.py +++ b/homeassistant/components/websocket_api/decorators.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from functools import wraps -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -137,7 +137,7 @@ def websocket_command( The schema must be either a dictionary where the keys are voluptuous markers, or a voluptuous.All schema where the first item is a voluptuous Mapping schema. """ - if isinstance(schema, dict): + if is_dict := isinstance(schema, dict): command = schema["type"] else: command = schema.validators[0].schema["type"] @@ -145,9 +145,13 @@ def websocket_command( def decorate(func: const.WebSocketCommandHandler) -> const.WebSocketCommandHandler: """Decorate ws command function.""" # pylint: disable=protected-access - if isinstance(schema, dict): + if is_dict and len(schema) == 1: # type only empty schema + func._ws_schema = False # type: ignore[attr-defined] + elif is_dict: func._ws_schema = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend(schema) # type: ignore[attr-defined] else: + if TYPE_CHECKING: + assert not isinstance(schema, dict) extended_schema = vol.All( schema.validators[0].extend( messages.BASE_COMMAND_MESSAGE_SCHEMA.schema diff --git a/homeassistant/components/websocket_api/sensor.py b/homeassistant/components/websocket_api/sensor.py index 7d668466bc2..4d874bca74e 100644 --- a/homeassistant/components/websocket_api/sensor.py +++ b/homeassistant/components/websocket_api/sensor.py @@ -30,9 +30,12 @@ async def async_setup_platform( class APICount(SensorEntity): """Entity to represent how many people are connected to the stream API.""" + _attr_name = "Connected clients" + _attr_native_unit_of_measurement = "clients" + def __init__(self) -> None: """Initialize the API count.""" - self.count = 0 + self._attr_native_value = 0 async def async_added_to_hass(self) -> None: """Handle addition to hass.""" @@ -47,22 +50,7 @@ class APICount(SensorEntity): ) ) - @property - def name(self) -> str: - """Return name of entity.""" - return "Connected clients" - - @property - def native_value(self) -> int: - """Return current API count.""" - return self.count - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement.""" - return "clients" - @callback def _update_count(self) -> None: - self.count = self.hass.data.get(DATA_CONNECTIONS, 0) + self._attr_native_value = self.hass.data.get(DATA_CONNECTIONS, 0) self.async_write_ha_state() diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index 2f2b705ff60..8ee8bac3fea 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -1,6 +1,6 @@ { "config": { - "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "flow_title": "{name}", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 35ee017286f..bea8d9b402f 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -299,6 +299,7 @@ async def async_create_miio_device_and_coordinator( # List of models requiring specific lazy_discover setting LAZY_DISCOVER_FOR_MODEL = { + "zhimi.fan.za3": True, "zhimi.fan.za5": True, "zhimi.airpurifier.za1": True, } diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a3f31ff8715..bf576b517d3 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2609,14 +2609,12 @@ def _handle_entry_updated_filter( Only handle changes to "disabled_by". If "disabled_by" was CONFIG_ENTRY, reload is not needed. """ - if ( + return not ( event_data["action"] != "update" or "disabled_by" not in event_data["changes"] or event_data["changes"]["disabled_by"] is entity_registry.RegistryEntryDisabler.CONFIG_ENTRY - ): - return False - return True + ) async def support_entry_unload(hass: HomeAssistant, domain: str) -> bool: diff --git a/homeassistant/core.py b/homeassistant/core.py index d957953b609..01536f8ffdb 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -86,6 +86,7 @@ from .exceptions import ( InvalidStateError, MaxLengthExceeded, ServiceNotFound, + ServiceValidationError, Unauthorized, ) from .helpers.deprecation import ( @@ -2571,16 +2572,27 @@ class ServiceRegistry: if return_response: if not blocking: - raise ValueError( - "Invalid argument return_response=True when blocking=False" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_should_be_blocking", + translation_placeholders={ + "return_response": "return_response=True", + "non_blocking_argument": "blocking=False", + }, ) if handler.supports_response is SupportsResponse.NONE: - raise ValueError( - "Invalid argument return_response=True when handler does not support responses" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_does_not_supports_reponse", + translation_placeholders={ + "return_response": "return_response=True" + }, ) elif handler.supports_response is SupportsResponse.ONLY: - raise ValueError( - "Service call requires responses but caller did not ask for responses" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_lacks_response_request", + translation_placeholders={"return_response": "return_response=True"}, ) if target: @@ -2628,7 +2640,11 @@ class ServiceRegistry: return None if not isinstance(response_data, dict): raise HomeAssistantError( - f"Service response data expected a dictionary, was {type(response_data)}" + translation_domain=DOMAIN, + translation_key="service_reponse_invalid", + translation_placeholders={ + "response_data_type": str(type(response_data)) + }, ) return response_data @@ -2670,6 +2686,41 @@ class ServiceRegistry: return await self._hass.async_add_executor_job(target, service_call) +class _ComponentSet(set[str]): + """Set of loaded components. + + This set contains both top level components and platforms. + + Examples: + `light`, `switch`, `hue`, `mjpeg.camera`, `universal.media_player`, + `homeassistant.scene` + + The top level components set only contains the top level components. + + """ + + def __init__(self, top_level_components: set[str]) -> None: + """Initialize the component set.""" + self._top_level_components = top_level_components + + def add(self, component: str) -> None: + """Add a component to the store.""" + if "." not in component: + self._top_level_components.add(component) + return super().add(component) + + def remove(self, component: str) -> None: + """Remove a component from the store.""" + if "." in component: + raise ValueError("_ComponentSet does not support removing sub-components") + self._top_level_components.remove(component) + return super().remove(component) + + def discard(self, component: str) -> None: + """Remove a component from the store.""" + raise NotImplementedError("_ComponentSet does not support discard, use remove") + + class Config: """Configuration settings for Home Assistant.""" @@ -2702,8 +2753,13 @@ class Config: # List of packages to skip when installing requirements on startup self.skip_pip_packages: list[str] = [] - # List of loaded components - self.components: set[str] = set() + # Set of loaded top level components + # This set is updated by _ComponentSet + # and should not be modified directly + self.top_level_components: set[str] = set() + + # Set of loaded components + self.components: _ComponentSet = _ComponentSet(self.top_level_components) # API (HTTP) server configuration self.api: ApiConfig | None = None diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 1eb964d82b1..044a41aab7a 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -255,7 +255,7 @@ class UnknownUser(Unauthorized): """When call is made with user ID that doesn't exist.""" -class ServiceNotFound(HomeAssistantError): +class ServiceNotFound(ServiceValidationError): """Raised when a service is not found.""" def __init__(self, domain: str, service: str) -> None: diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c9c9758cc23..6740c39b016 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -42,6 +42,7 @@ FLOWS = { "alarmdecoder", "amberelectric", "ambiclimate", + "ambient_network", "ambient_station", "analytics_insights", "android_ip_webcam", @@ -147,6 +148,7 @@ FLOWS = { "emulated_roku", "energenie_power_sockets", "energyzero", + "enigma2", "enocean", "enphase_envoy", "environment_canada", @@ -285,6 +287,7 @@ FLOWS = { "ld2410_ble", "leaone", "led_ble", + "lg_netcast", "lg_soundbar", "lidarr", "lifx", @@ -456,6 +459,7 @@ FLOWS = { "rympro", "sabnzbd", "samsungtv", + "sanix", "schlage", "scrape", "screenlogic", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4f9d22529d5..77246604df9 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -244,6 +244,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "ambient_network": { + "name": "Ambient Weather Network", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "ambient_station": { "name": "Ambient Weather Station", "integration_type": "hub", @@ -1598,8 +1604,8 @@ }, "enigma2": { "name": "Enigma2 (OpenWebif)", - "integration_type": "hub", - "config_flow": false, + "integration_type": "device", + "config_flow": true, "iot_class": "local_polling" }, "enmax": { @@ -2879,7 +2885,7 @@ "islamic_prayer_times": { "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "calculated" }, "ismartwindow": { "name": "iSmartWindow", @@ -3177,8 +3183,8 @@ "name": "LG", "integrations": { "lg_netcast": { - "integration_type": "hub", - "config_flow": false, + "integration_type": "device", + "config_flow": true, "iot_class": "local_polling", "name": "LG Netcast" }, @@ -5180,6 +5186,12 @@ } } }, + "sanix": { + "name": "Sanix", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "satel_integra": { "name": "Satel Integra", "integration_type": "hub", diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 2734ab5e2e5..b39fee9c185 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -385,9 +385,8 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): def _handle_floor_registry_update(event: fr.EventFloorRegistryUpdated) -> None: """Update areas that are associated with a floor that has been removed.""" floor_id = event.data["floor_id"] - for area_id, area in self.areas.items(): - if floor_id == area.floor_id: - self.async_update(area_id, floor_id=None) + for area in self.areas.get_areas_for_floor(floor_id): + self.async_update(area.id, floor_id=None) self.hass.bus.async_listen( event_type=fr.EVENT_FLOOR_REGISTRY_UPDATED, @@ -399,11 +398,8 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): def _handle_label_registry_update(event: lr.EventLabelRegistryUpdated) -> None: """Update areas that have a label that has been removed.""" label_id = event.data["label_id"] - for area_id, area in self.areas.items(): - if label_id in area.labels: - labels = area.labels.copy() - labels.remove(label_id) - self.async_update(area_id, labels=labels) + for area in self.areas.get_areas_for_label(label_id): + self.async_update(area.id, labels=area.labels - {label_id}) self.hass.bus.async_listen( event_type=lr.EVENT_LABEL_REGISTRY_UPDATED, diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 0270c8dc456..3a9d047810b 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1053,18 +1053,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): @callback def async_clear_area_id(self, area_id: str) -> None: """Clear area id from registry entries.""" - for dev_id, device in self.devices.items(): - if area_id == device.area_id: - self.async_update_device(dev_id, area_id=None) + for device in self.devices.get_devices_for_area_id(area_id): + self.async_update_device(device.id, area_id=None) @callback def async_clear_label_id(self, label_id: str) -> None: """Clear label from registry entries.""" - for device_id, entry in self.devices.items(): - if label_id in entry.labels: - labels = entry.labels.copy() - labels.remove(label_id) - self.async_update_device(device_id, labels=labels) + for device in self.devices.get_devices_for_label(label_id): + self.async_update_device(device.id, labels=device.labels - {label_id}) @callback @@ -1239,21 +1235,21 @@ def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None: return True - if hass.is_running: + def _async_listen_for_cleanup() -> None: + """Listen for entity registry changes.""" hass.bus.async_listen( entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, _async_entity_registry_changed, event_filter=entity_registry_changed_filter, ) + + if hass.is_running: + _async_listen_for_cleanup() return async def startup_clean(event: Event) -> None: """Clean up on startup.""" - hass.bus.async_listen( - entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, - _async_entity_registry_changed, - event_filter=entity_registry_changed_filter, - ) + _async_listen_for_cleanup() await debounced_cleanup.async_call() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, startup_clean) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index fb071d438b1..20948a7130a 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1052,8 +1052,10 @@ class Entity( available = self.available # only call self.available once per update cycle state = self._stringify_state(available) if available: - attr.update(self.state_attributes or {}) - attr.update(self.extra_state_attributes or {}) + if state_attributes := self.state_attributes: + attr.update(state_attributes) + if extra_state_attributes := self.extra_state_attributes: + attr.update(extra_state_attributes) if (unit_of_measurement := self.unit_of_measurement) is not None: attr[ATTR_UNIT_OF_MEASUREMENT] = unit_of_measurement diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 3a26505c7da..4e77df49ea6 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1329,11 +1329,8 @@ class EntityRegistry(BaseRegistry): @callback def async_clear_label_id(self, label_id: str) -> None: """Clear label from registry entries.""" - for entity_id, entry in self.entities.items(): - if label_id in entry.labels: - labels = entry.labels.copy() - labels.remove(label_id) - self.async_update_entity(entity_id, labels=labels) + for entry in self.entities.get_entries_for_label(label_id): + self.async_update_entity(entry.entity_id, labels=entry.labels - {label_id}) @callback def async_clear_config_entry(self, config_entry_id: str) -> None: diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 648a118f175..7fae0976686 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -38,6 +38,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.event_type import EventType +from . import frame from .device_registry import ( EVENT_DEVICE_REGISTRY_UPDATED, EventDeviceRegistryUpdatedData, @@ -203,8 +204,16 @@ def async_track_state_change( being None, async_track_state_change_event should be used instead as it is slightly faster. + This function is deprecated and will be removed in Home Assistant 2025.5. + Must be run within the event loop. """ + frame.report( + "calls `async_track_state_change` instead of `async_track_state_change_event`" + " which is deprecated and will be removed in Home Assistant 2025.5", + error_if_core=False, + ) + if from_state is not None: match_from_state = process_state_match(from_state) if to_state is not None: diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py index 973c93674b1..db90d38744a 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -6,6 +6,7 @@ import asyncio from collections.abc import Iterable from functools import lru_cache import logging +import pathlib from typing import Any from homeassistant.core import HomeAssistant, callback @@ -20,23 +21,17 @@ _LOGGER = logging.getLogger(__name__) @callback -def _component_icons_path(component: str, integration: Integration) -> str | None: +def _component_icons_path(integration: Integration) -> pathlib.Path: """Return the icons json file location for a component. Ex: components/hue/icons.json - If component is just a single file, will return None. """ - domain = component.rpartition(".")[-1] - - # If it's a component that is just one file, we don't support icons - # Example custom_components/my_component.py - if integration.file_path.name != domain: - return None - - return str(integration.file_path / "icons.json") + return integration.file_path / "icons.json" -def _load_icons_files(icons_files: dict[str, str]) -> dict[str, dict[str, Any]]: +def _load_icons_files( + icons_files: dict[str, pathlib.Path], +) -> dict[str, dict[str, Any]]: """Load and parse icons.json files.""" return { component: load_json_object(icons_file) @@ -53,19 +48,15 @@ async def _async_get_component_icons( icons: dict[str, Any] = {} # Determine files to load - files_to_load = {} - for loaded in components: - domain = loaded.rpartition(".")[-1] - if (path := _component_icons_path(loaded, integrations[domain])) is None: - icons[loaded] = {} - else: - files_to_load[loaded] = path + files_to_load = { + comp: _component_icons_path(integrations[comp]) for comp in components + } # Load files - if files_to_load and ( - load_icons_job := hass.async_add_executor_job(_load_icons_files, files_to_load) - ): - icons |= await load_icons_job + if files_to_load: + icons.update( + await hass.async_add_executor_job(_load_icons_files, files_to_load) + ) return icons @@ -108,8 +99,7 @@ class _IconsCache: _LOGGER.debug("Cache miss for: %s", components) integrations: dict[str, Integration] = {} - domains = {loaded.rpartition(".")[-1] for loaded in components} - ints_or_excs = await async_get_integrations(self._hass, domains) + ints_or_excs = await async_get_integrations(self._hass, components) for domain, int_or_exc in ints_or_excs.items(): if isinstance(int_or_exc, Exception): raise int_or_exc @@ -127,11 +117,9 @@ class _IconsCache: icons: dict[str, dict[str, Any]], ) -> None: """Extract resources into the cache.""" - categories: set[str] = set() - - for resource in icons.values(): - categories.update(resource) - + categories = { + category for component in icons.values() for category in component + } for category in categories: self._cache.setdefault(category, {}).update( build_resources(icons, components, category) @@ -151,9 +139,7 @@ async def async_get_icons( if integrations: components = set(integrations) else: - components = { - component for component in hass.config.components if "." not in component - } + components = hass.config.top_level_components if ICON_CACHE in hass.data: cache: _IconsCache = hass.data[ICON_CACHE] diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index 70846156702..be525b384e0 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -174,7 +174,7 @@ async def async_process_integration_platforms( integration_platforms = hass.data[DATA_INTEGRATION_PLATFORMS] async_register_preload_platform(hass, platform_name) - top_level_components = {comp for comp in hass.config.components if "." not in comp} + top_level_components = hass.config.top_level_components.copy() process_job = HassJob( catch_log_exception( process_platform, diff --git a/homeassistant/helpers/ratelimit.py b/homeassistant/helpers/ratelimit.py index 516d4134f76..020c7c3a0d3 100644 --- a/homeassistant/helpers/ratelimit.py +++ b/homeassistant/helpers/ratelimit.py @@ -30,7 +30,7 @@ class KeyedRateLimit: @callback def async_has_timer(self, key: Hashable) -> bool: """Check if a rate limit timer is running.""" - return bool(self._rate_limit_timers and key in self._rate_limit_timers) + return key in self._rate_limit_timers @callback def async_triggered(self, key: Hashable, now: float | None = None) -> None: @@ -41,10 +41,8 @@ class KeyedRateLimit: @callback def async_cancel_timer(self, key: Hashable) -> None: """Cancel a rate limit time that will call the action.""" - if not self._rate_limit_timers or key not in self._rate_limit_timers: - return - - self._rate_limit_timers.pop(key).cancel() + if handle := self._rate_limit_timers.pop(key, None): + handle.cancel() @callback def async_remove(self) -> None: diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 3947bc9cbf8..66c9f7db3e6 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -77,6 +77,8 @@ _LOGGER = logging.getLogger(__name__) SERVICE_DESCRIPTION_CACHE = "service_description_cache" ALL_SERVICE_DESCRIPTIONS_CACHE = "all_service_descriptions_cache" +_T = TypeVar("_T") + @cache def _base_components() -> dict[str, ModuleType]: @@ -1154,40 +1156,67 @@ def verify_domain_control( class ReloadServiceHelper: - """Helper for reload services to minimize unnecessary reloads.""" + """Helper for reload services. - def __init__(self, service_func: Callable[[ServiceCall], Awaitable]) -> None: + The helper has the following purposes: + - Make sure reloads do not happen in parallel + - Avoid redundant reloads of the same target + """ + + def __init__( + self, + service_func: Callable[[ServiceCall], Awaitable], + reload_targets_func: Callable[[ServiceCall], set[_T]], + ) -> None: """Initialize ReloadServiceHelper.""" self._service_func = service_func self._service_running = False self._service_condition = asyncio.Condition() + self._pending_reload_targets: set[_T] = set() + self._reload_targets_func = reload_targets_func async def execute_service(self, service_call: ServiceCall) -> None: """Execute the service. - If a previous reload task if currently in progress, wait for it to finish first. + If a previous reload task is currently in progress, wait for it to finish first. Once the previous reload task has finished, one of the waiting tasks will be - assigned to execute the reload, the others will wait for the reload to finish. + assigned to execute the reload of the targets it is assigned to reload. The + other tasks will wait if they should reload the same target, otherwise they + will wait for the next round. """ do_reload = False + reload_targets = None async with self._service_condition: if self._service_running: - # A previous reload task is already in progress, wait for it to finish + # A previous reload task is already in progress, wait for it to finish, + # because that task may be reloading a stale version of the resource. await self._service_condition.wait() - async with self._service_condition: - if not self._service_running: - # This task will do the reload - self._service_running = True - do_reload = True - else: - # Another task will perform the reload, wait for it to finish + while True: + async with self._service_condition: + # Once we've passed this point, we assume the version of the resource is + # the one our task was assigned to reload, or a newer one. Regardless of + # which, our task is happy as long as the target is reloaded at least + # once. + if reload_targets is None: + reload_targets = self._reload_targets_func(service_call) + self._pending_reload_targets |= reload_targets + if not self._service_running: + # This task will do a reload + self._service_running = True + do_reload = True + break + # Another task will perform a reload, wait for it to finish await self._service_condition.wait() + # Check if the reload this task is waiting for has been completed + if reload_targets.isdisjoint(self._pending_reload_targets): + break if do_reload: # Reload, then notify other tasks await self._service_func(service_call) async with self._service_condition: self._service_running = False + self._pending_reload_targets -= reload_targets self._service_condition.notify_all() diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index d344a473494..1f0742e896d 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1453,8 +1453,7 @@ def floor_areas(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]: def areas(hass: HomeAssistant) -> Iterable[str | None]: """Return all areas.""" - area_reg = area_registry.async_get(hass) - return [area.id for area in area_reg.async_list_areas()] + return list(area_registry.async_get(hass).areas) def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: @@ -1580,7 +1579,7 @@ def labels(hass: HomeAssistant, lookup_value: Any = None) -> Iterable[str | None """Return all labels, or those from a area ID, device ID, or entity ID.""" label_reg = label_registry.async_get(hass) if lookup_value is None: - return [label.label_id for label in label_reg.async_list_labels()] + return list(label_reg.labels) ent_reg = entity_registry.async_get(hass) diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 1fc2c3d075b..377826b7edb 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -30,9 +30,11 @@ TRANSLATION_FLATTEN_CACHE = "translation_flatten_cache" LOCALE_EN = "en" -def recursive_flatten(prefix: Any, data: dict[str, Any]) -> dict[str, Any]: +def recursive_flatten( + prefix: str, data: dict[str, dict[str, Any] | str] +) -> dict[str, str]: """Return a flattened representation of dict data.""" - output = {} + output: dict[str, str] = {} for key, value in data.items(): if isinstance(value, dict): output.update(recursive_flatten(f"{prefix}{key}.", value)) @@ -212,8 +214,7 @@ class _TranslationCache: languages = [LOCALE_EN] if language == LOCALE_EN else [LOCALE_EN, language] integrations: dict[str, Integration] = {} - domains = {loaded.partition(".")[0] for loaded in components} - ints_or_excs = await async_get_integrations(self.hass, domains) + ints_or_excs = await async_get_integrations(self.hass, components) for domain, int_or_exc in ints_or_excs.items(): if isinstance(int_or_exc, Exception): _LOGGER.warning( @@ -251,9 +252,9 @@ class _TranslationCache: def _validate_placeholders( self, language: str, - updated_resources: dict[str, Any], - cached_resources: dict[str, Any] | None = None, - ) -> dict[str, Any]: + updated_resources: dict[str, str], + cached_resources: dict[str, str] | None = None, + ) -> dict[str, str]: """Validate if updated resources have same placeholders as cached resources.""" if cached_resources is None: return updated_resources @@ -302,9 +303,11 @@ class _TranslationCache: """Extract resources into the cache.""" resource: dict[str, Any] | str cached = self.cache.setdefault(language, {}) - categories: set[str] = set() - for resource in translation_strings.values(): - categories.update(resource) + categories = { + category + for component in translation_strings.values() + for category in component + } for category in categories: new_resources = build_resources(translation_strings, components, category) @@ -313,17 +316,14 @@ class _TranslationCache: for component, resource in new_resources.items(): component_cache = category_cache.setdefault(component, {}) - if isinstance(resource, dict): - resources_flatten = recursive_flatten( - f"component.{component}.{category}.", - resource, - ) - resources_flatten = self._validate_placeholders( - language, resources_flatten, component_cache - ) - component_cache.update(resources_flatten) - else: + if not isinstance(resource, dict): component_cache[f"component.{component}.{category}"] = resource + continue + + prefix = f"component.{component}.{category}." + flat = recursive_flatten(prefix, resource) + flat = self._validate_placeholders(language, flat, component_cache) + component_cache.update(flat) @bind_hass @@ -345,7 +345,7 @@ async def async_get_translations( elif integrations is not None: components = set(integrations) else: - components = {comp for comp in hass.config.components if "." not in comp} + components = hass.config.top_level_components return await _async_get_translations_cache(hass).async_fetch( language, category, components @@ -364,11 +364,7 @@ def async_get_cached_translations( If integration is specified, return translations for it. Otherwise, default to all loaded integrations. """ - if integration is not None: - components = {integration} - else: - components = {comp for comp in hass.config.components if "." not in comp} - + components = {integration} if integration else hass.config.top_level_components return _async_get_translations_cache(hass).get_cached( language, category, components ) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f3ee84392a7..7f134b1a93d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,8 +4,8 @@ aiodhcpwatcher==1.0.0 aiodiscover==2.0.0 aiodns==3.2.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.3.1 -aiohttp==3.9.4 +aiohttp-isal==0.2.0 +aiohttp==3.9.5 aiohttp_cors==0.7.0 aiohttp_session==2.12.0 astral==2.2 @@ -18,7 +18,7 @@ bcrypt==4.1.2 bleak-retry-connector==3.5.0 bleak==0.21.1 bluetooth-adapters==0.18.0 -bluetooth-auto-recovery==1.4.0 +bluetooth-auto-recovery==1.4.1 bluetooth-data-tools==1.19.0 cached_ipaddress==0.3.0 certifi>=2021.5.30 @@ -28,7 +28,7 @@ dbus-fast==2.21.1 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.2.0 -habluetooth==2.4.2 +habluetooth==2.8.0 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 @@ -39,7 +39,7 @@ ifaddr==0.2.0 Jinja2==3.1.3 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.9.15 +orjson==3.10.1 packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.3.0 @@ -109,7 +109,7 @@ regex==2021.8.28 # requirements so we can directly link HA versions to these library versions. anyio==4.3.0 h11==0.14.0 -httpcore==1.0.4 +httpcore==1.0.5 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 643bb8983b8..5772fce6955 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -487,14 +487,6 @@ async def async_prepare_setup_platform( log_error("Integration not found") return None - # Process deps and reqs as soon as possible, so that requirements are - # available when we import the platform. - try: - await async_process_deps_reqs(hass, hass_config, integration) - except HomeAssistantError as err: - log_error(str(err)) - return None - # Platforms cannot exist on their own, they are part of their integration. # If the integration is not set up yet, and can be set up, set it up. # @@ -502,6 +494,16 @@ async def async_prepare_setup_platform( # where the top level component is. # if load_top_level_component := integration.domain not in hass.config.components: + # Process deps and reqs as soon as possible, so that requirements are + # available when we import the platform. We only do this if the integration + # is not in hass.config.components yet, as we already processed them in + # async_setup_component if it is. + try: + await async_process_deps_reqs(hass, hass_config, integration) + except HomeAssistantError as err: + log_error(str(err)) + return None + try: component = await integration.async_get_component() except ImportError as exc: diff --git a/mypy.ini b/mypy.ini index 3e0419be269..216d43322a4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4,9 +4,10 @@ [mypy] python_version = 3.12 +platform = linux plugins = pydantic.mypy show_error_codes = true -follow_imports = silent +follow_imports = normal local_partial_types = true strict_equality = true no_implicit_optional = true @@ -420,6 +421,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ambient_network.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.ambient_station.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index 8701d67c930..4b3b15f7bde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,11 +24,11 @@ classifiers = [ requires-python = ">=3.12.0" dependencies = [ "aiodns==3.2.0", - "aiohttp==3.9.4", + "aiohttp==3.9.5", "aiohttp_cors==0.7.0", "aiohttp_session==2.12.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.3.1", + "aiohttp-isal==0.2.0", "astral==2.2", "async-interrupt==1.1.1", "attrs==23.2.0", @@ -53,7 +53,7 @@ dependencies = [ "cryptography==42.0.5", "Pillow==10.3.0", "pyOpenSSL==24.1.0", - "orjson==3.9.15", + "orjson==3.10.1", "packaging>=23.1", "pip>=21.3.1", "psutil-home-assistant==0.0.1", @@ -668,6 +668,7 @@ select = [ "B007", # Loop control variable {name} not used within loop body "B014", # Exception handler with duplicate exception "B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it. + "B017", # pytest.raises(BaseException) should be considered evil "B018", # Found useless attribute access. Either assign it to a variable or remove it. "B023", # Function definition does not bind loop variable {name} "B026", # Star-arg unpacking after a keyword argument is strongly discouraged diff --git a/requirements.txt b/requirements.txt index 3c2a453b762..34ee8237921 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,11 +4,11 @@ # Home Assistant Core aiodns==3.2.0 -aiohttp==3.9.4 +aiohttp==3.9.5 aiohttp_cors==0.7.0 aiohttp_session==2.12.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.3.1 +aiohttp-isal==0.2.0 astral==2.2 async-interrupt==1.1.1 attrs==23.2.0 @@ -28,7 +28,7 @@ PyJWT==2.8.0 cryptography==42.0.5 Pillow==10.3.0 pyOpenSSL==24.1.0 -orjson==3.9.15 +orjson==3.10.1 packaging>=23.1 pip>=21.3.1 psutil-home-assistant==0.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index 6c40a79056e..b4c81ac30de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,6 +190,7 @@ aioairzone-cloud==0.5.1 # homeassistant.components.airzone aioairzone==0.7.6 +# homeassistant.components.ambient_network # homeassistant.components.ambient_station aioambient==2024.01.0 @@ -242,7 +243,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==24.0.0 +aioesphomeapi==24.1.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -262,16 +263,6 @@ aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==3.1.5 -# homeassistant.components.http -aiohttp-fast-url-dispatcher==0.3.0 - -# homeassistant.components.http -aiohttp-zlib-ng==0.3.1 - -# homeassistant.components.emulated_hue -# homeassistant.components.http -aiohttp_cors==0.7.0 - # homeassistant.components.hue aiohue==4.7.1 @@ -473,7 +464,7 @@ aranet4==2.3.3 arcam-fmj==1.4.0 # homeassistant.components.arris_tg2492lg -arris-tg2492lg==1.2.1 +arris-tg2492lg==2.2.0 # homeassistant.components.ampio asmog==0.0.6 @@ -588,7 +579,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.18.0 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.0 +bluetooth-auto-recovery==1.4.1 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble @@ -610,7 +601,7 @@ boto3==1.34.51 bring-api==0.5.7 # homeassistant.components.broadlink -broadlink==0.18.3 +broadlink==0.19.0 # homeassistant.components.brother brother==4.1.0 @@ -667,7 +658,7 @@ colorthief==0.2.1 concord232==0.15 # homeassistant.components.upc_connect -connect-box==0.2.8 +connect-box==0.3.1 # homeassistant.components.xiaomi_miio construct==2.10.68 @@ -795,7 +786,7 @@ elvia==0.1.0 emoji==2.8.0 # homeassistant.components.emulated_roku -emulated-roku==0.2.1 +emulated-roku==0.3.0 # homeassistant.components.huisbaasje energyflip-client==0.2.2 @@ -971,7 +962,7 @@ google-cloud-texttospeech==2.12.3 google-generativeai==0.3.1 # homeassistant.components.nest -google-nest-sdm==3.0.3 +google-nest-sdm==3.0.4 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -1038,7 +1029,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.4.2 +habluetooth==2.8.0 # homeassistant.components.cloud hass-nabucasa==0.78.0 @@ -1152,7 +1143,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.4.0 +insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire intellifire4py==2.2.2 @@ -1566,7 +1557,7 @@ poolsense==0.0.8 praw==7.5.0 # homeassistant.components.islamic_prayer_times -prayer-times-calculator==0.0.12 +prayer-times-calculator-offline==1.0.3 # homeassistant.components.proliphix proliphix==0.4.1 @@ -1941,7 +1932,7 @@ pylast==5.1.0 pylaunches==1.4.0 # homeassistant.components.lg_netcast -pylgnetcast==0.3.7 +pylgnetcast==0.3.9 # homeassistant.components.forked_daapd pylibrespot-java==0.1.1 @@ -2439,7 +2430,7 @@ refoss-ha==1.2.0 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.1 +renault-api==0.2.2 # homeassistant.components.renson renson-endura-delta==1.7.1 @@ -2504,6 +2495,9 @@ samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv samsungtvws[async,encrypted]==2.6.0 +# homeassistant.components.sanix +sanix==1.0.5 + # homeassistant.components.satel_integra satel-integra==0.3.7 @@ -2575,7 +2569,7 @@ smhi-pkg==1.0.16 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.2 +soco==0.30.3 # homeassistant.components.solaredge_local solaredge-local==0.2.3 @@ -2605,7 +2599,7 @@ spiderpy==1.6.1 spotipy==2.23.0 # homeassistant.components.sql -sqlparse==0.4.4 +sqlparse==0.5.0 # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -2656,7 +2650,7 @@ surepy==0.9.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.0.0 +switchbot-api==2.1.0 # homeassistant.components.synology_srm synology-srm==0.2.0 @@ -2799,7 +2793,7 @@ url-normalize==1.4.3 uvcclient==0.11.0 # homeassistant.components.roborock -vacuum-map-parser-roborock==0.1.1 +vacuum-map-parser-roborock==0.1.2 # homeassistant.components.vallox vallox-websocket-api==5.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 581cc09d637..0ba05afc18c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -169,6 +169,7 @@ aioairzone-cloud==0.5.1 # homeassistant.components.airzone aioairzone==0.7.6 +# homeassistant.components.ambient_network # homeassistant.components.ambient_station aioambient==2024.01.0 @@ -221,7 +222,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==24.0.0 +aioesphomeapi==24.1.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -238,16 +239,6 @@ aioharmony==0.2.10 # homeassistant.components.homekit_controller aiohomekit==3.1.5 -# homeassistant.components.http -aiohttp-fast-url-dispatcher==0.3.0 - -# homeassistant.components.http -aiohttp-zlib-ng==0.3.1 - -# homeassistant.components.emulated_hue -# homeassistant.components.http -aiohttp_cors==0.7.0 - # homeassistant.components.hue aiohue==4.7.1 @@ -503,7 +494,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.18.0 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.0 +bluetooth-auto-recovery==1.4.1 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble @@ -521,7 +512,7 @@ boschshcpy==0.2.91 bring-api==0.5.7 # homeassistant.components.broadlink -broadlink==0.18.3 +broadlink==0.19.0 # homeassistant.components.brother brother==4.1.0 @@ -652,7 +643,7 @@ elmax-api==0.0.4 elvia==0.1.0 # homeassistant.components.emulated_roku -emulated-roku==0.2.1 +emulated-roku==0.3.0 # homeassistant.components.huisbaasje energyflip-client==0.2.2 @@ -797,7 +788,7 @@ google-cloud-pubsub==2.13.11 google-generativeai==0.3.1 # homeassistant.components.nest -google-nest-sdm==3.0.3 +google-nest-sdm==3.0.4 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -852,7 +843,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.bluetooth -habluetooth==2.4.2 +habluetooth==2.8.0 # homeassistant.components.cloud hass-nabucasa==0.78.0 @@ -936,7 +927,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.4.0 +insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire intellifire4py==2.2.2 @@ -1182,6 +1173,9 @@ openerz-api==0.3.0 # homeassistant.components.openhome openhomedevice==2.2.0 +# homeassistant.components.enigma2 +openwebifpy==4.2.4 + # homeassistant.components.opower opower==0.4.3 @@ -1237,7 +1231,7 @@ poolsense==0.0.8 praw==7.5.0 # homeassistant.components.islamic_prayer_times -prayer-times-calculator==0.0.12 +prayer-times-calculator-offline==1.0.3 # homeassistant.components.prometheus prometheus-client==0.17.1 @@ -1512,6 +1506,9 @@ pylast==5.1.0 # homeassistant.components.launch_library pylaunches==1.4.0 +# homeassistant.components.lg_netcast +pylgnetcast==0.3.9 + # homeassistant.components.forked_daapd pylibrespot-java==0.1.1 @@ -1732,6 +1729,9 @@ python-ecobee-api==0.2.17 # homeassistant.components.fully_kiosk python-fullykiosk==0.0.12 +# homeassistant.components.sms +# python-gammu==3.2.4 + # homeassistant.components.analytics_insights python-homeassistant-analytics==0.6.0 @@ -1888,7 +1888,7 @@ refoss-ha==1.2.0 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.1 +renault-api==0.2.2 # homeassistant.components.renson renson-endura-delta==1.7.1 @@ -1935,6 +1935,9 @@ samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv samsungtvws[async,encrypted]==2.6.0 +# homeassistant.components.sanix +sanix==1.0.5 + # homeassistant.components.screenlogic screenlogicpy==0.10.0 @@ -1985,7 +1988,7 @@ smhi-pkg==1.0.16 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.2 +soco==0.30.3 # homeassistant.components.solaredge solaredge==0.0.2 @@ -2012,7 +2015,7 @@ spiderpy==1.6.1 spotipy==2.23.0 # homeassistant.components.sql -sqlparse==0.4.4 +sqlparse==0.5.0 # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -2057,7 +2060,7 @@ sunweg==2.1.1 surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.0.0 +switchbot-api==2.1.0 # homeassistant.components.system_bridge systembridgeconnector==4.0.3 @@ -2158,7 +2161,7 @@ url-normalize==1.4.3 uvcclient==0.11.0 # homeassistant.components.roborock -vacuum-map-parser-roborock==0.1.1 +vacuum-map-parser-roborock==0.1.2 # homeassistant.components.vallox vallox-websocket-api==5.1.1 diff --git a/script/alexa_locales.py b/script/alexa_locales.py new file mode 100644 index 00000000000..84bdac4133a --- /dev/null +++ b/script/alexa_locales.py @@ -0,0 +1,67 @@ +"""Check if upstream Alexa locales are subset of the core Alexa supported locales.""" + +from pprint import pprint +import re + +from bs4 import BeautifulSoup +import requests + +from homeassistant.components.alexa import capabilities + +SITE = ( + "https://developer.amazon.com/en-GB/docs/alexa/device-apis/list-of-interfaces.html" +) + + +def run_script() -> None: + """Run the script.""" + response = requests.get(SITE, timeout=10) + soup = BeautifulSoup(response.text, "html.parser") + + table = soup.find("table") + table_body = table.find_all("tbody")[-1] + rows = table_body.find_all("tr") + data = [[ele.text.strip() for ele in row.find_all("td") if ele] for row in rows] + upstream_locales_raw = {row[0]: row[3] for row in data} + language_pattern = re.compile(r"^[a-z]{2}-[A-Z]{2}$") + upstream_locales = { + upstream_interface: { + name + for word in upstream_locale.split(" ") + if (name := word.strip(",")) and language_pattern.match(name) is not None + } + for upstream_interface, upstream_locale in upstream_locales_raw.items() + if upstream_interface.count(".") == 1 # Skip sub-interfaces + } + + interfaces_missing = {} + interfaces_nok = {} + interfaces_ok = {} + + for upstream_interface, upstream_locale in upstream_locales.items(): + core_interface_name = upstream_interface.replace(".", "") + core_interface = getattr(capabilities, core_interface_name, None) + + if core_interface is None: + interfaces_missing[upstream_interface] = upstream_locale + continue + + core_locale = core_interface.supported_locales + + if not upstream_locale.issubset(core_locale): + interfaces_nok[core_interface_name] = core_locale + else: + interfaces_ok[core_interface_name] = core_locale + + print("Missing interfaces:") + pprint(list(interfaces_missing)) + print("\n") + print("Interfaces where upstream locales are not subsets of the core locales:") + pprint(list(interfaces_nok)) + print("\n") + print("Interfaces checked ok:") + pprint(list(interfaces_ok)) + + +if __name__ == "__main__": + run_script() diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index d8fffac1a06..7fc0907e756 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -18,7 +18,6 @@ from homeassistant.util.yaml.loader import load_yaml from script.hassfest.model import Integration COMMENT_REQUIREMENTS = ( - "Adafruit-BBIO", "atenpdu", # depends on pysnmp which is not maintained at this time "avea", # depends on bluepy "avion", @@ -100,7 +99,7 @@ regex==2021.8.28 # requirements so we can directly link HA versions to these library versions. anyio==4.3.0 h11==0.14.0 -httpcore==1.0.4 +httpcore==1.0.5 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation @@ -262,15 +261,13 @@ def normalize_package_name(requirement: str) -> str: if not match: return "" - # pipdeptree needs lowercase and dash instead of underscore as separator - return match.group(1).lower().replace("_", "-") + # pipdeptree needs lowercase and dash instead of underscore or period as separator + return match.group(1).lower().replace("_", "-").replace(".", "-") def comment_requirement(req: str) -> bool: """Comment out requirement. Some don't install on all systems.""" - return any( - normalize_package_name(req) == ign for ign in COMMENT_REQUIREMENTS_NORMALIZED - ) + return normalize_package_name(req) in COMMENT_REQUIREMENTS_NORMALIZED def gather_modules() -> dict[str, list[str]] | None: diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 76fe47837e4..fab3d5fcd7f 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -32,9 +32,10 @@ HEADER: Final = """ GENERAL_SETTINGS: Final[dict[str, str]] = { "python_version": ".".join(str(x) for x in REQUIRED_PYTHON_VER[:2]), + "platform": "linux", "plugins": "pydantic.mypy", "show_error_codes": "true", - "follow_imports": "silent", + "follow_imports": "normal", # Enable some checks globally. "local_partial_types": "true", "strict_equality": "true", diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 18d560f840f..ee63bf07f90 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -28,15 +28,9 @@ PACKAGE_REGEX = re.compile( PIP_REGEX = re.compile(r"^(--.+\s)?([-_\.\w\d]+.*(?:==|>=|<=|~=|!=|<|>|===)?.*$)") PIP_VERSION_RANGE_SEPARATOR = re.compile(r"^(==|>=|<=|~=|!=|<|>|===)?(.*)$") -IGNORE_VIOLATIONS = { - # Still has standard library requirements. - "acmeda", - "blink", - "ezviz", - "hdmi_cec", - "juicenet", - "lupusec", - "rainbird", +IGNORE_STANDARD_LIBRARY_VIOLATIONS = { + # Integrations which have standard library requirements. + "electrasmart", "slide", "suez_water", } @@ -112,10 +106,6 @@ def validate_requirements(integration: Integration) -> None: if not validate_requirements_format(integration): return - # Some integrations have not been fixed yet so are allowed to have violations. - if integration.domain in IGNORE_VIOLATIONS: - return - integration_requirements = set() integration_packages = set() for req in integration.requirements: @@ -126,7 +116,7 @@ def validate_requirements(integration: Integration) -> None: f"Failed to normalize package name from requirement {req}", ) return - if (package == ign for ign in IGNORE_PACKAGES): + if package in IGNORE_PACKAGES: continue integration_requirements.add(req) integration_packages.add(package) @@ -149,12 +139,34 @@ def validate_requirements(integration: Integration) -> None: return # Check for requirements incompatible with standard library. + standard_library_violations = set() for req in all_integration_requirements: - if req in sys.stlib_module_names: - integration.add_error( - "requirements", - f"Package {req} is not compatible with the Python standard library", - ) + if req in sys.stdlib_module_names: + standard_library_violations.add(req) + + if ( + standard_library_violations + and integration.domain not in IGNORE_STANDARD_LIBRARY_VIOLATIONS + ): + integration.add_error( + "requirements", + ( + f"Package {req} has dependencies {standard_library_violations} which " + "are not compatible with the Python standard library" + ), + ) + elif ( + not standard_library_violations + and integration.domain in IGNORE_STANDARD_LIBRARY_VIOLATIONS + ): + integration.add_error( + "requirements", + ( + f"Integration {integration.domain} no longer has requirements which are" + " incompatible with the Python standard library, remove it from " + "IGNORE_STANDARD_LIBRARY_VIOLATIONS" + ), + ) @cache diff --git a/tests/components/accuweather/__init__.py b/tests/components/accuweather/__init__.py index afaa5bbef25..a08b894ebb4 100644 --- a/tests/components/accuweather/__init__.py +++ b/tests/components/accuweather/__init__.py @@ -11,14 +11,8 @@ from tests.common import ( ) -async def init_integration( - hass, forecast=False, unsupported_icon=False -) -> MockConfigEntry: +async def init_integration(hass, unsupported_icon=False) -> MockConfigEntry: """Set up the AccuWeather integration in Home Assistant.""" - options = {} - if forecast: - options["forecast"] = True - entry = MockConfigEntry( domain=DOMAIN, title="Home", @@ -29,7 +23,6 @@ async def init_integration( "longitude": 122.12, "name": "Home", }, - options=options, ) current = load_json_object_fixture("accuweather/current_conditions_data.json") diff --git a/tests/components/accuweather/snapshots/test_diagnostics.ambr b/tests/components/accuweather/snapshots/test_diagnostics.ambr index b3c0c1de752..7477602f3a4 100644 --- a/tests/components/accuweather/snapshots/test_diagnostics.ambr +++ b/tests/components/accuweather/snapshots/test_diagnostics.ambr @@ -7,7 +7,7 @@ 'longitude': '**REDACTED**', 'name': 'Home', }), - 'coordinator_data': dict({ + 'observation_data': dict({ 'ApparentTemperature': dict({ 'Imperial': dict({ 'Unit': 'F', @@ -297,8 +297,6 @@ }), }), }), - 'forecast': list([ - ]), }), }) # --- diff --git a/tests/components/accuweather/snapshots/test_sensor.ambr b/tests/components/accuweather/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..42783f375b0 --- /dev/null +++ b/tests/components/accuweather/snapshots/test_sensor.ambr @@ -0,0 +1,6436 @@ +# serializer version: 1 +# name: test_sensor[sensor.home_air_quality_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_air_quality_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:air-filter', + 'original_name': 'Air quality day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality_1d', + 'unique_id': '0123456-airquality-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_air_quality_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'enum', + 'friendly_name': 'Home Air quality day 1', + 'icon': 'mdi:air-filter', + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'context': , + 'entity_id': 'sensor.home_air_quality_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensor[sensor.home_air_quality_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_air_quality_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:air-filter', + 'original_name': 'Air quality day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality_2d', + 'unique_id': '0123456-airquality-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_air_quality_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'enum', + 'friendly_name': 'Home Air quality day 2', + 'icon': 'mdi:air-filter', + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'context': , + 'entity_id': 'sensor.home_air_quality_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensor[sensor.home_air_quality_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_air_quality_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:air-filter', + 'original_name': 'Air quality day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality_3d', + 'unique_id': '0123456-airquality-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_air_quality_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'enum', + 'friendly_name': 'Home Air quality day 3', + 'icon': 'mdi:air-filter', + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'context': , + 'entity_id': 'sensor.home_air_quality_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensor[sensor.home_air_quality_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_air_quality_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:air-filter', + 'original_name': 'Air quality day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality_4d', + 'unique_id': '0123456-airquality-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_air_quality_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'enum', + 'friendly_name': 'Home Air quality day 4', + 'icon': 'mdi:air-filter', + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'context': , + 'entity_id': 'sensor.home_air_quality_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensor[sensor.home_air_quality_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_air_quality_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:air-filter', + 'original_name': 'Air quality today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality_0d', + 'unique_id': '0123456-airquality-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_air_quality_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'enum', + 'friendly_name': 'Home Air quality today', + 'icon': 'mdi:air-filter', + 'options': list([ + 'good', + 'hazardous', + 'high', + 'low', + 'moderate', + 'unhealthy', + ]), + }), + 'context': , + 'entity_id': 'sensor.home_air_quality_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensor[sensor.home_apparent_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_apparent_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent temperature', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'apparent_temperature', + 'unique_id': '0123456-apparenttemperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_apparent_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home Apparent temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_apparent_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.8', + }) +# --- +# name: test_sensor[sensor.home_cloud_ceiling-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_ceiling', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:weather-fog', + 'original_name': 'Cloud ceiling', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_ceiling', + 'unique_id': '0123456-ceiling', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_cloud_ceiling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'distance', + 'friendly_name': 'Home Cloud ceiling', + 'icon': 'mdi:weather-fog', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_cloud_ceiling', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3200.0', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover', + 'unique_id': '0123456-cloudcover', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover', + 'icon': 'mdi:weather-cloudy', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_day_1d', + 'unique_id': '0123456-cloudcoverday-1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover day 1', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_day_2d', + 'unique_id': '0123456-cloudcoverday-2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover day 2', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_day_3d', + 'unique_id': '0123456-cloudcoverday-3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover day 3', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_day_4d', + 'unique_id': '0123456-cloudcoverday-4', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover day 4', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_night_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_night_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover night 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_night_1d', + 'unique_id': '0123456-cloudcovernight-1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_night_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover night 1', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_night_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '63', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_night_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_night_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover night 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_night_2d', + 'unique_id': '0123456-cloudcovernight-2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_night_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover night 2', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_night_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_night_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_night_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover night 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_night_3d', + 'unique_id': '0123456-cloudcovernight-3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_night_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover night 3', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_night_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_night_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_night_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover night 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_night_4d', + 'unique_id': '0123456-cloudcovernight-4', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_night_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover night 4', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_night_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_day_0d', + 'unique_id': '0123456-cloudcoverday-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover today', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '58', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_tonight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_cloud_cover_tonight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-cloudy', + 'original_name': 'Cloud cover tonight', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cloud_cover_night_0d', + 'unique_id': '0123456-cloudcovernight-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_cloud_cover_tonight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Cloud cover tonight', + 'icon': 'mdi:weather-cloudy', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_cloud_cover_tonight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_sensor[sensor.home_condition_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_day_1d', + 'unique_id': '0123456-longphraseday-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition day 1', + }), + 'context': , + 'entity_id': 'sensor.home_condition_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Clouds and sun', + }) +# --- +# name: test_sensor[sensor.home_condition_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_day_2d', + 'unique_id': '0123456-longphraseday-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition day 2', + }), + 'context': , + 'entity_id': 'sensor.home_condition_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Very warm with a blend of sun and clouds', + }) +# --- +# name: test_sensor[sensor.home_condition_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_day_3d', + 'unique_id': '0123456-longphraseday-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition day 3', + }), + 'context': , + 'entity_id': 'sensor.home_condition_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Cooler with partial sunshine', + }) +# --- +# name: test_sensor[sensor.home_condition_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_day_4d', + 'unique_id': '0123456-longphraseday-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition day 4', + }), + 'context': , + 'entity_id': 'sensor.home_condition_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Intervals of clouds and sunshine', + }) +# --- +# name: test_sensor[sensor.home_condition_night_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_night_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition night 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_night_1d', + 'unique_id': '0123456-longphrasenight-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_night_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition night 1', + }), + 'context': , + 'entity_id': 'sensor.home_condition_night_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Partly cloudy', + }) +# --- +# name: test_sensor[sensor.home_condition_night_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_night_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition night 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_night_2d', + 'unique_id': '0123456-longphrasenight-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_night_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition night 2', + }), + 'context': , + 'entity_id': 'sensor.home_condition_night_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Partly cloudy', + }) +# --- +# name: test_sensor[sensor.home_condition_night_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_night_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition night 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_night_3d', + 'unique_id': '0123456-longphrasenight-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_night_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition night 3', + }), + 'context': , + 'entity_id': 'sensor.home_condition_night_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Mainly clear', + }) +# --- +# name: test_sensor[sensor.home_condition_night_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_night_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition night 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_night_4d', + 'unique_id': '0123456-longphrasenight-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_night_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition night 4', + }), + 'context': , + 'entity_id': 'sensor.home_condition_night_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Mostly clear', + }) +# --- +# name: test_sensor[sensor.home_condition_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_day_0d', + 'unique_id': '0123456-longphraseday-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition today', + }), + 'context': , + 'entity_id': 'sensor.home_condition_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Clouds and sunshine with a couple of showers and a thunderstorm around late this afternoon', + }) +# --- +# name: test_sensor[sensor.home_condition_tonight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_condition_tonight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Condition tonight', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'condition_night_0d', + 'unique_id': '0123456-longphrasenight-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_condition_tonight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Condition tonight', + }), + 'context': , + 'entity_id': 'sensor.home_condition_tonight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Partly cloudy', + }) +# --- +# name: test_sensor[sensor.home_dew_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_dew_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dew point', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dew_point', + 'unique_id': '0123456-dewpoint', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home Dew point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.2', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_grass_pollen_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:grass', + 'original_name': 'Grass pollen day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grass_pollen_1d', + 'unique_id': '0123456-grass-1', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Grass pollen day 1', + 'icon': 'mdi:grass', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_grass_pollen_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_grass_pollen_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:grass', + 'original_name': 'Grass pollen day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grass_pollen_2d', + 'unique_id': '0123456-grass-2', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Grass pollen day 2', + 'icon': 'mdi:grass', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_grass_pollen_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_grass_pollen_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:grass', + 'original_name': 'Grass pollen day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grass_pollen_3d', + 'unique_id': '0123456-grass-3', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Grass pollen day 3', + 'icon': 'mdi:grass', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_grass_pollen_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_grass_pollen_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:grass', + 'original_name': 'Grass pollen day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grass_pollen_4d', + 'unique_id': '0123456-grass-4', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Grass pollen day 4', + 'icon': 'mdi:grass', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_grass_pollen_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_grass_pollen_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:grass', + 'original_name': 'Grass pollen today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grass_pollen_0d', + 'unique_id': '0123456-grass-0', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_grass_pollen_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Grass pollen today', + 'icon': 'mdi:grass', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_grass_pollen_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_hours_of_sun_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-partly-cloudy', + 'original_name': 'Hours of sun day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hours_of_sun_1d', + 'unique_id': '0123456-hoursofsun-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Hours of sun day 1', + 'icon': 'mdi:weather-partly-cloudy', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_hours_of_sun_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.4', + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_hours_of_sun_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-partly-cloudy', + 'original_name': 'Hours of sun day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hours_of_sun_2d', + 'unique_id': '0123456-hoursofsun-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Hours of sun day 2', + 'icon': 'mdi:weather-partly-cloudy', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_hours_of_sun_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.7', + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_hours_of_sun_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-partly-cloudy', + 'original_name': 'Hours of sun day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hours_of_sun_3d', + 'unique_id': '0123456-hoursofsun-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Hours of sun day 3', + 'icon': 'mdi:weather-partly-cloudy', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_hours_of_sun_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.4', + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_hours_of_sun_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-partly-cloudy', + 'original_name': 'Hours of sun day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hours_of_sun_4d', + 'unique_id': '0123456-hoursofsun-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Hours of sun day 4', + 'icon': 'mdi:weather-partly-cloudy', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_hours_of_sun_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.2', + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_hours_of_sun_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-partly-cloudy', + 'original_name': 'Hours of sun today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hours_of_sun_0d', + 'unique_id': '0123456-hoursofsun-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_hours_of_sun_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Hours of sun today', + 'icon': 'mdi:weather-partly-cloudy', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_hours_of_sun_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.2', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_mold_pollen_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:blur', + 'original_name': 'Mold pollen day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mold_pollen_1d', + 'unique_id': '0123456-mold-1', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Mold pollen day 1', + 'icon': 'mdi:blur', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_mold_pollen_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_mold_pollen_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:blur', + 'original_name': 'Mold pollen day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mold_pollen_2d', + 'unique_id': '0123456-mold-2', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Mold pollen day 2', + 'icon': 'mdi:blur', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_mold_pollen_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_mold_pollen_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:blur', + 'original_name': 'Mold pollen day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mold_pollen_3d', + 'unique_id': '0123456-mold-3', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Mold pollen day 3', + 'icon': 'mdi:blur', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_mold_pollen_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_mold_pollen_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:blur', + 'original_name': 'Mold pollen day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mold_pollen_4d', + 'unique_id': '0123456-mold-4', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Mold pollen day 4', + 'icon': 'mdi:blur', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_mold_pollen_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_mold_pollen_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:blur', + 'original_name': 'Mold pollen today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mold_pollen_0d', + 'unique_id': '0123456-mold-0', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_mold_pollen_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Mold pollen today', + 'icon': 'mdi:blur', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_mold_pollen_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_precipitation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_precipitation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Precipitation', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'precipitation', + 'unique_id': '0123456-precipitation', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_precipitation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'precipitation_intensity', + 'friendly_name': 'Home Precipitation', + 'state_class': , + 'type': None, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_precipitation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor[sensor.home_pressure_tendency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'falling', + 'rising', + 'steady', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_pressure_tendency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gauge', + 'original_name': 'Pressure tendency', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pressure_tendency', + 'unique_id': '0123456-pressuretendency', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.home_pressure_tendency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'enum', + 'friendly_name': 'Home Pressure tendency', + 'icon': 'mdi:gauge', + 'options': list([ + 'falling', + 'rising', + 'steady', + ]), + }), + 'context': , + 'entity_id': 'sensor.home_pressure_tendency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'falling', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_ragweed_pollen_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:sprout', + 'original_name': 'Ragweed pollen day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ragweed_pollen_1d', + 'unique_id': '0123456-ragweed-1', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Ragweed pollen day 1', + 'icon': 'mdi:sprout', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_ragweed_pollen_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_ragweed_pollen_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:sprout', + 'original_name': 'Ragweed pollen day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ragweed_pollen_2d', + 'unique_id': '0123456-ragweed-2', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Ragweed pollen day 2', + 'icon': 'mdi:sprout', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_ragweed_pollen_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_ragweed_pollen_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:sprout', + 'original_name': 'Ragweed pollen day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ragweed_pollen_3d', + 'unique_id': '0123456-ragweed-3', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Ragweed pollen day 3', + 'icon': 'mdi:sprout', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_ragweed_pollen_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_ragweed_pollen_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:sprout', + 'original_name': 'Ragweed pollen day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ragweed_pollen_4d', + 'unique_id': '0123456-ragweed-4', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Ragweed pollen day 4', + 'icon': 'mdi:sprout', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_ragweed_pollen_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_ragweed_pollen_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:sprout', + 'original_name': 'Ragweed pollen today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ragweed_pollen_0d', + 'unique_id': '0123456-ragweed-0', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_ragweed_pollen_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Ragweed pollen today', + 'icon': 'mdi:sprout', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_ragweed_pollen_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature', + 'unique_id': '0123456-realfeeltemperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.1', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_max_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature max day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_max_1d', + 'unique_id': '0123456-realfeeltemperaturemax-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature max day 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_max_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.9', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_max_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature max day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_max_2d', + 'unique_id': '0123456-realfeeltemperaturemax-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature max day 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_max_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31.6', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_max_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature max day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_max_3d', + 'unique_id': '0123456-realfeeltemperaturemax-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature max day 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_max_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.5', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_max_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature max day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_max_4d', + 'unique_id': '0123456-realfeeltemperaturemax-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature max day 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_max_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.2', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_max_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature max today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_max_0d', + 'unique_id': '0123456-realfeeltemperaturemax-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_max_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature max today', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_max_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.8', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_min_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature min day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_min_1d', + 'unique_id': '0123456-realfeeltemperaturemin-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature min day 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_min_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.8', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_min_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature min day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_min_2d', + 'unique_id': '0123456-realfeeltemperaturemin-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature min day 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_min_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.7', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_min_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature min day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_min_3d', + 'unique_id': '0123456-realfeeltemperaturemin-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature min day 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_min_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.1', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_min_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature min day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_min_4d', + 'unique_id': '0123456-realfeeltemperaturemin-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature min day 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_min_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.3', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_min_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature min today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_min_0d', + 'unique_id': '0123456-realfeeltemperaturemin-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_min_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature min today', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_min_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.1', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade', + 'unique_id': '0123456-realfeeltemperatureshade', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.1', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade max day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_max_1d', + 'unique_id': '0123456-realfeeltemperatureshademax-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade max day 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade max day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_max_2d', + 'unique_id': '0123456-realfeeltemperatureshademax-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade max day 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade max day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_max_3d', + 'unique_id': '0123456-realfeeltemperatureshademax-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade max day 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.5', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade max day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_max_4d', + 'unique_id': '0123456-realfeeltemperatureshademax-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade max day 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.5', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade max today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_max_0d', + 'unique_id': '0123456-realfeeltemperatureshademax-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_max_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade max today', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_max_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.0', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade min day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_min_1d', + 'unique_id': '0123456-realfeeltemperatureshademin-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade min day 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.8', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade min day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_min_2d', + 'unique_id': '0123456-realfeeltemperatureshademin-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade min day 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.7', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade min day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_min_3d', + 'unique_id': '0123456-realfeeltemperatureshademin-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade min day 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.1', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade min day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_min_4d', + 'unique_id': '0123456-realfeeltemperatureshademin-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade min day 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.3', + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RealFeel temperature shade min today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'realfeel_temperature_shade_min_0d', + 'unique_id': '0123456-realfeeltemperatureshademin-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_realfeel_temperature_shade_min_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home RealFeel temperature shade min today', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_realfeel_temperature_shade_min_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.1', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_day_1d', + 'unique_id': '0123456-solarirradianceday-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance day 1', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7447.1', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_day_2d', + 'unique_id': '0123456-solarirradianceday-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance day 2', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7447.1', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_day_3d', + 'unique_id': '0123456-solarirradianceday-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance day 3', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7447.1', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_day_4d', + 'unique_id': '0123456-solarirradianceday-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance day 4', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7447.1', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_night_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_night_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance night 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_night_1d', + 'unique_id': '0123456-solarirradiancenight-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_night_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance night 1', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_night_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '271.6', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_night_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_night_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance night 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_night_2d', + 'unique_id': '0123456-solarirradiancenight-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_night_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance night 2', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_night_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '271.6', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_night_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_night_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance night 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_night_3d', + 'unique_id': '0123456-solarirradiancenight-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_night_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance night 3', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_night_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7447.1', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_night_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_night_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance night 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_night_4d', + 'unique_id': '0123456-solarirradiancenight-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_night_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance night 4', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_night_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '276.1', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_day_0d', + 'unique_id': '0123456-solarirradianceday-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance today', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7447.1', + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_tonight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_solar_irradiance_tonight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'Solar irradiance tonight', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_irradiance_night_0d', + 'unique_id': '0123456-solarirradiancenight-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_solar_irradiance_tonight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Solar irradiance tonight', + 'icon': 'mdi:weather-sunny', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_solar_irradiance_tonight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '271.6', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_day_1d', + 'unique_id': '0123456-thunderstormprobabilityday-1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability day 1', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_day_2d', + 'unique_id': '0123456-thunderstormprobabilityday-2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability day 2', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_day_3d', + 'unique_id': '0123456-thunderstormprobabilityday-3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability day 3', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_day_4d', + 'unique_id': '0123456-thunderstormprobabilityday-4', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability day 4', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_night_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability night 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_night_1d', + 'unique_id': '0123456-thunderstormprobabilitynight-1', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability night 1', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_night_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_night_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability night 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_night_2d', + 'unique_id': '0123456-thunderstormprobabilitynight-2', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability night 2', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_night_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_night_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability night 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_night_3d', + 'unique_id': '0123456-thunderstormprobabilitynight-3', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability night 3', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_night_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_night_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability night 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_night_4d', + 'unique_id': '0123456-thunderstormprobabilitynight-4', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_night_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability night 4', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_night_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_day_0d', + 'unique_id': '0123456-thunderstormprobabilityday-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability today', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_tonight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_thunderstorm_probability_tonight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-lightning', + 'original_name': 'Thunderstorm probability tonight', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thunderstorm_probability_night_0d', + 'unique_id': '0123456-thunderstormprobabilitynight-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_thunderstorm_probability_tonight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Thunderstorm probability tonight', + 'icon': 'mdi:weather-lightning', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_thunderstorm_probability_tonight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_tree_pollen_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:tree-outline', + 'original_name': 'Tree pollen day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tree_pollen_1d', + 'unique_id': '0123456-tree-1', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Tree pollen day 1', + 'icon': 'mdi:tree-outline', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_tree_pollen_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_tree_pollen_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:tree-outline', + 'original_name': 'Tree pollen day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tree_pollen_2d', + 'unique_id': '0123456-tree-2', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Tree pollen day 2', + 'icon': 'mdi:tree-outline', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_tree_pollen_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_tree_pollen_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:tree-outline', + 'original_name': 'Tree pollen day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tree_pollen_3d', + 'unique_id': '0123456-tree-3', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Tree pollen day 3', + 'icon': 'mdi:tree-outline', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_tree_pollen_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_tree_pollen_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:tree-outline', + 'original_name': 'Tree pollen day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tree_pollen_4d', + 'unique_id': '0123456-tree-4', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Tree pollen day 4', + 'icon': 'mdi:tree-outline', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_tree_pollen_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_tree_pollen_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:tree-outline', + 'original_name': 'Tree pollen today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tree_pollen_0d', + 'unique_id': '0123456-tree-0', + 'unit_of_measurement': 'p/m³', + }) +# --- +# name: test_sensor[sensor.home_tree_pollen_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home Tree pollen today', + 'icon': 'mdi:tree-outline', + 'level': 'low', + 'unit_of_measurement': 'p/m³', + }), + 'context': , + 'entity_id': 'sensor.home_tree_pollen_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[sensor.home_uv_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_uv_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'UV index', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index', + 'unique_id': '0123456-uvindex', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor[sensor.home_uv_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home UV index', + 'icon': 'mdi:weather-sunny', + 'level': 'High', + 'state_class': , + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.home_uv_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_sensor[sensor.home_uv_index_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_uv_index_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'UV index day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index_1d', + 'unique_id': '0123456-uvindex-1', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor[sensor.home_uv_index_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home UV index day 1', + 'icon': 'mdi:weather-sunny', + 'level': 'high', + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.home_uv_index_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_sensor[sensor.home_uv_index_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_uv_index_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'UV index day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index_2d', + 'unique_id': '0123456-uvindex-2', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor[sensor.home_uv_index_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home UV index day 2', + 'icon': 'mdi:weather-sunny', + 'level': 'high', + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.home_uv_index_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_sensor[sensor.home_uv_index_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_uv_index_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'UV index day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index_3d', + 'unique_id': '0123456-uvindex-3', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor[sensor.home_uv_index_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home UV index day 3', + 'icon': 'mdi:weather-sunny', + 'level': 'high', + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.home_uv_index_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_sensor[sensor.home_uv_index_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_uv_index_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'UV index day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index_4d', + 'unique_id': '0123456-uvindex-4', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor[sensor.home_uv_index_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home UV index day 4', + 'icon': 'mdi:weather-sunny', + 'level': 'high', + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.home_uv_index_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_sensor[sensor.home_uv_index_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_uv_index_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:weather-sunny', + 'original_name': 'UV index today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index_0d', + 'unique_id': '0123456-uvindex-0', + 'unit_of_measurement': 'UV index', + }) +# --- +# name: test_sensor[sensor.home_uv_index_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'friendly_name': 'Home UV index today', + 'icon': 'mdi:weather-sunny', + 'level': 'moderate', + 'unit_of_measurement': 'UV index', + }), + 'context': , + 'entity_id': 'sensor.home_uv_index_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor[sensor.home_wet_bulb_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wet_bulb_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wet bulb temperature', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wet_bulb_temperature', + 'unique_id': '0123456-wetbulbtemperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wet_bulb_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home Wet bulb temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wet_bulb_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.6', + }) +# --- +# name: test_sensor[sensor.home_wind_chill_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_chill_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind chill temperature', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_chill_temperature', + 'unique_id': '0123456-windchilltemperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_chill_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home Wind chill temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_chill_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.8', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed', + 'unique_id': '0123456-windgust', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'friendly_name': 'Home Wind gust speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.3', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_day_1d', + 'unique_id': '0123456-windgustday-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'NW', + 'friendly_name': 'Home Wind gust speed day 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.8', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_day_2d', + 'unique_id': '0123456-windgustday-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'SSW', + 'friendly_name': 'Home Wind gust speed day 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.1', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_day_3d', + 'unique_id': '0123456-windgustday-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind gust speed day 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.1', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_day_4d', + 'unique_id': '0123456-windgustday-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind gust speed day 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.8', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_night_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_night_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed night 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_night_1d', + 'unique_id': '0123456-windgustnight-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_night_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'S', + 'friendly_name': 'Home Wind gust speed night 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_night_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.8', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_night_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_night_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed night 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_night_2d', + 'unique_id': '0123456-windgustnight-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_night_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind gust speed night 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_night_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.2', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_night_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_night_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed night 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_night_3d', + 'unique_id': '0123456-windgustnight-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_night_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind gust speed night 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_night_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_night_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_night_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed night 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_night_4d', + 'unique_id': '0123456-windgustnight-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_night_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind gust speed night 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_night_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_day_0d', + 'unique_id': '0123456-windgustday-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'S', + 'friendly_name': 'Home Wind gust speed today', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.6', + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_tonight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_gust_speed_tonight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust speed tonight', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust_speed_night_0d', + 'unique_id': '0123456-windgustnight-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_gust_speed_tonight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'WSW', + 'friendly_name': 'Home Wind gust speed tonight', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_gust_speed_tonight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- +# name: test_sensor[sensor.home_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed', + 'unique_id': '0123456-wind', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'friendly_name': 'Home Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.5', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_day_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_day_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed day 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_day_1d', + 'unique_id': '0123456-windday-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_day_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'WNW', + 'friendly_name': 'Home Wind speed day 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_day_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.3', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_day_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_day_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed day 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_day_2d', + 'unique_id': '0123456-windday-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_day_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'SSW', + 'friendly_name': 'Home Wind speed day 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_day_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16.7', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_day_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_day_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed day 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_day_3d', + 'unique_id': '0123456-windday-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_day_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'WNW', + 'friendly_name': 'Home Wind speed day 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_day_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.0', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_day_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_day_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed day 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_day_4d', + 'unique_id': '0123456-windday-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_day_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind speed day 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_day_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_night_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_night_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed night 1', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_night_1d', + 'unique_id': '0123456-windnight-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_night_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'SSE', + 'friendly_name': 'Home Wind speed night 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_night_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.4', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_night_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_night_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed night 2', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_night_2d', + 'unique_id': '0123456-windnight-2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_night_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind speed night 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_night_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.3', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_night_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_night_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed night 3', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_night_3d', + 'unique_id': '0123456-windnight-3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_night_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind speed night 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_night_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.1', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_night_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_night_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed night 4', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_night_4d', + 'unique_id': '0123456-windnight-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_night_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'W', + 'friendly_name': 'Home Wind speed night 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_night_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.3', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed today', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_day_0d', + 'unique_id': '0123456-windday-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'SSE', + 'friendly_name': 'Home Wind speed today', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.0', + }) +# --- +# name: test_sensor[sensor.home_wind_speed_tonight-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_wind_speed_tonight', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed tonight', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_speed_night_0d', + 'unique_id': '0123456-windnight-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_wind_speed_tonight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'wind_speed', + 'direction': 'WNW', + 'friendly_name': 'Home Wind speed tonight', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_wind_speed_tonight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.4', + }) +# --- diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py index bc75ef17309..07b126e0856 100644 --- a/tests/components/accuweather/test_config_flow.py +++ b/tests/components/accuweather/test_config_flow.py @@ -1,10 +1,10 @@ """Define tests for the AccuWeather config flow.""" -from unittest.mock import PropertyMock, patch +from unittest.mock import patch from accuweather import ApiError, InvalidApiKeyError, RequestsExceededError -from homeassistant.components.accuweather.const import CONF_FORECAST, DOMAIN +from homeassistant.components.accuweather.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant @@ -140,52 +140,3 @@ async def test_create_entry(hass: HomeAssistant) -> None: assert result["data"][CONF_LATITUDE] == 55.55 assert result["data"][CONF_LONGITUDE] == 122.12 assert result["data"][CONF_API_KEY] == "32-character-string-1234567890qw" - - -async def test_options_flow(hass: HomeAssistant) -> None: - """Test config flow options.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - unique_id="123456", - data=VALID_CONFIG, - ) - config_entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.components.accuweather.AccuWeather._async_get_data", - return_value=load_json_object_fixture("accuweather/location_data.json"), - ), - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - return_value=load_json_object_fixture( - "accuweather/current_conditions_data.json" - ), - ), - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast" - ), - patch( - "homeassistant.components.accuweather.AccuWeather.requests_remaining", - new_callable=PropertyMock, - return_value=10, - ), - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_FORECAST: True} - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == {CONF_FORECAST: True} - - await hass.async_block_till_done() - assert await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() diff --git a/tests/components/accuweather/test_diagnostics.py b/tests/components/accuweather/test_diagnostics.py index ab77fc337d0..593cde0f0a3 100644 --- a/tests/components/accuweather/test_diagnostics.py +++ b/tests/components/accuweather/test_diagnostics.py @@ -6,7 +6,6 @@ from homeassistant.core import HomeAssistant from . import init_integration -from tests.common import load_json_object_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -19,12 +18,6 @@ async def test_entry_diagnostics( """Test config entry diagnostics.""" entry = await init_integration(hass) - coordinator_data = load_json_object_fixture( - "current_conditions_data.json", "accuweather" - ) - - coordinator_data["forecast"] = [] - result = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert result == snapshot diff --git a/tests/components/accuweather/test_init.py b/tests/components/accuweather/test_init.py index bb5b67e7918..08ad4a66dec 100644 --- a/tests/components/accuweather/test_init.py +++ b/tests/components/accuweather/test_init.py @@ -1,11 +1,14 @@ """Test init of AccuWeather integration.""" -from datetime import timedelta from unittest.mock import patch from accuweather import ApiError -from homeassistant.components.accuweather.const import DOMAIN +from homeassistant.components.accuweather.const import ( + DOMAIN, + UPDATE_INTERVAL_DAILY_FORECAST, + UPDATE_INTERVAL_OBSERVATION, +) from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE @@ -76,30 +79,8 @@ async def test_update_interval(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.LOADED - current = load_json_object_fixture("accuweather/current_conditions_data.json") - future = utcnow() + timedelta(minutes=40) - - with patch( - "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", - return_value=current, - ) as mock_current: - assert mock_current.call_count == 0 - - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert mock_current.call_count == 1 - - -async def test_update_interval_forecast(hass: HomeAssistant) -> None: - """Test correct update interval when forecast is True.""" - entry = await init_integration(hass, forecast=True) - - assert entry.state is ConfigEntryState.LOADED - current = load_json_object_fixture("accuweather/current_conditions_data.json") forecast = load_json_array_fixture("accuweather/forecast_data.json") - future = utcnow() + timedelta(minutes=80) with ( patch( @@ -114,10 +95,14 @@ async def test_update_interval_forecast(hass: HomeAssistant) -> None: assert mock_current.call_count == 0 assert mock_forecast.call_count == 0 - async_fire_time_changed(hass, future) + async_fire_time_changed(hass, utcnow() + UPDATE_INTERVAL_OBSERVATION) await hass.async_block_till_done() assert mock_current.call_count == 1 + + async_fire_time_changed(hass, utcnow() + UPDATE_INTERVAL_DAILY_FORECAST) + await hass.async_block_till_done() + assert mock_forecast.call_count == 1 diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index 8e6e01a4578..e79e49db96d 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -3,29 +3,20 @@ from datetime import timedelta from unittest.mock import PropertyMock, patch -from homeassistant.components.accuweather.const import ATTRIBUTION -from homeassistant.components.sensor import ( - ATTR_OPTIONS, - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) +from accuweather import ApiError, InvalidApiKeyError, RequestsExceededError +from aiohttp.client_exceptions import ClientConnectorError +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.accuweather.const import UPDATE_INTERVAL_DAILY_FORECAST from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, - ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, - CONCENTRATION_PARTS_PER_CUBIC_METER, - PERCENTAGE, STATE_UNAVAILABLE, - UV_INDEX, - UnitOfIrradiance, + Platform, UnitOfLength, UnitOfSpeed, UnitOfTemperature, - UnitOfTime, - UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -42,517 +33,23 @@ from tests.common import ( ) -async def test_sensor_without_forecast( +async def test_sensor( hass: HomeAssistant, entity_registry_enabled_by_default: None, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test states of the sensor without forecast.""" - await init_integration(hass) + """Test states of the sensor.""" + with patch("homeassistant.components.accuweather.PLATFORMS", [Platform.SENSOR]): + entry = await init_integration(hass) - state = hass.states.get("sensor.home_cloud_ceiling") - assert state - assert state.state == "3200.0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-fog" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfLength.METERS - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DISTANCE + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) - entry = entity_registry.async_get("sensor.home_cloud_ceiling") - assert entry - assert entry.unique_id == "0123456-ceiling" - assert entry.options["sensor"] == {"suggested_display_precision": 0} - - state = hass.states.get("sensor.home_precipitation") - assert state - assert state.state == "0.0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR - ) - assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get("type") is None - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert ( - state.attributes.get(ATTR_DEVICE_CLASS) - == SensorDeviceClass.PRECIPITATION_INTENSITY - ) - - entry = entity_registry.async_get("sensor.home_precipitation") - assert entry - assert entry.unique_id == "0123456-precipitation" - - state = hass.states.get("sensor.home_pressure_tendency") - assert state - assert state.state == "falling" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:gauge" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENUM - assert state.attributes.get(ATTR_STATE_CLASS) is None - assert state.attributes.get(ATTR_OPTIONS) == ["falling", "rising", "steady"] - - entry = entity_registry.async_get("sensor.home_pressure_tendency") - assert entry - assert entry.unique_id == "0123456-pressuretendency" - assert entry.translation_key == "pressure_tendency" - - state = hass.states.get("sensor.home_realfeel_temperature") - assert state - assert state.state == "25.1" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_realfeel_temperature") - assert entry - assert entry.unique_id == "0123456-realfeeltemperature" - - state = hass.states.get("sensor.home_uv_index") - assert state - assert state.state == "6" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UV_INDEX - assert state.attributes.get("level") == "High" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_uv_index") - assert entry - assert entry.unique_id == "0123456-uvindex" - - state = hass.states.get("sensor.home_apparent_temperature") - assert state - assert state.state == "22.8" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_apparent_temperature") - assert entry - assert entry.unique_id == "0123456-apparenttemperature" - - state = hass.states.get("sensor.home_cloud_cover") - assert state - assert state.state == "10" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy" - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_cloud_cover") - assert entry - assert entry.unique_id == "0123456-cloudcover" - - state = hass.states.get("sensor.home_dew_point") - assert state - assert state.state == "16.2" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_dew_point") - assert entry - assert entry.unique_id == "0123456-dewpoint" - - state = hass.states.get("sensor.home_realfeel_temperature_shade") - assert state - assert state.state == "21.1" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_realfeel_temperature_shade") - assert entry - assert entry.unique_id == "0123456-realfeeltemperatureshade" - - state = hass.states.get("sensor.home_wet_bulb_temperature") - assert state - assert state.state == "18.6" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_wet_bulb_temperature") - assert entry - assert entry.unique_id == "0123456-wetbulbtemperature" - - state = hass.states.get("sensor.home_wind_chill_temperature") - assert state - assert state.state == "22.8" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - - entry = entity_registry.async_get("sensor.home_wind_chill_temperature") - assert entry - assert entry.unique_id == "0123456-windchilltemperature" - - state = hass.states.get("sensor.home_wind_gust_speed") - assert state - assert state.state == "20.3" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfSpeed.KILOMETERS_PER_HOUR - ) - assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - - entry = entity_registry.async_get("sensor.home_wind_gust_speed") - assert entry - assert entry.unique_id == "0123456-windgust" - - state = hass.states.get("sensor.home_wind_speed") - assert state - assert state.state == "14.5" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfSpeed.KILOMETERS_PER_HOUR - ) - assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - - entry = entity_registry.async_get("sensor.home_wind_speed") - assert entry - assert entry.unique_id == "0123456-wind" - - -async def test_sensor_with_forecast( - hass: HomeAssistant, - entity_registry_enabled_by_default: None, - entity_registry: er.EntityRegistry, -) -> None: - """Test states of the sensor with forecast.""" - await init_integration(hass, forecast=True) - - state = hass.states.get("sensor.home_hours_of_sun_today") - assert state - assert state.state == "7.2" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-partly-cloudy" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.HOURS - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_hours_of_sun_today") - assert entry - assert entry.unique_id == "0123456-hoursofsun-0" - - state = hass.states.get("sensor.home_realfeel_temperature_max_today") - assert state - assert state.state == "29.8" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_realfeel_temperature_max_today") - assert entry - - state = hass.states.get("sensor.home_realfeel_temperature_min_today") - assert state - assert state.state == "15.1" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_realfeel_temperature_min_today") - assert entry - assert entry.unique_id == "0123456-realfeeltemperaturemin-0" - - state = hass.states.get("sensor.home_thunderstorm_probability_today") - assert state - assert state.state == "40" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_thunderstorm_probability_today") - assert entry - assert entry.unique_id == "0123456-thunderstormprobabilityday-0" - - state = hass.states.get("sensor.home_thunderstorm_probability_tonight") - assert state - assert state.state == "40" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_thunderstorm_probability_tonight") - assert entry - assert entry.unique_id == "0123456-thunderstormprobabilitynight-0" - - state = hass.states.get("sensor.home_uv_index_today") - assert state - assert state.state == "5" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UV_INDEX - assert state.attributes.get("level") == "moderate" - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_uv_index_today") - assert entry - assert entry.unique_id == "0123456-uvindex-0" - - state = hass.states.get("sensor.home_air_quality_today") - assert state - assert state.state == "good" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENUM - assert state.attributes.get(ATTR_OPTIONS) == [ - "good", - "hazardous", - "high", - "low", - "moderate", - "unhealthy", - ] - - state = hass.states.get("sensor.home_cloud_cover_today") - assert state - assert state.state == "58" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy" - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_cloud_cover_today") - assert entry - assert entry.unique_id == "0123456-cloudcoverday-0" - - state = hass.states.get("sensor.home_cloud_cover_tonight") - assert state - assert state.state == "65" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy" - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_cloud_cover_tonight") - assert entry - - state = hass.states.get("sensor.home_grass_pollen_today") - assert state - assert state.state == "0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_PARTS_PER_CUBIC_METER - ) - assert state.attributes.get("level") == "low" - assert state.attributes.get(ATTR_ICON) == "mdi:grass" - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_grass_pollen_today") - assert entry - assert entry.unique_id == "0123456-grass-0" - - state = hass.states.get("sensor.home_mold_pollen_today") - assert state - assert state.state == "0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_PARTS_PER_CUBIC_METER - ) - assert state.attributes.get("level") == "low" - assert state.attributes.get(ATTR_ICON) == "mdi:blur" - - entry = entity_registry.async_get("sensor.home_mold_pollen_today") - assert entry - assert entry.unique_id == "0123456-mold-0" - - state = hass.states.get("sensor.home_ragweed_pollen_today") - assert state - assert state.state == "0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_PARTS_PER_CUBIC_METER - ) - assert state.attributes.get("level") == "low" - assert state.attributes.get(ATTR_ICON) == "mdi:sprout" - - entry = entity_registry.async_get("sensor.home_ragweed_pollen_today") - assert entry - assert entry.unique_id == "0123456-ragweed-0" - - state = hass.states.get("sensor.home_realfeel_temperature_shade_max_today") - assert state - assert state.state == "28.0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get( - "sensor.home_realfeel_temperature_shade_max_today" - ) - assert entry - assert entry.unique_id == "0123456-realfeeltemperatureshademax-0" - - state = hass.states.get("sensor.home_realfeel_temperature_shade_min_today") - assert state - assert state.state == "15.1" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - - entry = entity_registry.async_get( - "sensor.home_realfeel_temperature_shade_min_today" - ) - assert entry - assert entry.unique_id == "0123456-realfeeltemperatureshademin-0" - - state = hass.states.get("sensor.home_tree_pollen_today") - assert state - assert state.state == "0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_PARTS_PER_CUBIC_METER - ) - assert state.attributes.get("level") == "low" - assert state.attributes.get(ATTR_ICON) == "mdi:tree-outline" - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = entity_registry.async_get("sensor.home_tree_pollen_today") - assert entry - assert entry.unique_id == "0123456-tree-0" - - state = hass.states.get("sensor.home_wind_speed_today") - assert state - assert state.state == "13.0" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfSpeed.KILOMETERS_PER_HOUR - ) - assert state.attributes.get("direction") == "SSE" - assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - - entry = entity_registry.async_get("sensor.home_wind_speed_today") - assert entry - assert entry.unique_id == "0123456-windday-0" - - state = hass.states.get("sensor.home_wind_speed_tonight") - assert state - assert state.state == "7.4" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfSpeed.KILOMETERS_PER_HOUR - ) - assert state.attributes.get("direction") == "WNW" - assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get(ATTR_STATE_CLASS) is None - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - - entry = entity_registry.async_get("sensor.home_wind_speed_tonight") - assert entry - assert entry.unique_id == "0123456-windnight-0" - - state = hass.states.get("sensor.home_wind_gust_speed_today") - assert state - assert state.state == "29.6" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfSpeed.KILOMETERS_PER_HOUR - ) - assert state.attributes.get("direction") == "S" - assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get(ATTR_STATE_CLASS) is None - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - - entry = entity_registry.async_get("sensor.home_wind_gust_speed_today") - assert entry - assert entry.unique_id == "0123456-windgustday-0" - - state = hass.states.get("sensor.home_wind_gust_speed_tonight") - assert state - assert state.state == "18.5" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfSpeed.KILOMETERS_PER_HOUR - ) - assert state.attributes.get("direction") == "WSW" - assert state.attributes.get(ATTR_ICON) is None - assert state.attributes.get(ATTR_STATE_CLASS) is None - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.WIND_SPEED - - entry = entity_registry.async_get("sensor.home_wind_gust_speed_tonight") - assert entry - assert entry.unique_id == "0123456-windgustnight-0" - - entry = entity_registry.async_get("sensor.home_air_quality_today") - assert entry - assert entry.unique_id == "0123456-airquality-0" - - state = hass.states.get("sensor.home_solar_irradiance_today") - assert state - assert state.state == "7447.1" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny" - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfIrradiance.WATTS_PER_SQUARE_METER - ) - - entry = entity_registry.async_get("sensor.home_solar_irradiance_today") - assert entry - assert entry.unique_id == "0123456-solarirradianceday-0" - - state = hass.states.get("sensor.home_solar_irradiance_tonight") - assert state - assert state.state == "271.6" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny" - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == UnitOfIrradiance.WATTS_PER_SQUARE_METER - ) - - entry = entity_registry.async_get("sensor.home_solar_irradiance_tonight") - assert entry - assert entry.unique_id == "0123456-solarirradiancenight-0" - - state = hass.states.get("sensor.home_condition_today") - assert state - assert ( - state.state - == "Clouds and sunshine with a couple of showers and a thunderstorm around late this afternoon" - ) - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - - entry = entity_registry.async_get("sensor.home_condition_today") - assert entry - assert entry.unique_id == "0123456-longphraseday-0" - - state = hass.states.get("sensor.home_condition_tonight") - assert state - assert state.state == "Partly cloudy" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - - entry = entity_registry.async_get("sensor.home_condition_tonight") - assert entry - assert entry.unique_id == "0123456-longphrasenight-0" + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") async def test_availability(hass: HomeAssistant) -> None: @@ -599,24 +96,88 @@ async def test_availability(hass: HomeAssistant) -> None: assert state.state == "3200.0" +@pytest.mark.parametrize( + "exception", + [ + ApiError, + ConnectionError, + ClientConnectorError, + InvalidApiKeyError, + RequestsExceededError, + ], +) +async def test_availability_forecast(hass: HomeAssistant, exception: Exception) -> None: + """Ensure that we mark the entities unavailable correctly when service is offline.""" + current = load_json_object_fixture("accuweather/current_conditions_data.json") + forecast = load_json_array_fixture("accuweather/forecast_data.json") + entity_id = "sensor.home_hours_of_sun_day_2" + + await init_integration(hass) + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "5.7" + + with ( + patch( + "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", + return_value=current, + ), + patch( + "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", + side_effect=exception, + ), + patch( + "homeassistant.components.accuweather.AccuWeather.requests_remaining", + new_callable=PropertyMock, + return_value=10, + ), + ): + async_fire_time_changed(hass, utcnow() + UPDATE_INTERVAL_DAILY_FORECAST) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + with ( + patch( + "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", + return_value=current, + ), + patch( + "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", + return_value=forecast, + ), + patch( + "homeassistant.components.accuweather.AccuWeather.requests_remaining", + new_callable=PropertyMock, + return_value=10, + ), + ): + async_fire_time_changed(hass, utcnow() + UPDATE_INTERVAL_DAILY_FORECAST * 2) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "5.7" + + async def test_manual_update_entity(hass: HomeAssistant) -> None: """Test manual update entity via service homeassistant/update_entity.""" - await init_integration(hass, forecast=True) + await init_integration(hass) await async_setup_component(hass, "homeassistant", {}) current = load_json_object_fixture("accuweather/current_conditions_data.json") - forecast = load_json_array_fixture("accuweather/forecast_data.json") with ( patch( "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", return_value=current, ) as mock_current, - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", - return_value=forecast, - ) as mock_forecast, patch( "homeassistant.components.accuweather.AccuWeather.requests_remaining", new_callable=PropertyMock, @@ -629,8 +190,7 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: ["sensor.home_cloud_ceiling"]}, blocking=True, ) - assert mock_current.call_count == 1 - assert mock_forecast.call_count == 1 + assert mock_current.call_count == 1 async def test_sensor_imperial_units(hass: HomeAssistant) -> None: diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index 0b9d3e28fb2..b3237ca2958 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -7,7 +7,10 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.accuweather.const import ATTRIBUTION +from homeassistant.components.accuweather.const import ( + ATTRIBUTION, + UPDATE_INTERVAL_DAILY_FORECAST, +) from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_WEATHER_APPARENT_TEMPERATURE, @@ -24,6 +27,7 @@ from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, LEGACY_SERVICE_GET_FORECAST, SERVICE_GET_FORECASTS, + WeatherEntityFeature, ) from homeassistant.const import ( ATTR_ATTRIBUTION, @@ -65,7 +69,10 @@ async def test_weather(hass: HomeAssistant, entity_registry: er.EntityRegistry) assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 20.3 assert state.attributes.get(ATTR_WEATHER_UV_INDEX) == 6 assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert ATTR_SUPPORTED_FEATURES not in state.attributes + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + is WeatherEntityFeature.FORECAST_DAILY + ) entry = entity_registry.async_get("weather.home") assert entry @@ -118,22 +125,17 @@ async def test_availability(hass: HomeAssistant) -> None: async def test_manual_update_entity(hass: HomeAssistant) -> None: """Test manual update entity via service homeassistant/update_entity.""" - await init_integration(hass, forecast=True) + await init_integration(hass) await async_setup_component(hass, "homeassistant", {}) current = load_json_object_fixture("accuweather/current_conditions_data.json") - forecast = load_json_array_fixture("accuweather/forecast_data.json") with ( patch( "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", return_value=current, ) as mock_current, - patch( - "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", - return_value=forecast, - ) as mock_forecast, patch( "homeassistant.components.accuweather.AccuWeather.requests_remaining", new_callable=PropertyMock, @@ -147,12 +149,11 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: blocking=True, ) assert mock_current.call_count == 1 - assert mock_forecast.call_count == 1 async def test_unsupported_condition_icon_data(hass: HomeAssistant) -> None: """Test with unsupported condition icon data.""" - await init_integration(hass, forecast=True, unsupported_icon=True) + await init_integration(hass, unsupported_icon=True) state = hass.states.get("weather.home") assert state.attributes.get(ATTR_FORECAST_CONDITION) is None @@ -171,7 +172,7 @@ async def test_forecast_service( service: str, ) -> None: """Test multiple forecast.""" - await init_integration(hass, forecast=True) + await init_integration(hass) response = await hass.services.async_call( WEATHER_DOMAIN, @@ -195,7 +196,7 @@ async def test_forecast_subscription( """Test multiple forecast.""" client = await hass_ws_client(hass) - await init_integration(hass, forecast=True) + await init_integration(hass) await client.send_json_auto_id( { @@ -235,7 +236,7 @@ async def test_forecast_subscription( return_value=10, ), ): - freezer.tick(timedelta(minutes=80) + timedelta(seconds=1)) + freezer.tick(UPDATE_INTERVAL_DAILY_FORECAST + timedelta(seconds=1)) await hass.async_block_till_done() msg = await client.receive_json() diff --git a/tests/components/airzone_cloud/test_select.py b/tests/components/airzone_cloud/test_select.py new file mode 100644 index 00000000000..1375b052050 --- /dev/null +++ b/tests/components/airzone_cloud/test_select.py @@ -0,0 +1,61 @@ +"""The select tests for the Airzone Cloud platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, SERVICE_SELECT_OPTION +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from .util import async_init_integration + + +async def test_airzone_create_selects( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: + """Test creation of selects.""" + + await async_init_integration(hass) + + # Zones + state = hass.states.get("select.dormitorio_air_quality_mode") + assert state.state == "auto" + + state = hass.states.get("select.salon_air_quality_mode") + assert state.state == "auto" + + +async def test_airzone_select_air_quality_mode(hass: HomeAssistant) -> None: + """Test select Air Quality mode.""" + + await async_init_integration(hass) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.dormitorio_air_quality_mode", + ATTR_OPTION: "Invalid", + }, + blocking=True, + ) + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.dormitorio_air_quality_mode", + ATTR_OPTION: "off", + }, + blocking=True, + ) + + state = hass.states.get("select.dormitorio_air_quality_mode") + assert state.state == "off" diff --git a/tests/components/ambient_network/__init__.py b/tests/components/ambient_network/__init__.py new file mode 100644 index 00000000000..2971b77ddd8 --- /dev/null +++ b/tests/components/ambient_network/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ambient Weather Network integration.""" diff --git a/tests/components/ambient_network/conftest.py b/tests/components/ambient_network/conftest.py new file mode 100644 index 00000000000..ede44b5d92f --- /dev/null +++ b/tests/components/ambient_network/conftest.py @@ -0,0 +1,89 @@ +"""Common fixtures for the Ambient Weather Network integration tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +from aioambient import OpenAPI +import pytest + +from homeassistant.components import ambient_network +from homeassistant.core import HomeAssistant + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ambient_network.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="devices_by_location", scope="package") +def devices_by_location_fixture() -> list[dict[str, Any]]: + """Return result of OpenAPI get_devices_by_location() call.""" + return load_json_array_fixture( + "devices_by_location_response.json", "ambient_network" + ) + + +def mock_device_details_callable(mac_address: str) -> dict[str, Any]: + """Return result of OpenAPI get_device_details() call.""" + return load_json_object_fixture( + f"device_details_response_{mac_address[0].lower()}.json", "ambient_network" + ) + + +@pytest.fixture(name="open_api") +def mock_open_api() -> OpenAPI: + """Mock OpenAPI object.""" + return Mock( + get_device_details=AsyncMock(side_effect=mock_device_details_callable), + ) + + +@pytest.fixture(name="aioambient") +async def mock_aioambient(open_api: OpenAPI): + """Mock aioambient library.""" + with ( + patch( + "homeassistant.components.ambient_network.config_flow.OpenAPI", + return_value=open_api, + ), + patch( + "homeassistant.components.ambient_network.OpenAPI", + return_value=open_api, + ), + ): + yield + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(request) -> MockConfigEntry: + """Mock config entry.""" + return MockConfigEntry( + domain=ambient_network.DOMAIN, + title=f"Station {request.param[0]}", + data={"mac": request.param}, + ) + + +async def setup_platform( + expected_outcome: bool, + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Load the Ambient Network integration with the provided OpenAPI and config entry.""" + + config_entry.add_to_hass(hass) + assert ( + await hass.config_entries.async_setup(config_entry.entry_id) == expected_outcome + ) + await hass.async_block_till_done() diff --git a/tests/components/ambient_network/fixtures/device_details_response_a.json b/tests/components/ambient_network/fixtures/device_details_response_a.json new file mode 100644 index 00000000000..40491e2631c --- /dev/null +++ b/tests/components/ambient_network/fixtures/device_details_response_a.json @@ -0,0 +1,34 @@ +{ + "_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "macAddress": "AA:AA:AA:AA:AA:AA", + "lastData": { + "stationtype": "AMBWeatherPro_V5.0.6", + "dateutc": 1699474320000, + "tempf": 82.9, + "dewPoint": 82.0, + "feelsLike": 85.0, + "humidity": 60, + "windspeedmph": 8.72, + "windgustmph": 9.17, + "maxdailygust": 22.82, + "winddir": 11, + "uv": 0, + "solarradiation": 37.64, + "hourlyrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0, + "monthlyrainin": 0, + "totalrainin": 26.402, + "baromrelin": 29.586, + "baromabsin": 28.869, + "batt_co2": 1, + "type": "weather-data", + "created_at": 1699474320914, + "dateutc5": 1699474200000, + "lastRain": 1698659100000, + "tz": "America/Chicago" + }, + "info": { + "name": "Station A" + } +} diff --git a/tests/components/ambient_network/fixtures/device_details_response_b.json b/tests/components/ambient_network/fixtures/device_details_response_b.json new file mode 100644 index 00000000000..8249f6f0c30 --- /dev/null +++ b/tests/components/ambient_network/fixtures/device_details_response_b.json @@ -0,0 +1,7 @@ +{ + "_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "macAddress": "BB:BB:BB:BB:BB:BB", + "info": { + "name": "Station B" + } +} diff --git a/tests/components/ambient_network/fixtures/device_details_response_c.json b/tests/components/ambient_network/fixtures/device_details_response_c.json new file mode 100644 index 00000000000..8e171f35374 --- /dev/null +++ b/tests/components/ambient_network/fixtures/device_details_response_c.json @@ -0,0 +1,33 @@ +{ + "_id": "cccccccccccccccccccccccccccccccc", + "macAddress": "CC:CC:CC:CC:CC:CC", + "lastData": { + "stationtype": "AMBWeatherPro_V5.0.6", + "dateutc": 1699474320000, + "tempf": 82.9, + "dewPoint": 82.0, + "feelsLike": 85.0, + "humidity": 60, + "windspeedmph": 8.72, + "windgustmph": 9.17, + "maxdailygust": 22.82, + "winddir": 11, + "uv": 0, + "solarradiation": 37.64, + "hourlyrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0, + "monthlyrainin": 0, + "totalrainin": 26.402, + "baromrelin": 29.586, + "baromabsin": 28.869, + "batt_co2": 1, + "type": "weather-data", + "dateutc5": 1699474200000, + "lastRain": 1698659100000, + "tz": "America/Chicago" + }, + "info": { + "name": "Station C" + } +} diff --git a/tests/components/ambient_network/fixtures/devices_by_location_response.json b/tests/components/ambient_network/fixtures/devices_by_location_response.json new file mode 100644 index 00000000000..848ba0a7b87 --- /dev/null +++ b/tests/components/ambient_network/fixtures/devices_by_location_response.json @@ -0,0 +1,364 @@ +[ + { + "_id": "aaaaaaaaaaaaaaaaaaaaaaaa", + "macAddress": "AA:AA:AA:AA:AA:AA", + "lastData": { + "stationtype": "AMBWeatherPro_V5.0.6", + "dateutc": 1699474320000, + "tempf": 82.9, + "humidity": 60, + "windspeedmph": 8.72, + "windgustmph": 9.17, + "maxdailygust": 22.82, + "winddir": 11, + "uv": 0, + "solarradiation": 37.64, + "hourlyrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0, + "monthlyrainin": 0, + "totalrainin": 26.402, + "baromrelin": 29.586, + "baromabsin": 28.869, + "batt_co2": 1, + "type": "weather-data", + "created_at": 1699474320914, + "dateutc5": 1699474200000, + "lastRain": 1698659100000, + "tz": "America/Chicago" + }, + "info": { + "name": "Station A", + "coords": { + "geo": { + "coordinates": [-97.0, 32.0], + "type": "Point" + }, + "elevation": 237.0, + "location": "Location A", + "coords": { + "lon": -97.0, + "lat": 32.0 + } + }, + "indoor": false, + "slug": "aaaaaaaaaaaaaaaaaaaaaaaa" + }, + "tz": { + "name": "America/Chicago" + } + }, + { + "_id": "bbbbbbbbbbbbbbbbbbbbbbbb", + "macAddress": "BB:BB:BB:BB:BB:BB", + "lastData": { + "stationtype": "AMBWeatherV4.2.6", + "dateutc": 1700716980000, + "baromrelin": 29.342, + "baromabsin": 29.342, + "tempf": 35.8, + "humidity": 88, + "winddir": 237, + "winddir_avg10m": 221, + "windspeedmph": 0, + "windspdmph_avg10m": 0, + "windgustmph": 1.3, + "maxdailygust": 12.3, + "hourlyrainin": 0, + "eventrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0.024, + "monthlyrainin": 0.331, + "yearlyrainin": 12.382, + "solarradiation": 0, + "uv": 0, + "soilhum2": 0, + "type": "weather-data", + "created_at": 1700717004020, + "dateutc5": 1700716800000, + "lastRain": 1700445000000, + "discreets": { + "humidity1": [41, 42, 43] + }, + "tz": "America/Chicago" + }, + "info": { + "name": "Station B", + "coords": { + "coords": { + "lat": 32.0, + "lon": -97.0 + }, + "location": "Location B", + "elevation": 226.0, + "geo": { + "type": "Point", + "coordinates": [-97.0, 32.0] + } + }, + "indoor": false, + "slug": "bbbbbbbbbbbbbbbbbbbbbbbb" + }, + "tz": { + "name": "America/Chicago" + } + }, + { + "_id": "cccccccccccccccccccccccc", + "macAddress": "CC:CC:CC:CC:CC:CC", + "lastData": {}, + "info": { + "name": "Station C", + "coords": { + "geo": { + "coordinates": [-97.0, 32.0], + "type": "Point" + }, + "elevation": 242.0, + "location": "Location C", + "coords": { + "lon": -97.0, + "lat": 32.0 + } + }, + "indoor": false, + "slug": "cccccccccccccccccccccccc" + }, + "tz": { + "name": "America/Chicago" + } + }, + { + "_id": "dddddddddddddddddddddddd", + "macAddress": "DD:DD:DD:DD:DD:DD", + "lastData": { + "stationtype": "AMBWeatherPro_V5.1.3", + "dateutc": 1700716920000, + "tempf": 38.1, + "humidity": 85, + "windspeedmph": 0, + "windgustmph": 0, + "maxdailygust": 0, + "winddir": 89, + "uv": 0, + "solarradiation": 0, + "hourlyrainin": 0, + "eventrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0.028, + "monthlyrainin": 0.327, + "yearlyrainin": 12.76, + "totalrainin": 12.76, + "baromrelin": 29.731, + "baromabsin": 29.338, + "type": "weather-data", + "created_at": 1700716969446, + "dateutc5": 1700716800000, + "lastRain": 1700449500000, + "tz": "America/Chicago" + }, + "info": { + "name": "Station D", + "coords": { + "coords": { + "lat": 32.0, + "lon": -97.0 + }, + "address": "", + "location": "Location D", + "elevation": 221.0, + "address_components": [ + { + "long_name": "1234", + "short_name": "1234", + "types": ["street_number"] + }, + { + "long_name": "D Street", + "short_name": "D St.", + "types": ["route"] + }, + { + "long_name": "D Town", + "short_name": "D Town", + "types": ["locality", "political"] + }, + { + "long_name": "D County", + "short_name": "D County", + "types": ["administrative_area_level_2", "political"] + }, + { + "long_name": "Delaware", + "short_name": "DE", + "types": ["administrative_area_level_1", "political"] + }, + { + "long_name": "United States", + "short_name": "US", + "types": ["country", "political"] + }, + { + "long_name": "12345", + "short_name": "12345", + "types": ["postal_code"] + } + ], + "geo": { + "type": "Point", + "coordinates": [-97.0, 32.0] + } + }, + "indoor": false, + "slug": "dddddddddddddddddddddddd" + }, + "tz": { + "name": "America/Chicago" + } + }, + { + "_id": "eeeeeeeeeeeeeeeeeeeeeeee", + "macAddress": "EE:EE:EE:EE:EE:EE", + "lastData": { + "stationtype": "AMBWeatherV4.3.4", + "dateutc": 1700716920000, + "baromrelin": 29.238, + "baromabsin": 29.238, + "tempf": 45, + "humidity": 55, + "winddir": 98, + "winddir_avg10m": 185, + "windspeedmph": 1.1, + "windspdmph_avg10m": 1.3, + "windgustmph": 3.4, + "maxdailygust": 12.5, + "hourlyrainin": 0, + "eventrainin": 0, + "dailyrainin": 0, + "weeklyrainin": 0.059, + "monthlyrainin": 0.39, + "yearlyrainin": 31.268, + "lightning_day": 1, + "lightning_time": 1700700515000, + "lightning_distance": 8.7, + "batt_lightning": 0, + "solarradiation": 0, + "uv": 0, + "batt_co2": 1, + "type": "weather-data", + "created_at": 1700716954726, + "dateutc5": 1700716800000, + "lastRain": 1700445300000, + "lightnings": [ + [1700713320000, 0], + [1700713380000, 0], + [1700713440000, 0], + [1700713500000, 0], + [1700713560000, 0], + [1700713620000, 0], + [1700713680000, 0], + [1700713740000, 0], + [1700713800000, 0], + [1700713860000, 0], + [1700713920000, 0], + [1700713980000, 0], + [1700714040000, 0], + [1700714100000, 0], + [1700714160000, 0], + [1700714220000, 0], + [1700714280000, 0], + [1700714340000, 0], + [1700714400000, 0], + [1700714460000, 0], + [1700714520000, 0], + [1700714580000, 0], + [1700714640000, 0], + [1700714700000, 0], + [1700714760000, 0], + [1700714820000, 0], + [1700714880000, 0], + [1700714940000, 0], + [1700715000000, 0], + [1700715060000, 0], + [1700715120000, 0], + [1700715180000, 0], + [1700715240000, 0], + [1700715300000, 0], + [1700715360000, 0], + [1700715420000, 0], + [1700715480000, 0], + [1700715540000, 0], + [1700715600000, 0], + [1700715660000, 0], + [1700715720000, 0], + [1700715780000, 0], + [1700715840000, 0], + [1700715900000, 0], + [1700715960000, 0], + [1700716020000, 0], + [1700716080000, 0], + [1700716140000, 0], + [1700716200000, 0], + [1700716260000, 0], + [1700716320000, 0], + [1700716380000, 0], + [1700716440000, 0], + [1700716500000, 0], + [1700716560000, 0], + [1700716620000, 0], + [1700716680000, 0], + [1700716740000, 0], + [1700716800000, 0], + [1700716860000, 0], + [1700716920000, 0] + ], + "lightning_hour": 0, + "tz": "America/Chicago" + }, + "info": { + "name": "Station E", + "coords": { + "coords": { + "lat": 32.0, + "lon": -97.0 + }, + "location": "Location E", + "elevation": 236.0, + "geo": { + "type": "Point", + "coordinates": [-97.0, 32.0] + } + }, + "indoor": false, + "slug": "eeeeeeeeeeeeeeeeeeeeeeee" + }, + "tz": { + "name": "America/Chicago" + } + }, + { + "_id": "ffffffffffffffffffffffff", + "macAddress": "FF:FF:FF:FF:FF:FF", + "lastData": {}, + "info": { + "name": "", + "coords": { + "coords": { + "lat": 32.0, + "lon": -97.0 + }, + "location": "Location F", + "elevation": 242.0, + "geo": { + "type": "Point", + "coordinates": [-97.0, 32.0] + } + }, + "indoor": false, + "slug": "ffffffffffffffffffffffff" + }, + "tz": { + "name": "America/Chicago" + } + } +] diff --git a/tests/components/ambient_network/snapshots/test_sensor.ambr b/tests/components/ambient_network/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..377018c54be --- /dev/null +++ b/tests/components/ambient_network/snapshots/test_sensor.ambr @@ -0,0 +1,856 @@ +# serializer version: 1 +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_absolute_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_absolute_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Absolute pressure', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'absolute_pressure', + 'unique_id': 'AA:AA:AA:AA:AA:AA_baromabsin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_absolute_pressure-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_daily_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_daily_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Daily rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_rain', + 'unique_id': 'AA:AA:AA:AA:AA:AA_dailyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_daily_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'precipitation', + 'friendly_name': 'Station A Daily rain', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_daily_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_dew_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_dew_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dew point', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dew_point', + 'unique_id': 'AA:AA:AA:AA:AA:AA_dewPoint', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_dew_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'temperature', + 'friendly_name': 'Station A Dew point', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_dew_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.7777777777778', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_feels_like-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_feels_like', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Feels like', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'feels_like', + 'unique_id': 'AA:AA:AA:AA:AA:AA_feelsLike', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_feels_like-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'temperature', + 'friendly_name': 'Station A Feels like', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_feels_like', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.4444444444444', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_hourly_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_hourly_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hourly rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hourly_rain', + 'unique_id': 'AA:AA:AA:AA:AA:AA_hourlyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_hourly_rain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'precipitation_intensity', + 'friendly_name': 'Station A Hourly rain', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_hourly_rain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:AA_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'humidity', + 'friendly_name': 'Station A Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.station_a_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_irradiance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_irradiance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Irradiance', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:AA_solarradiation', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_irradiance-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_last_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_last_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_rain', + 'unique_id': 'AA:AA:AA:AA:AA:AA_lastRain', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_last_rain-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_max_daily_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_max_daily_gust', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max daily gust', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_daily_gust', + 'unique_id': 'AA:AA:AA:AA:AA:AA_maxdailygust', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_max_daily_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_speed', + 'friendly_name': 'Station A Max daily gust', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_max_daily_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36.72523008', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_monthly_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_monthly_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Monthly rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'monthly_rain', + 'unique_id': 'AA:AA:AA:AA:AA:AA_monthlyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_monthly_rain-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_relative_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_relative_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relative pressure', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'relative_pressure', + 'unique_id': 'AA:AA:AA:AA:AA:AA_baromrelin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_relative_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'pressure', + 'friendly_name': 'Station A Relative pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_relative_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1001.89694313129', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:AA_tempf', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'temperature', + 'friendly_name': 'Station A Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.2777777777778', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_uv_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_uv_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'UV index', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uv_index', + 'unique_id': 'AA:AA:AA:AA:AA:AA_uv', + 'unit_of_measurement': 'index', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_uv_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'friendly_name': 'Station A UV index', + 'state_class': , + 'unit_of_measurement': 'index', + }), + 'context': , + 'entity_id': 'sensor.station_a_uv_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_weekly_rain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_weekly_rain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Weekly rain', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'weekly_rain', + 'unique_id': 'AA:AA:AA:AA:AA:AA_weeklyrainin', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_weekly_rain-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_direction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_wind_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wind direction', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_direction', + 'unique_id': 'AA:AA:AA:AA:AA:AA_winddir', + 'unit_of_measurement': '°', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_direction-state] + None +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_gust-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_wind_gust', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind gust', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust', + 'unique_id': 'AA:AA:AA:AA:AA:AA_windgustmph', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_speed', + 'friendly_name': 'Station A Wind gust', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_wind_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.75768448', + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_a_wind_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind speed', + 'platform': 'ambient_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:AA:AA:AA:AA:AA_windspeedmph', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[AA:AA:AA:AA:AA:AA][sensor.station_a_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by ambientnetwork.net', + 'device_class': 'wind_speed', + 'friendly_name': 'Station A Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_a_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.03347968', + }) +# --- diff --git a/tests/components/ambient_network/test_config_flow.py b/tests/components/ambient_network/test_config_flow.py new file mode 100644 index 00000000000..d9093de7234 --- /dev/null +++ b/tests/components/ambient_network/test_config_flow.py @@ -0,0 +1,85 @@ +"""Test the Ambient Weather Network config flow.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from aioambient import OpenAPI +import pytest + +from homeassistant.components.ambient_network.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True) +async def test_happy_path( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + open_api: OpenAPI, + aioambient: AsyncMock, + devices_by_location: list[dict[str, Any]], + config_entry: ConfigEntry, +) -> None: + """Test the happy path.""" + + setup_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert setup_result["type"] == FlowResultType.FORM + assert setup_result["step_id"] == "user" + + with patch.object( + open_api, + "get_devices_by_location", + AsyncMock(return_value=devices_by_location), + ): + user_result = await hass.config_entries.flow.async_configure( + setup_result["flow_id"], + {"location": {"latitude": 10.0, "longitude": 20.0, "radius": 1.0}}, + ) + + assert user_result["type"] == FlowResultType.FORM + assert user_result["step_id"] == "station" + + stations_result = await hass.config_entries.flow.async_configure( + user_result["flow_id"], + { + "station": "AA:AA:AA:AA:AA:AA", + }, + ) + + assert stations_result["type"] == FlowResultType.CREATE_ENTRY + assert stations_result["title"] == config_entry.title + assert stations_result["data"] == config_entry.data + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_no_station_found( + hass: HomeAssistant, + aioambient: AsyncMock, + open_api: OpenAPI, +) -> None: + """Test that we abort when we cannot find a station in the area.""" + + setup_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert setup_result["type"] == FlowResultType.FORM + assert setup_result["step_id"] == "user" + + with patch.object( + open_api, + "get_devices_by_location", + AsyncMock(return_value=[]), + ): + user_result = await hass.config_entries.flow.async_configure( + setup_result["flow_id"], + {"location": {"latitude": 10.0, "longitude": 20.0, "radius": 1.0}}, + ) + + assert user_result["type"] == FlowResultType.FORM + assert user_result["step_id"] == "user" + assert user_result["errors"] == {"base": "no_stations_found"} diff --git a/tests/components/ambient_network/test_sensor.py b/tests/components/ambient_network/test_sensor.py new file mode 100644 index 00000000000..b556c0c9c7c --- /dev/null +++ b/tests/components/ambient_network/test_sensor.py @@ -0,0 +1,123 @@ +"""Test Ambient Weather Network sensors.""" + +from datetime import datetime, timedelta +from unittest.mock import patch + +from aioambient import OpenAPI +from aioambient.errors import RequestError +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_platform + +from tests.common import async_fire_time_changed + + +@freeze_time("2023-11-08") +@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True) +async def test_sensors( + hass: HomeAssistant, + open_api: OpenAPI, + aioambient, + config_entry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all sensors under normal operation.""" + await setup_platform(True, hass, config_entry) + + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=f"{entity_entry.entity_id}-state" + ) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + + +@freeze_time("2023-11-09") +@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True) +async def test_sensors_with_stale_data( + hass: HomeAssistant, open_api: OpenAPI, aioambient, config_entry +) -> None: + """Test that the sensors are not populated if the data is stale.""" + await setup_platform(False, hass, config_entry) + + sensor = hass.states.get("sensor.station_a_absolute_pressure") + assert sensor is None + + +@freeze_time("2023-11-08") +@pytest.mark.parametrize("config_entry", ["BB:BB:BB:BB:BB:BB"], indirect=True) +async def test_sensors_with_no_data( + hass: HomeAssistant, open_api: OpenAPI, aioambient, config_entry +) -> None: + """Test that the sensors are not populated if the last data is absent.""" + await setup_platform(False, hass, config_entry) + + sensor = hass.states.get("sensor.station_b_absolute_pressure") + assert sensor is None + + +@freeze_time("2023-11-08") +@pytest.mark.parametrize("config_entry", ["CC:CC:CC:CC:CC:CC"], indirect=True) +async def test_sensors_with_no_update_time( + hass: HomeAssistant, open_api: OpenAPI, aioambient, config_entry +) -> None: + """Test that the sensors are not populated if the update time is missing.""" + await setup_platform(False, hass, config_entry) + + sensor = hass.states.get("sensor.station_c_absolute_pressure") + assert sensor is None + + +@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True) +async def test_sensors_disappearing( + hass: HomeAssistant, + open_api: OpenAPI, + aioambient, + config_entry, + caplog, +) -> None: + """Test that we log errors properly.""" + + initial_datetime = datetime(year=2023, month=11, day=8) + with freeze_time(initial_datetime) as frozen_datetime: + # Normal state, sensor is available. + await setup_platform(True, hass, config_entry) + sensor = hass.states.get("sensor.station_a_relative_pressure") + assert sensor is not None + assert float(sensor.state) == pytest.approx(1001.89694313129) + + # Sensor becomes unavailable if the network is unavailable. Log message + # should only show up once. + for _ in range(5): + with patch.object( + open_api, "get_device_details", side_effect=RequestError() + ): + frozen_datetime.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + sensor = hass.states.get("sensor.station_a_relative_pressure") + assert sensor is not None + assert sensor.state == "unavailable" + assert caplog.text.count("Cannot connect to Ambient Network") == 1 + + # Network comes back. Sensor should start reporting again. Log message + # should only show up once. + for _ in range(5): + frozen_datetime.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + sensor = hass.states.get("sensor.station_a_relative_pressure") + assert sensor is not None + assert float(sensor.state) == pytest.approx(1001.89694313129) + assert caplog.text.count("Fetching ambient_network data recovered") == 1 diff --git a/tests/components/apple_tv/test_remote.py b/tests/components/apple_tv/test_remote.py index f831518d75a..bc8a0e6a2dd 100644 --- a/tests/components/apple_tv/test_remote.py +++ b/tests/components/apple_tv/test_remote.py @@ -5,25 +5,37 @@ from unittest.mock import AsyncMock import pytest from homeassistant.components.apple_tv.remote import AppleTVRemote -from homeassistant.components.remote import ATTR_DELAY_SECS, ATTR_NUM_REPEATS +from homeassistant.components.remote import ( + ATTR_DELAY_SECS, + ATTR_HOLD_SECS, + ATTR_NUM_REPEATS, +) @pytest.mark.parametrize( - ("command", "method"), + ("command", "method", "hold_secs"), [ - ("up", "remote_control.up"), - ("wakeup", "power.turn_on"), - ("volume_up", "audio.volume_up"), - ("home_hold", "remote_control.home"), + ("up", "remote_control.up", 0.0), + ("wakeup", "power.turn_on", 0.0), + ("volume_up", "audio.volume_up", 0.0), + ("home", "remote_control.home", 1.0), + ("select", "remote_control.select", 1.0), ], - ids=["up", "wakeup", "volume_up", "home_hold"], + ids=["up", "wakeup", "volume_up", "home", "select"], ) -async def test_send_command(command: str, method: str) -> None: +async def test_send_command(command: str, method: str, hold_secs: float) -> None: """Test "send_command" method.""" remote = AppleTVRemote("test", "test", None) remote.atv = AsyncMock() await remote.async_send_command( - [command], **{ATTR_NUM_REPEATS: 1, ATTR_DELAY_SECS: 0} + [command], + **{ATTR_NUM_REPEATS: 1, ATTR_DELAY_SECS: 0, ATTR_HOLD_SECS: hold_secs}, ) assert len(remote.atv.method_calls) == 1 - assert str(remote.atv.method_calls[0]) == f"call.{method}()" + if hold_secs >= 1: + assert ( + str(remote.atv.method_calls[0]) + == f"call.{method}(action=)" + ) + else: + assert str(remote.atv.method_calls[0]) == f"call.{method}()" diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 5b3fc2a723e..61e6d0e4660 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -21,6 +21,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_NAME, + CONF_ID, EVENT_HOMEASSISTANT_STARTED, SERVICE_RELOAD, SERVICE_TOGGLE, @@ -692,7 +693,9 @@ async def test_reload_config_handles_load_fails(hass: HomeAssistant, calls) -> N assert len(calls) == 2 -@pytest.mark.parametrize("service", ["turn_off_stop", "turn_off_no_stop", "reload"]) +@pytest.mark.parametrize( + "service", ["turn_off_stop", "turn_off_no_stop", "reload", "reload_single"] +) async def test_automation_stops(hass: HomeAssistant, calls, service) -> None: """Test that turning off / reloading stops any running actions as appropriate.""" entity_id = "automation.hello" @@ -700,6 +703,7 @@ async def test_automation_stops(hass: HomeAssistant, calls, service) -> None: config = { automation.DOMAIN: { + "id": "sun", "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event"}, "action": [ @@ -737,7 +741,7 @@ async def test_automation_stops(hass: HomeAssistant, calls, service) -> None: {ATTR_ENTITY_ID: entity_id, automation.CONF_STOP_ACTIONS: False}, blocking=True, ) - else: + elif service == "reload": config[automation.DOMAIN]["alias"] = "goodbye" with patch( "homeassistant.config.load_yaml_config_file", @@ -747,6 +751,19 @@ async def test_automation_stops(hass: HomeAssistant, calls, service) -> None: await hass.services.async_call( automation.DOMAIN, SERVICE_RELOAD, blocking=True ) + else: # service == "reload_single" + config[automation.DOMAIN]["alias"] = "goodbye" + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "sun"}, + blocking=True, + ) hass.states.async_set(test_entity, "goodbye") await hass.async_block_till_done() @@ -801,6 +818,238 @@ async def test_reload_unchanged_does_not_stop( assert len(calls) == 1 +async def test_reload_single_unchanged_does_not_stop( + hass: HomeAssistant, calls +) -> None: + """Test that reloading stops any running actions as appropriate.""" + test_entity = "test.entity" + + config = { + automation.DOMAIN: { + "id": "sun", + "alias": "hello", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + {"event": "running"}, + {"wait_template": "{{ is_state('test.entity', 'goodbye') }}"}, + {"service": "test.automation"}, + ], + } + } + assert await async_setup_component(hass, automation.DOMAIN, config) + + running = asyncio.Event() + + @callback + def running_cb(event): + running.set() + + hass.bus.async_listen_once("running", running_cb) + hass.states.async_set(test_entity, "hello") + + hass.bus.async_fire("test_event") + await running.wait() + assert len(calls) == 0 + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "sun"}, + blocking=True, + ) + + hass.states.async_set(test_entity, "goodbye") + await hass.async_block_till_done() + + assert len(calls) == 1 + + +async def test_reload_single_add_automation(hass: HomeAssistant, calls) -> None: + """Test that reloading a single automation.""" + config1 = {automation.DOMAIN: {}} + config2 = { + automation.DOMAIN: { + "id": "sun", + "alias": "hello", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [{"service": "test.automation"}], + } + } + assert await async_setup_component(hass, automation.DOMAIN, config1) + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 0 + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config2, + ): + await hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "sun"}, + blocking=True, + ) + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_reload_single_parallel_calls(hass: HomeAssistant, calls) -> None: + """Test reloading single automations in parallel.""" + config1 = {automation.DOMAIN: {}} + config2 = { + automation.DOMAIN: [ + { + "id": "sun", + "alias": "hello", + "trigger": {"platform": "event", "event_type": "test_event_sun"}, + "action": [{"service": "test.automation"}], + }, + { + "id": "moon", + "alias": "goodbye", + "trigger": {"platform": "event", "event_type": "test_event_moon"}, + "action": [{"service": "test.automation"}], + }, + { + "id": "mars", + "alias": "goodbye", + "trigger": {"platform": "event", "event_type": "test_event_mars"}, + "action": [{"service": "test.automation"}], + }, + { + "id": "venus", + "alias": "goodbye", + "trigger": {"platform": "event", "event_type": "test_event_venus"}, + "action": [{"service": "test.automation"}], + }, + ] + } + assert await async_setup_component(hass, automation.DOMAIN, config1) + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 0 + + # Trigger multiple reload service calls, each automation is reloaded twice. + # This tests the logic in the `ReloadServiceHelper` which avoids redundant + # reloads of the same target automation. + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config2, + ): + tasks = [ + hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "sun"}, + blocking=False, + ), + hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "moon"}, + blocking=False, + ), + hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "mars"}, + blocking=False, + ), + hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "venus"}, + blocking=False, + ), + hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "sun"}, + blocking=False, + ), + hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "moon"}, + blocking=False, + ), + hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "mars"}, + blocking=False, + ), + hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "venus"}, + blocking=False, + ), + ] + await asyncio.gather(*tasks) + await hass.async_block_till_done() + + # Sanity check to ensure all automations are correctly setup + hass.bus.async_fire("test_event_sun") + await hass.async_block_till_done() + assert len(calls) == 1 + hass.bus.async_fire("test_event_moon") + await hass.async_block_till_done() + assert len(calls) == 2 + hass.bus.async_fire("test_event_mars") + await hass.async_block_till_done() + assert len(calls) == 3 + hass.bus.async_fire("test_event_venus") + await hass.async_block_till_done() + assert len(calls) == 4 + + +async def test_reload_single_remove_automation(hass: HomeAssistant, calls) -> None: + """Test that reloading a single automation.""" + config1 = { + automation.DOMAIN: { + "id": "sun", + "alias": "hello", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [{"service": "test.automation"}], + } + } + config2 = {automation.DOMAIN: {}} + assert await async_setup_component(hass, automation.DOMAIN, config1) + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 1 + + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config2, + ): + await hass.services.async_call( + automation.DOMAIN, + SERVICE_RELOAD, + {CONF_ID: "sun"}, + blocking=True, + ) + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 1 + + async def test_reload_moved_automation_without_alias( hass: HomeAssistant, calls ) -> None: diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index c1e040ccd49..d4056c1e38e 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -219,6 +219,45 @@ def two_adapters_fixture(): yield +@pytest.fixture(name="crashed_adapter") +def crashed_adapter_fixture(): + """Fixture that mocks one crashed adapter on Linux.""" + with ( + patch( + "homeassistant.components.bluetooth.platform.system", + return_value="Linux", + ), + patch( + "habluetooth.scanner.platform.system", + return_value="Linux", + ), + patch( + "bluetooth_adapters.systems.platform.system", + return_value="Linux", + ), + patch("habluetooth.scanner.SYSTEM", "Linux"), + patch( + "bluetooth_adapters.systems.linux.LinuxAdapters.refresh", + ), + patch( + "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", + { + "hci0": { + "address": "00:00:00:00:00:00", + "hw_version": "usb:v1D6Bp0246d053F", + "passive_scan": True, + "sw_version": "homeassistant", + "manufacturer": None, + "product": None, + "product_id": None, + "vendor_id": None, + }, + }, + ), + ): + yield + + @pytest.fixture(name="one_adapter_old_bluez") def one_adapter_old_bluez(): """Fixture that mocks two adapters on Linux.""" diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 89243223129..d044be76e6d 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -32,6 +32,9 @@ async def test_options_flow_disabled_not_setup( domain=DOMAIN, data={}, options={}, unique_id=DEFAULT_ADDRESS ) entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + ws_client = await hass_ws_client(hass) await ws_client.send_json( @@ -65,7 +68,7 @@ async def test_async_step_user_macos(hass: HomeAssistant, macos_adapter: None) - result["flow_id"], user_input={} ) assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Core Bluetooth" + assert result2["title"] == "Apple Unknown MacOS Model (Core Bluetooth)" assert result2["data"] == {} assert len(mock_setup_entry.mock_calls) == 1 @@ -81,6 +84,11 @@ async def test_async_step_user_linux_one_adapter( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "single_adapter" + assert result["description_placeholders"] == { + "name": "hci0 (00:00:00:00:00:01)", + "model": "Bluetooth Adapter 5.0 (cc01:aa01)", + "manufacturer": "ACME", + } with ( patch("homeassistant.components.bluetooth.async_setup", return_value=True), patch( @@ -91,11 +99,26 @@ async def test_async_step_user_linux_one_adapter( result["flow_id"], user_input={} ) assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "00:00:00:00:00:01" + assert ( + result2["title"] == "ACME Bluetooth Adapter 5.0 (cc01:aa01) (00:00:00:00:00:01)" + ) assert result2["data"] == {} assert len(mock_setup_entry.mock_calls) == 1 +async def test_async_step_user_linux_crashed_adapter( + hass: HomeAssistant, crashed_adapter: None +) -> None: + """Test setting up manually with one crashed adapter on Linux.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_adapters" + + async def test_async_step_user_linux_two_adapters( hass: HomeAssistant, two_adapters: None ) -> None: @@ -107,6 +130,10 @@ async def test_async_step_user_linux_two_adapters( ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "multiple_adapters" + assert result["data_schema"].schema["adapter"].container == { + "hci0": "hci0 (00:00:00:00:00:01) ACME Bluetooth Adapter 5.0 (cc01:aa01)", + "hci1": "hci1 (00:00:00:00:00:02) ACME Bluetooth Adapter 5.0 (cc01:aa01)", + } with ( patch("homeassistant.components.bluetooth.async_setup", return_value=True), patch( @@ -117,7 +144,9 @@ async def test_async_step_user_linux_two_adapters( result["flow_id"], user_input={CONF_ADAPTER: "hci1"} ) assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "00:00:00:00:00:02" + assert ( + result2["title"] == "ACME Bluetooth Adapter 5.0 (cc01:aa01) (00:00:00:00:00:02)" + ) assert result2["data"] == {} assert len(mock_setup_entry.mock_calls) == 1 @@ -153,6 +182,11 @@ async def test_async_step_integration_discovery(hass: HomeAssistant) -> None: data={CONF_ADAPTER: "hci0", CONF_DETAILS: details}, ) assert result["type"] is FlowResultType.FORM + assert result["description_placeholders"] == { + "name": "hci0 (00:00:00:00:00:01)", + "model": "Unknown", + "manufacturer": "ACME", + } assert result["step_id"] == "single_adapter" with ( patch("homeassistant.components.bluetooth.async_setup", return_value=True), @@ -164,7 +198,7 @@ async def test_async_step_integration_discovery(hass: HomeAssistant) -> None: result["flow_id"], user_input={} ) assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "00:00:00:00:00:01" + assert result2["title"] == "ACME Unknown (00:00:00:00:00:01)" assert result2["data"] == {} assert len(mock_setup_entry.mock_calls) == 1 @@ -196,7 +230,7 @@ async def test_async_step_integration_discovery_during_onboarding_one_adapter( data={CONF_ADAPTER: "hci0", CONF_DETAILS: details}, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "00:00:00:00:00:01" + assert result["title"] == "ACME Unknown (00:00:00:00:00:01)" assert result["data"] == {} assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_onboarding.mock_calls) == 1 @@ -240,11 +274,11 @@ async def test_async_step_integration_discovery_during_onboarding_two_adapters( data={CONF_ADAPTER: "hci1", CONF_DETAILS: details2}, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "00:00:00:00:00:01" + assert result["title"] == "ACME Unknown (00:00:00:00:00:01)" assert result["data"] == {} assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "00:00:00:00:00:02" + assert result2["title"] == "ACME Unknown (00:00:00:00:00:02)" assert result2["data"] == {} assert len(mock_setup_entry.mock_calls) == 2 @@ -278,7 +312,7 @@ async def test_async_step_integration_discovery_during_onboarding( data={CONF_ADAPTER: "Core Bluetooth", CONF_DETAILS: details}, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Core Bluetooth" + assert result["title"] == "ACME Unknown (Core Bluetooth)" assert result["data"] == {} assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_onboarding.mock_calls) == 1 diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 3d29080d56c..c67bd583b1e 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -176,6 +176,14 @@ async def test_diagnostics( "source": "00:00:00:00:00:01", "start_time": ANY, "type": "HaScanner", + "current_mode": { + "__type": "", + "repr": "", + }, + "requested_mode": { + "__type": "", + "repr": "", + }, }, { "adapter": "hci1", @@ -203,6 +211,14 @@ async def test_diagnostics( "source": "00:00:00:00:00:02", "start_time": ANY, "type": "FakeHaScanner", + "current_mode": { + "__type": "", + "repr": "", + }, + "requested_mode": { + "__type": "", + "repr": "", + }, }, ], "slot_manager": { @@ -376,6 +392,14 @@ async def test_diagnostics_macos( "source": "Core Bluetooth", "start_time": ANY, "type": "FakeHaScanner", + "current_mode": { + "__type": "", + "repr": "", + }, + "requested_mode": { + "__type": "", + "repr": "", + }, } ], "slot_manager": { @@ -543,6 +567,14 @@ async def test_diagnostics_remote_adapter( "source": "00:00:00:00:00:01", "start_time": ANY, "type": "HaScanner", + "current_mode": { + "__type": "", + "repr": "", + }, + "requested_mode": { + "__type": "", + "repr": "", + }, }, { "connectable": True, diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 65962ac8f21..82fa0341966 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -2807,6 +2807,19 @@ async def test_can_unsetup_bluetooth_single_adapter_macos( await hass.async_block_till_done() +async def test_default_address_config_entries_removed_linux( + hass: HomeAssistant, + mock_bleak_scanner_start: MagicMock, + one_adapter: None, +) -> None: + """Test default address entries are removed on linux.""" + entry = MockConfigEntry(domain=bluetooth.DOMAIN, data={}, unique_id=DEFAULT_ADDRESS) + entry.add_to_hass(hass) + await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(bluetooth.DOMAIN) + + async def test_can_unsetup_bluetooth_single_adapter_linux( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, @@ -2889,6 +2902,16 @@ async def test_auto_detect_bluetooth_adapters_linux_multiple( assert len(hass.config_entries.flow.async_progress(bluetooth.DOMAIN)) == 2 +async def test_auto_detect_bluetooth_adapters_skips_crashed( + hass: HomeAssistant, crashed_adapter: None +) -> None: + """Test we skip crashed adapters on linux.""" + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(bluetooth.DOMAIN) + assert len(hass.config_entries.flow.async_progress(bluetooth.DOMAIN)) == 0 + + async def test_auto_detect_bluetooth_adapters_linux_none_found( hass: HomeAssistant, ) -> None: @@ -3015,12 +3038,14 @@ async def test_discover_new_usb_adapters( "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", + "manufacturer": "ACME", }, "hci1": { "address": "00:00:00:00:00:02", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", + "manufacturer": "ACME", }, }, ), @@ -3088,12 +3113,14 @@ async def test_discover_new_usb_adapters_with_firmware_fallback_delay( "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", + "manufacturer": "ACME", }, "hci1": { "address": "00:00:00:00:00:02", "hw_version": "usb:v1D6Bp0246d053F", "passive_scan": False, "sw_version": "homeassistant", + "manufacturer": "ACME", }, }, ), diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index 523364e0dfd..5658aea523b 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -22,7 +22,6 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import ( - _get_manager, async_setup_with_one_adapter, generate_advertisement_data, generate_ble_device, @@ -183,7 +182,7 @@ async def test_adapter_needs_reset_at_start( with ( patch( "habluetooth.scanner.OriginalBleakScanner.start", - side_effect=[BleakError(error), None], + side_effect=[BleakError(error), BleakError(error), None], ), patch( "habluetooth.util.recover_adapter", return_value=True @@ -239,46 +238,47 @@ async def test_recovery_from_dbus_restart( assert called_start == 1 - start_time_monotonic = time.monotonic() - mock_discovered = [MagicMock()] + start_time_monotonic = time.monotonic() + mock_discovered = [MagicMock()] - # Ensure we don't restart the scanner if we don't need to - with patch_bluetooth_time( - start_time_monotonic + 10, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + # Ensure we don't restart the scanner if we don't need to + with patch_bluetooth_time( + start_time_monotonic + 10, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert called_start == 1 + assert called_start == 1 - # Fire a callback to reset the timer - with patch_bluetooth_time( - start_time_monotonic, - ): - _callback( - generate_ble_device("44:44:33:11:23:42", "any_name"), - generate_advertisement_data(local_name="any_name"), - ) + # Fire a callback to reset the timer + with patch_bluetooth_time( + start_time_monotonic, + ): + _callback( + generate_ble_device("44:44:33:11:23:42", "any_name"), + generate_advertisement_data(local_name="any_name"), + ) - # Ensure we don't restart the scanner if we don't need to - with patch_bluetooth_time( - start_time_monotonic + 20, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + # Ensure we don't restart the scanner if we don't need to + with patch_bluetooth_time( + start_time_monotonic + 20, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert called_start == 1 + assert called_start == 1 - # We hit the timer, so we restart the scanner - with patch_bluetooth_time( - start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, - ): - async_fire_time_changed( - hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL + timedelta(seconds=20) - ) - await hass.async_block_till_done() + # We hit the timer, so we restart the scanner + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, + ): + async_fire_time_changed( + hass, + dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL + timedelta(seconds=20), + ) + await hass.async_block_till_done() - assert called_start == 2 + assert called_start == 2 async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: @@ -327,43 +327,42 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: assert called_start == 1 - scanner = _get_manager() - mock_discovered = [MagicMock()] + mock_discovered = [MagicMock()] - # Ensure we don't restart the scanner if we don't need to - with patch_bluetooth_time( - start_time_monotonic + 10, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + # Ensure we don't restart the scanner if we don't need to + with patch_bluetooth_time( + start_time_monotonic + 10, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert called_start == 1 + assert called_start == 1 - # Ensure we don't restart the scanner if we don't need to - with patch_bluetooth_time( - start_time_monotonic + 20, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + # Ensure we don't restart the scanner if we don't need to + with patch_bluetooth_time( + start_time_monotonic + 20, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert called_start == 1 + assert called_start == 1 - # We hit the timer with no detections, so we reset the adapter and restart the scanner - with ( - patch_bluetooth_time( - start_time_monotonic - + SCANNER_WATCHDOG_TIMEOUT - + SCANNER_WATCHDOG_INTERVAL.total_seconds(), - ), - patch( - "habluetooth.util.recover_adapter", return_value=True - ) as mock_recover_adapter, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + # We hit the timer with no detections, so we reset the adapter and restart the scanner + with ( + patch_bluetooth_time( + start_time_monotonic + + SCANNER_WATCHDOG_TIMEOUT + + SCANNER_WATCHDOG_INTERVAL.total_seconds(), + ), + patch( + "habluetooth.util.recover_adapter", return_value=True + ) as mock_recover_adapter, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert len(mock_recover_adapter.mock_calls) == 1 - assert called_start == 2 + assert len(mock_recover_adapter.mock_calls) == 1 + assert called_start == 2 async def test_adapter_scanner_fails_to_start_first_time( @@ -418,61 +417,61 @@ async def test_adapter_scanner_fails_to_start_first_time( assert called_start == 1 - scanner = _get_manager() - mock_discovered = [MagicMock()] + mock_discovered = [MagicMock()] - # Ensure we don't restart the scanner if we don't need to - with patch_bluetooth_time( - start_time_monotonic + 10, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + # Ensure we don't restart the scanner if we don't need to + with patch_bluetooth_time( + start_time_monotonic + 10, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert called_start == 1 + assert called_start == 1 - # Ensure we don't restart the scanner if we don't need to - with patch_bluetooth_time( - start_time_monotonic + 20, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + # Ensure we don't restart the scanner if we don't need to + with patch_bluetooth_time( + start_time_monotonic + 20, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert called_start == 1 + assert called_start == 1 - # We hit the timer with no detections, so we reset the adapter and restart the scanner - with ( - patch_bluetooth_time( - start_time_monotonic - + SCANNER_WATCHDOG_TIMEOUT - + SCANNER_WATCHDOG_INTERVAL.total_seconds(), - ), - patch( - "habluetooth.util.recover_adapter", return_value=True - ) as mock_recover_adapter, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + # We hit the timer with no detections, so we reset the adapter and restart the scanner + with ( + patch_bluetooth_time( + start_time_monotonic + + SCANNER_WATCHDOG_TIMEOUT + + SCANNER_WATCHDOG_INTERVAL.total_seconds(), + ), + patch( + "habluetooth.util.recover_adapter", return_value=True + ) as mock_recover_adapter, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert len(mock_recover_adapter.mock_calls) == 1 - assert called_start == 3 + assert len(mock_recover_adapter.mock_calls) == 1 + assert called_start == 4 - # We hit the timer again the previous start call failed, make sure - # we try again - with ( - patch_bluetooth_time( - start_time_monotonic - + SCANNER_WATCHDOG_TIMEOUT - + SCANNER_WATCHDOG_INTERVAL.total_seconds(), - ), - patch( - "habluetooth.util.recover_adapter", return_value=True - ) as mock_recover_adapter, - ): - async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) - await hass.async_block_till_done() + now_monotonic = time.monotonic() + # We hit the timer again the previous start call failed, make sure + # we try again + with ( + patch_bluetooth_time( + now_monotonic + + SCANNER_WATCHDOG_TIMEOUT * 2 + + SCANNER_WATCHDOG_INTERVAL.total_seconds(), + ), + patch( + "habluetooth.util.recover_adapter", return_value=True + ) as mock_recover_adapter, + ): + async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) + await hass.async_block_till_done() - assert len(mock_recover_adapter.mock_calls) == 1 - assert called_start == 4 + assert len(mock_recover_adapter.mock_calls) == 1 + assert called_start == 5 async def test_adapter_fails_to_start_and_takes_a_bit_to_init( @@ -497,9 +496,11 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( nonlocal called_start called_start += 1 if called_start == 1: - raise BleakError("org.bluez.Error.InProgress") - if called_start == 2: raise BleakError("org.freedesktop.DBus.Error.UnknownObject") + if called_start == 2: + raise BleakError("org.bluez.Error.InProgress") + if called_start == 3: + raise BleakError("org.bluez.Error.InProgress") async def stop(self, *args, **kwargs): """Mock Start.""" @@ -538,7 +539,7 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( ): await async_setup_with_one_adapter(hass) - assert called_start == 3 + assert called_start == 4 assert len(mock_recover_adapter.mock_calls) == 1 assert "Waiting for adapter to initialize" in caplog.text diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index c14fb8a58c1..2acc2b0ddfc 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -107,7 +107,7 @@ class FakeBleakClientRaisesOnConnect(BaseFakeBleakClient): async def connect(self, *args, **kwargs): """Connect.""" - raise Exception("Test exception") + raise ConnectionError("Test exception") def _generate_ble_device_and_adv_data( @@ -304,8 +304,9 @@ async def test_release_slot_on_connect_exception( ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) - with pytest.raises(Exception): - assert await client.connect() is False + with pytest.raises(ConnectionError) as exc_info: + await client.connect() + assert str(exc_info.value) == "Test exception" assert allocate_slot_mock.call_count == 1 assert release_slot_mock.call_count == 1 diff --git a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr index c9dd4e3ddb8..dcf68622fdc 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr @@ -96,6 +96,25 @@ 'last_updated': , 'state': '340', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'enum', + 'friendly_name': 'iX xDrive50 Climate status', + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_climate_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'inactive', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', @@ -191,6 +210,25 @@ 'last_updated': , 'state': '472', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'enum', + 'friendly_name': 'i4 eDrive40 Climate status', + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_climate_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heating', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', @@ -261,6 +299,25 @@ 'last_updated': , 'state': '80', }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'enum', + 'friendly_name': 'M340i xDrive Climate status', + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_climate_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'inactive', + }), StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index 80f68b96fe1..b17face10d9 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -10,7 +10,7 @@ import pytest from homeassistant.bootstrap import async_setup_component from homeassistant.components import config from homeassistant.components.config import automation -from homeassistant.const import STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import yaml @@ -82,10 +82,8 @@ async def test_update_automation_config( ) await hass.async_block_till_done() assert sorted(hass.states.async_entity_ids("automation")) == [ - "automation.automation_0", "automation.automation_1", ] - assert hass.states.get("automation.automation_0").state == STATE_UNAVAILABLE assert hass.states.get("automation.automation_1").state == STATE_ON assert resp.status == HTTPStatus.OK @@ -260,10 +258,8 @@ async def test_update_remove_key_automation_config( ) await hass.async_block_till_done() assert sorted(hass.states.async_entity_ids("automation")) == [ - "automation.automation_0", "automation.automation_1", ] - assert hass.states.get("automation.automation_0").state == STATE_UNAVAILABLE assert hass.states.get("automation.automation_1").state == STATE_ON assert resp.status == HTTPStatus.OK @@ -305,10 +301,8 @@ async def test_bad_formatted_automations( ) await hass.async_block_till_done() assert sorted(hass.states.async_entity_ids("automation")) == [ - "automation.automation_0", "automation.automation_1", ] - assert hass.states.get("automation.automation_0").state == STATE_UNAVAILABLE assert hass.states.get("automation.automation_1").state == STATE_ON assert resp.status == HTTPStatus.OK diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index 0052093298e..5ccd948cc6b 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -108,6 +108,15 @@ async def test_services( await call_service(hass, SERVICE_TOGGLE, ent6) assert is_opening(hass, ent6) + # After the unusual state transition: closing -> fully open, toggle should close + set_state(ent5, STATE_OPEN) + await call_service(hass, SERVICE_TOGGLE, ent5) # Start closing + assert is_closing(hass, ent5) + set_state(ent5, STATE_OPEN) # Unusual state transition from closing -> fully open + set_cover_position(ent5, 100) + await call_service(hass, SERVICE_TOGGLE, ent5) # Should close, not open + assert is_closing(hass, ent5) + def call_service(hass, service, ent): """Call any service on entity.""" diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index b373bd4401f..5f44593aabe 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -22,6 +22,7 @@ from homeassistant.const import ( STATE_NOT_HOME, STATE_OFF, STATE_ON, + STATE_UNKNOWN, ) from homeassistant.core import CoreState, HomeAssistant from homeassistant.setup import async_setup_component @@ -150,10 +151,22 @@ async def test_lights_turn_on_when_coming_home_after_sun_set( hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}} ) - hass.states.async_set(f"{DOMAIN}.device_2", STATE_HOME) - + hass.states.async_set(f"{DOMAIN}.device_2", STATE_UNKNOWN) await hass.async_block_till_done() + assert all( + hass.states.get(ent_id).state == STATE_OFF + for ent_id in hass.states.async_entity_ids("light") + ) + hass.states.async_set(f"{DOMAIN}.device_2", STATE_NOT_HOME) + await hass.async_block_till_done() + assert all( + hass.states.get(ent_id).state == STATE_OFF + for ent_id in hass.states.async_entity_ids("light") + ) + + hass.states.async_set(f"{DOMAIN}.device_2", STATE_HOME) + await hass.async_block_till_done() assert all( hass.states.get(ent_id).state == light.STATE_ON for ent_id in hass.states.async_entity_ids("light") diff --git a/tests/components/devolo_home_network/snapshots/test_button.ambr b/tests/components/devolo_home_network/snapshots/test_button.ambr index 3e8e4ae2bb3..126ac4e7cdb 100644 --- a/tests/components/devolo_home_network/snapshots/test_button.ambr +++ b/tests/components/devolo_home_network/snapshots/test_button.ambr @@ -1,47 +1,4 @@ # serializer version: 1 -# name: test_button[identify_device_with_a_blinking_led-async_identify_device_start] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Identify device with a blinking LED', - 'icon': 'mdi:led-on', - }), - 'context': , - 'entity_id': 'button.mock_title_identify_device_with_a_blinking_led', - 'last_changed': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_button[identify_device_with_a_blinking_led-async_identify_device_start].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_title_identify_device_with_a_blinking_led', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:led-on', - 'original_name': 'Identify device with a blinking LED', - 'platform': 'devolo_home_network', - 'supported_features': 0, - 'translation_key': 'identify', - 'unique_id': '1234567890_identify', - 'unit_of_measurement': None, - }) -# --- # name: test_button[identify_device_with_a_blinking_led-plcnet-async_identify_device_start] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -89,49 +46,6 @@ 'unit_of_measurement': None, }) # --- -# name: test_button[restart_device-async_restart] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'restart', - 'friendly_name': 'Mock Title Restart device', - }), - 'context': , - 'entity_id': 'button.mock_title_restart_device', - 'last_changed': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_button[restart_device-async_restart].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': , - 'entity_id': 'button.mock_title_restart_device', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Restart device', - 'platform': 'devolo_home_network', - 'supported_features': 0, - 'translation_key': 'restart', - 'unique_id': '1234567890_restart', - 'unit_of_measurement': None, - }) -# --- # name: test_button[restart_device-device-async_restart] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -179,49 +93,6 @@ 'unit_of_measurement': None, }) # --- -# name: test_button[start_plc_pairing-async_pair_device] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Start PLC pairing', - 'icon': 'mdi:plus-network-outline', - }), - 'context': , - 'entity_id': 'button.mock_title_start_plc_pairing', - 'last_changed': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_button[start_plc_pairing-async_pair_device].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.mock_title_start_plc_pairing', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:plus-network-outline', - 'original_name': 'Start PLC pairing', - 'platform': 'devolo_home_network', - 'supported_features': 0, - 'translation_key': 'pairing', - 'unique_id': '1234567890_pairing', - 'unit_of_measurement': None, - }) -# --- # name: test_button[start_plc_pairing-plcnet-async_pair_device] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -268,49 +139,6 @@ 'unit_of_measurement': None, }) # --- -# name: test_button[start_wps-async_start_wps] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Start WPS', - 'icon': 'mdi:wifi-plus', - }), - 'context': , - 'entity_id': 'button.mock_title_start_wps', - 'last_changed': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_button[start_wps-async_start_wps].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'button', - 'entity_category': None, - 'entity_id': 'button.mock_title_start_wps', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:wifi-plus', - 'original_name': 'Start WPS', - 'platform': 'devolo_home_network', - 'supported_features': 0, - 'translation_key': 'start_wps', - 'unique_id': '1234567890_start_wps', - 'unit_of_measurement': None, - }) -# --- # name: test_button[start_wps-device-async_start_wps] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/devolo_home_network/snapshots/test_image.ambr b/tests/components/devolo_home_network/snapshots/test_image.ambr index ad8ccf43c55..b3924a508cf 100644 --- a/tests/components/devolo_home_network/snapshots/test_image.ambr +++ b/tests/components/devolo_home_network/snapshots/test_image.ambr @@ -11,7 +11,7 @@ 'disabled_by': None, 'domain': 'image', 'entity_category': , - 'entity_id': 'image.mock_title_guest_wifi_credentials_as_qr_code', + 'entity_id': 'image.mock_title_guest_wi_fi_credentials_as_qr_code', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -23,7 +23,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Guest Wifi credentials as QR code', + 'original_name': 'Guest Wi-Fi credentials as QR code', 'platform': 'devolo_home_network', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/devolo_home_network/snapshots/test_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_sensor.ambr index fc173da8294..d985ac35495 100644 --- a/tests/components/devolo_home_network/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_sensor.ambr @@ -45,21 +45,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[connected_wifi_clients-async_get_wifi_connected_station-interval0] +# name: test_sensor[connected_wi_fi_clients-async_get_wifi_connected_station-interval0] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Connected Wifi clients', + 'friendly_name': 'Mock Title Connected Wi-Fi clients', 'state_class': , }), 'context': , - 'entity_id': 'sensor.mock_title_connected_wifi_clients', + 'entity_id': 'sensor.mock_title_connected_wi_fi_clients', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1', }) # --- -# name: test_sensor[connected_wifi_clients-async_get_wifi_connected_station-interval0].1 +# name: test_sensor[connected_wi_fi_clients-async_get_wifi_connected_station-interval0].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -73,7 +73,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.mock_title_connected_wifi_clients', + 'entity_id': 'sensor.mock_title_connected_wi_fi_clients', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -85,7 +85,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Connected Wifi clients', + 'original_name': 'Connected Wi-Fi clients', 'platform': 'devolo_home_network', 'previous_unique_id': None, 'supported_features': 0, @@ -94,20 +94,20 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[neighboring_wifi_networks-async_get_wifi_neighbor_access_points-interval1] +# name: test_sensor[neighboring_wi_fi_networks-async_get_wifi_neighbor_access_points-interval1] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Neighboring Wifi networks', + 'friendly_name': 'Mock Title Neighboring Wi-Fi networks', }), 'context': , - 'entity_id': 'sensor.mock_title_neighboring_wifi_networks', + 'entity_id': 'sensor.mock_title_neighboring_wi_fi_networks', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1', }) # --- -# name: test_sensor[neighboring_wifi_networks-async_get_wifi_neighbor_access_points-interval1].1 +# name: test_sensor[neighboring_wi_fi_networks-async_get_wifi_neighbor_access_points-interval1].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -119,7 +119,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.mock_title_neighboring_wifi_networks', + 'entity_id': 'sensor.mock_title_neighboring_wi_fi_networks', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -131,7 +131,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Neighboring Wifi networks', + 'original_name': 'Neighboring Wi-Fi networks', 'platform': 'devolo_home_network', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/devolo_home_network/snapshots/test_switch.ambr b/tests/components/devolo_home_network/snapshots/test_switch.ambr index 09b56efc784..a2df5d2579f 100644 --- a/tests/components/devolo_home_network/snapshots/test_switch.ambr +++ b/tests/components/devolo_home_network/snapshots/test_switch.ambr @@ -1,97 +1,11 @@ # serializer version: 1 -# name: test_switches[enable_guest_wifi-async_get_wifi_guest_access-async_set_wifi_guest_access-interval0] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Enable guest Wifi', - 'icon': 'mdi:wifi', - }), - 'context': , - 'entity_id': 'switch.mock_title_enable_guest_wifi', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_switches[enable_guest_wifi-async_get_wifi_guest_access-async_set_wifi_guest_access-interval0].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.mock_title_enable_guest_wifi', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:wifi', - 'original_name': 'Enable guest Wifi', - 'platform': 'devolo_home_network', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1234567890_switch_guest_wifi', - 'unit_of_measurement': None, - }) -# --- -# name: test_switches[enable_leds-async_get_led_setting-async_set_led_setting-interval1] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Enable LEDs', - 'icon': 'mdi:led-off', - }), - 'context': , - 'entity_id': 'switch.mock_title_enable_leds', - 'last_changed': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_switches[enable_leds-async_get_led_setting-async_set_led_setting-interval1].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': , - 'entity_id': 'switch.mock_title_enable_leds', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:led-off', - 'original_name': 'Enable LEDs', - 'platform': 'devolo_home_network', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '1234567890_switch_leds', - 'unit_of_measurement': None, - }) -# --- # name: test_update_enable_guest_wifi StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Title Enable guest Wifi', + 'friendly_name': 'Mock Title Enable guest Wi-Fi', }), 'context': , - 'entity_id': 'switch.mock_title_enable_guest_wifi', + 'entity_id': 'switch.mock_title_enable_guest_wi_fi', 'last_changed': , 'last_reported': , 'last_updated': , @@ -110,7 +24,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.mock_title_enable_guest_wifi', + 'entity_id': 'switch.mock_title_enable_guest_wi_fi', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -122,7 +36,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Enable guest Wifi', + 'original_name': 'Enable guest Wi-Fi', 'platform': 'devolo_home_network', 'previous_unique_id': None, 'supported_features': 0, diff --git a/tests/components/devolo_home_network/test_image.py b/tests/components/devolo_home_network/test_image.py index 0ca3936e1ac..80efc4fcc09 100644 --- a/tests/components/devolo_home_network/test_image.py +++ b/tests/components/devolo_home_network/test_image.py @@ -32,7 +32,7 @@ async def test_image_setup(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert ( - hass.states.get(f"{DOMAIN}.{device_name}_guest_wifi_credentials_as_qr_code") + hass.states.get(f"{DOMAIN}.{device_name}_guest_wi_fi_credentials_as_qr_code") is not None ) @@ -51,13 +51,13 @@ async def test_guest_wifi_qr( """Test showing a QR code of the guest wifi credentials.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{DOMAIN}.{device_name}_guest_wifi_credentials_as_qr_code" + state_key = f"{DOMAIN}.{device_name}_guest_wi_fi_credentials_as_qr_code" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() state = hass.states.get(state_key) - assert state.name == "Mock Title Guest Wifi credentials as QR code" + assert state.name == "Mock Title Guest Wi-Fi credentials as QR code" assert state.state == dt_util.utcnow().isoformat() assert entity_registry.async_get(state_key) == snapshot diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index 5b5e05a40d1..efcbaa803df 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -32,9 +32,11 @@ async def test_sensor_setup(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.{device_name}_connected_wifi_clients") is not None + assert ( + hass.states.get(f"{DOMAIN}.{device_name}_connected_wi_fi_clients") is not None + ) assert hass.states.get(f"{DOMAIN}.{device_name}_connected_plc_devices") is None - assert hass.states.get(f"{DOMAIN}.{device_name}_neighboring_wifi_networks") is None + assert hass.states.get(f"{DOMAIN}.{device_name}_neighboring_wi_fi_networks") is None assert ( hass.states.get( f"{DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" @@ -67,12 +69,12 @@ async def test_sensor_setup(hass: HomeAssistant) -> None: ("name", "get_method", "interval"), [ ( - "connected_wifi_clients", + "connected_wi_fi_clients", "async_get_wifi_connected_station", SHORT_UPDATE_INTERVAL, ), ( - "neighboring_wifi_networks", + "neighboring_wi_fi_networks", "async_get_wifi_neighbor_access_points", LONG_UPDATE_INTERVAL, ), diff --git a/tests/components/devolo_home_network/test_switch.py b/tests/components/devolo_home_network/test_switch.py index 0fe5bea5c52..b96697dc9cc 100644 --- a/tests/components/devolo_home_network/test_switch.py +++ b/tests/components/devolo_home_network/test_switch.py @@ -41,7 +41,7 @@ async def test_switch_setup(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(f"{PLATFORM}.{device_name}_enable_guest_wifi") is not None + assert hass.states.get(f"{PLATFORM}.{device_name}_enable_guest_wi_fi") is not None assert hass.states.get(f"{PLATFORM}.{device_name}_enable_leds") is not None await hass.config_entries.async_unload(entry.entry_id) @@ -82,7 +82,7 @@ async def test_update_enable_guest_wifi( """Test state change of a enable_guest_wifi switch device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_enable_guest_wifi" + state_key = f"{PLATFORM}.{device_name}_enable_guest_wi_fi" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -247,7 +247,7 @@ async def test_update_enable_leds( @pytest.mark.parametrize( ("name", "get_method", "update_interval"), [ - ("enable_guest_wifi", "async_get_wifi_guest_access", SHORT_UPDATE_INTERVAL), + ("enable_guest_wi_fi", "async_get_wifi_guest_access", SHORT_UPDATE_INTERVAL), ("enable_leds", "async_get_led_setting", SHORT_UPDATE_INTERVAL), ], ) @@ -284,7 +284,7 @@ async def test_device_failure( @pytest.mark.parametrize( ("name", "set_method"), [ - ("enable_guest_wifi", "async_set_wifi_guest_access"), + ("enable_guest_wi_fi", "async_set_wifi_guest_access"), ("enable_leds", "async_set_led_setting"), ], ) diff --git a/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr b/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c6bc616ffd3 --- /dev/null +++ b/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr @@ -0,0 +1,23 @@ +# serializer version: 1 +# name: test_get_config_entry_diagnostics + dict({ + 'entities': dict({ + }), + 'entry': dict({ + 'data': dict({ + }), + 'disabled_by': None, + 'domain': 'dsmr_reader', + 'entry_id': 'TEST_ENTRY_ID', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'dsmr_reader', + 'unique_id': 'UNIQUE_TEST_ID', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/dsmr_reader/test_diagnostics.py b/tests/components/dsmr_reader/test_diagnostics.py new file mode 100644 index 00000000000..553efd0b38b --- /dev/null +++ b/tests/components/dsmr_reader/test_diagnostics.py @@ -0,0 +1,39 @@ +"""Test the DSMR Reader component diagnostics.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.dsmr_reader.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_get_config_entry_diagnostics( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hass_client: ClientSessionGenerator, +) -> None: + """Test if get_config_entry_diagnostics returns the correct data.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=DOMAIN, + options={}, + entry_id="TEST_ENTRY_ID", + unique_id="UNIQUE_TEST_ID", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.dsmr_reader.async_setup_entry", return_value=True + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, config_entry + ) + assert diagnostics == snapshot diff --git a/tests/components/enigma2/__init__.py b/tests/components/enigma2/__init__.py new file mode 100644 index 00000000000..15580d55b17 --- /dev/null +++ b/tests/components/enigma2/__init__.py @@ -0,0 +1 @@ +"""Tests for the Enigma2 integration.""" diff --git a/tests/components/enigma2/conftest.py b/tests/components/enigma2/conftest.py new file mode 100644 index 00000000000..9bbbda895bd --- /dev/null +++ b/tests/components/enigma2/conftest.py @@ -0,0 +1,90 @@ +"""Test the Enigma2 config flow.""" + +from homeassistant.components.enigma2.const import ( + CONF_DEEP_STANDBY, + CONF_MAC_ADDRESS, + CONF_SOURCE_BOUQUET, + CONF_USE_CHANNEL_ICON, + DEFAULT_DEEP_STANDBY, + DEFAULT_PORT, + DEFAULT_SSL, + DEFAULT_VERIFY_SSL, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) + +MAC_ADDRESS = "12:34:56:78:90:ab" + +TEST_REQUIRED = { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: DEFAULT_SSL, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, +} + +TEST_FULL = { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: DEFAULT_SSL, + CONF_USERNAME: "root", + CONF_PASSWORD: "password", + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, +} + +TEST_IMPORT_FULL = { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_PORT, + CONF_SSL: DEFAULT_SSL, + CONF_USERNAME: "root", + CONF_PASSWORD: "password", + CONF_NAME: "My Player", + CONF_DEEP_STANDBY: DEFAULT_DEEP_STANDBY, + CONF_SOURCE_BOUQUET: "Favourites", + CONF_MAC_ADDRESS: MAC_ADDRESS, + CONF_USE_CHANNEL_ICON: False, +} + +TEST_IMPORT_REQUIRED = {CONF_HOST: "1.1.1.1"} + +EXPECTED_OPTIONS = { + CONF_DEEP_STANDBY: DEFAULT_DEEP_STANDBY, + CONF_SOURCE_BOUQUET: "Favourites", + CONF_USE_CHANNEL_ICON: False, +} + + +class MockDevice: + """A mock Enigma2 device.""" + + mac_address: str | None = "12:34:56:78:90:ab" + _base = "http://1.1.1.1" + + async def _call_api(self, url: str) -> dict: + if url.endswith("/api/about"): + return { + "info": { + "ifaces": [ + { + "mac": self.mac_address, + } + ] + } + } + + def get_version(self): + """Return the version.""" + return None + + async def get_about(self) -> dict: + """Get mock about endpoint.""" + return await self._call_api("/api/about") + + async def close(self): + """Mock close.""" diff --git a/tests/components/enigma2/test_config_flow.py b/tests/components/enigma2/test_config_flow.py new file mode 100644 index 00000000000..dfca569276d --- /dev/null +++ b/tests/components/enigma2/test_config_flow.py @@ -0,0 +1,166 @@ +"""Test the Enigma2 config flow.""" + +from typing import Any +from unittest.mock import patch + +from aiohttp.client_exceptions import ClientError +from openwebif.error import InvalidAuthError +import pytest + +from homeassistant import config_entries +from homeassistant.components.enigma2.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.issue_registry import IssueRegistry + +from .conftest import ( + EXPECTED_OPTIONS, + TEST_FULL, + TEST_IMPORT_FULL, + TEST_IMPORT_REQUIRED, + TEST_REQUIRED, + MockDevice, +) + + +@pytest.fixture +async def user_flow(hass: HomeAssistant) -> str: + """Return a user-initiated flow after filling in host info.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + return result["flow_id"] + + +@pytest.mark.parametrize( + ("test_config"), + [(TEST_FULL), (TEST_REQUIRED)], +) +async def test_form_user( + hass: HomeAssistant, user_flow: str, test_config: dict[str, Any] +): + """Test a successful user initiated flow.""" + with ( + patch( + "openwebif.api.OpenWebIfDevice.__new__", + return_value=MockDevice(), + ), + patch( + "homeassistant.components.enigma2.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure(user_flow, test_config) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == test_config[CONF_HOST] + assert result["data"] == test_config + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error_type"), + [ + (InvalidAuthError, "invalid_auth"), + (ClientError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_user_errors( + hass: HomeAssistant, user_flow, exception: Exception, error_type: str +) -> None: + """Test we handle errors.""" + with patch( + "homeassistant.components.enigma2.config_flow.OpenWebIfDevice.__new__", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure(user_flow, TEST_FULL) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER + assert result["errors"] == {"base": error_type} + + +@pytest.mark.parametrize( + ("test_config", "expected_data", "expected_options"), + [ + (TEST_IMPORT_FULL, TEST_FULL, EXPECTED_OPTIONS), + (TEST_IMPORT_REQUIRED, TEST_REQUIRED, {}), + ], +) +async def test_form_import( + hass: HomeAssistant, + test_config: dict[str, Any], + expected_data: dict[str, Any], + expected_options: dict[str, Any], + issue_registry: IssueRegistry, +) -> None: + """Test we get the form with import source.""" + with ( + patch( + "homeassistant.components.enigma2.config_flow.OpenWebIfDevice.__new__", + return_value=MockDevice(), + ), + patch( + "homeassistant.components.enigma2.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=test_config, + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + + assert issue + assert issue.issue_domain == DOMAIN + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == test_config[CONF_HOST] + assert result["data"] == expected_data + assert result["options"] == expected_options + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error_type"), + [ + (InvalidAuthError, "invalid_auth"), + (ClientError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_import_errors( + hass: HomeAssistant, + exception: Exception, + error_type: str, + issue_registry: IssueRegistry, +) -> None: + """Test we handle errors on import.""" + with patch( + "homeassistant.components.enigma2.config_flow.OpenWebIfDevice.__new__", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=TEST_IMPORT_FULL, + ) + + issue = issue_registry.async_get_issue( + DOMAIN, f"deprecated_yaml_{DOMAIN}_import_issue_{error_type}" + ) + + assert issue + assert issue.issue_domain == DOMAIN + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == error_type diff --git a/tests/components/enigma2/test_init.py b/tests/components/enigma2/test_init.py new file mode 100644 index 00000000000..93a130eef54 --- /dev/null +++ b/tests/components/enigma2/test_init.py @@ -0,0 +1,38 @@ +"""Test the Enigma2 integration init.""" + +from unittest.mock import patch + +from homeassistant.components.enigma2.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import TEST_REQUIRED, MockDevice + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test successful unload of entry.""" + with ( + patch( + "homeassistant.components.enigma2.OpenWebIfDevice.__new__", + return_value=MockDevice(), + ), + patch( + "homeassistant.components.enigma2.media_player.async_setup_entry", + return_value=True, + ), + ): + entry = MockConfigEntry(domain=DOMAIN, data=TEST_REQUIRED, title="name") + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) diff --git a/tests/components/esphome/test_valve.py b/tests/components/esphome/test_valve.py new file mode 100644 index 00000000000..5ba7bcbe187 --- /dev/null +++ b/tests/components/esphome/test_valve.py @@ -0,0 +1,196 @@ +"""Test ESPHome valves.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import call + +from aioesphomeapi import ( + APIClient, + EntityInfo, + EntityState, + UserService, + ValveInfo, + ValveOperation, + ValveState, +) + +from homeassistant.components.valve import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN as VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_SET_VALVE_POSITION, + SERVICE_STOP_VALVE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from .conftest import MockESPHomeDevice + + +async def test_valve_entity( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a generic valve entity.""" + entity_info = [ + ValveInfo( + object_id="myvalve", + key=1, + name="my valve", + unique_id="my_valve", + supports_position=True, + supports_stop=True, + ) + ] + states = [ + ValveState( + key=1, + position=0.5, + current_operation=ValveOperation.IS_OPENING, + ) + ] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_OPENING + assert state.attributes[ATTR_CURRENT_POSITION] == 50 + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: "valve.test_myvalve"}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.0)]) + mock_client.valve_command.reset_mock() + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: "valve.test_myvalve"}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, position=1.0)]) + mock_client.valve_command.reset_mock() + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test_myvalve", ATTR_POSITION: 50}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.5)]) + mock_client.valve_command.reset_mock() + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_STOP_VALVE, + {ATTR_ENTITY_ID: "valve.test_myvalve"}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, stop=True)]) + mock_client.valve_command.reset_mock() + + mock_device.set_state( + ValveState(key=1, position=0.0, current_operation=ValveOperation.IDLE) + ) + await hass.async_block_till_done() + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_CLOSED + + mock_device.set_state( + ValveState(key=1, position=0.5, current_operation=ValveOperation.IS_CLOSING) + ) + await hass.async_block_till_done() + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_CLOSING + + mock_device.set_state( + ValveState(key=1, position=1.0, current_operation=ValveOperation.IDLE) + ) + await hass.async_block_till_done() + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_OPEN + + +async def test_valve_entity_without_position( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a generic valve entity without position or stop.""" + entity_info = [ + ValveInfo( + object_id="myvalve", + key=1, + name="my valve", + unique_id="my_valve", + supports_position=False, + supports_stop=False, + ) + ] + states = [ + ValveState( + key=1, + position=0.5, + current_operation=ValveOperation.IS_OPENING, + ) + ] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_OPENING + assert ATTR_CURRENT_POSITION not in state.attributes + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: "valve.test_myvalve"}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.0)]) + mock_client.valve_command.reset_mock() + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: "valve.test_myvalve"}, + blocking=True, + ) + mock_client.valve_command.assert_has_calls([call(key=1, position=1.0)]) + mock_client.valve_command.reset_mock() + + mock_device.set_state( + ValveState(key=1, position=0.0, current_operation=ValveOperation.IDLE) + ) + await hass.async_block_till_done() + state = hass.states.get("valve.test_myvalve") + assert state is not None + assert state.state == STATE_CLOSED diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 2dffba74fef..33d266eb24b 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -6,7 +6,6 @@ from unittest.mock import patch import pytest -from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.core import HomeAssistant import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component @@ -18,12 +17,6 @@ from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -@pytest.fixture(autouse=True) -async def setup_repairs(hass: HomeAssistant): - """Set up the repairs integration.""" - assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) - - @pytest.fixture(autouse=True) async def fixture_supervisor_environ(): """Mock os environ for supervisor.""" diff --git a/tests/components/homeworks/test_config_flow.py b/tests/components/homeworks/test_config_flow.py index 53128c4cd65..d00b5a13150 100644 --- a/tests/components/homeworks/test_config_flow.py +++ b/tests/components/homeworks/test_config_flow.py @@ -9,7 +9,6 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAI from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.homeworks.const import ( CONF_ADDR, - CONF_BUTTONS, CONF_DIMMERS, CONF_INDEX, CONF_KEYPADS, @@ -161,26 +160,6 @@ async def test_import_flow( { CONF_ADDR: "[02:08:02:01]", CONF_NAME: "Foyer Keypad", - CONF_BUTTONS: [ - { - CONF_NAME: "Morning", - CONF_NUMBER: 1, - CONF_LED: True, - CONF_RELEASE_DELAY: None, - }, - { - CONF_NAME: "Relax", - CONF_NUMBER: 2, - CONF_LED: True, - CONF_RELEASE_DELAY: None, - }, - { - CONF_NAME: "Dim up", - CONF_NUMBER: 3, - CONF_LED: False, - CONF_RELEASE_DELAY: 0.2, - }, - ], } ], }, @@ -207,16 +186,7 @@ async def test_import_flow( "keypads": [ { "addr": "[02:08:02:01]", - "buttons": [ - { - "led": True, - "name": "Morning", - "number": 1, - "release_delay": None, - }, - {"led": True, "name": "Relax", "number": 2, "release_delay": None}, - {"led": False, "name": "Dim up", "number": 3, "release_delay": 0.2}, - ], + "buttons": [], "name": "Foyer Keypad", } ], @@ -574,8 +544,12 @@ async def test_options_add_remove_light_flow( ) +@pytest.mark.parametrize("keypad_address", ["[02:08:03:01]", "[02:08:03]"]) async def test_options_add_remove_keypad_flow( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homeworks: MagicMock + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homeworks: MagicMock, + keypad_address: str, ) -> None: """Test options flow to add and remove a keypad.""" mock_config_entry.add_to_hass(hass) @@ -596,7 +570,7 @@ async def test_options_add_remove_keypad_flow( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - CONF_ADDR: "[02:08:03:01]", + CONF_ADDR: keypad_address, CONF_NAME: "Hall Keypad", }, ) @@ -622,7 +596,7 @@ async def test_options_add_remove_keypad_flow( ], "name": "Foyer Keypad", }, - {"addr": "[02:08:03:01]", "buttons": [], "name": "Hall Keypad"}, + {"addr": keypad_address, "buttons": [], "name": "Hall Keypad"}, ], "port": 1234, } @@ -642,7 +616,7 @@ async def test_options_add_remove_keypad_flow( assert result["step_id"] == "remove_keypad" assert result["data_schema"].schema["index"].options == { "0": "Foyer Keypad ([02:08:02:01])", - "1": "Hall Keypad ([02:08:03:01])", + "1": f"Hall Keypad ({keypad_address})", } result = await hass.config_entries.options.async_configure( @@ -655,7 +629,7 @@ async def test_options_add_remove_keypad_flow( {"addr": "[02:08:01:01]", "name": "Foyer Sconces", "rate": 1.0}, ], "host": "192.168.0.1", - "keypads": [{"addr": "[02:08:03:01]", "buttons": [], "name": "Hall Keypad"}], + "keypads": [{"addr": keypad_address, "buttons": [], "name": "Hall Keypad"}], "port": 1234, } await hass.async_block_till_done() diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 751ba8aa288..d09444808d8 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -38,7 +38,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow @@ -205,6 +205,16 @@ async def test_mode_service_calls( ) device.set_system_mode.assert_called_once_with("auto") + device.set_system_mode.reset_mock() + device.set_system_mode.side_effect = aiosomecomfort.UnexpectedResponse + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + blocking=True, + ) + async def test_auxheat_service_calls( hass: HomeAssistant, device: MagicMock, config_entry: MagicMock @@ -300,6 +310,15 @@ async def test_fan_modes_service_calls( blocking=True, ) + device.set_fan_mode.side_effect = aiosomecomfort.UnexpectedResponse + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_DIFFUSE}, + blocking=True, + ) + async def test_service_calls_off_mode( hass: HomeAssistant, @@ -344,7 +363,7 @@ async def test_service_calls_off_mode( device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError caplog.clear() - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -431,6 +450,12 @@ async def test_service_calls_off_mode( device.set_hold_heat.assert_called_once_with(False) device.set_hold_cool.assert_called_once_with(False) + device.set_hold_heat.reset_mock() + device.set_hold_cool.reset_mock() + + device.set_setpoint_cool.reset_mock() + device.set_setpoint_heat.reset_mock() + reset_mock(device) device.raw_ui_data["StatusHeat"] = 2 @@ -506,7 +531,7 @@ async def test_service_calls_cool_mode( device.set_setpoint_cool.reset_mock() device.set_setpoint_cool.side_effect = aiosomecomfort.SomeComfortError - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -538,7 +563,7 @@ async def test_service_calls_cool_mode( device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError caplog.clear() - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, @@ -570,7 +595,7 @@ async def test_service_calls_cool_mode( device.hold_heat = True device.hold_cool = True - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -709,7 +734,7 @@ async def test_service_calls_heat_mode( device.set_hold_heat.reset_mock() device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -747,7 +772,7 @@ async def test_service_calls_heat_mode( device.set_setpoint_heat.reset_mock() device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -780,7 +805,7 @@ async def test_service_calls_heat_mode( device.hold_heat = True device.hold_cool = True - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -811,7 +836,7 @@ async def test_service_calls_heat_mode( reset_mock(device) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, @@ -828,7 +853,7 @@ async def test_service_calls_heat_mode( device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, @@ -841,6 +866,16 @@ async def test_service_calls_heat_mode( device.set_setpoint_cool.assert_not_called() assert "Temperature out of range" in caplog.text + device.set_hold_heat.side_effect = aiosomecomfort.UnexpectedResponse + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) + reset_mock(device) caplog.clear() with pytest.raises(HomeAssistantError): @@ -951,7 +986,7 @@ async def test_service_calls_auto_mode( device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -966,7 +1001,7 @@ async def test_service_calls_auto_mode( device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError device.set_setpoint_cool.side_effect = aiosomecomfort.SomeComfortError - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -1021,7 +1056,7 @@ async def test_service_calls_auto_mode( device.set_setpoint_heat.side_effect = None device.set_setpoint_cool.side_effect = None - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, diff --git a/tests/components/iaqualink/test_utils.py b/tests/components/iaqualink/test_utils.py index c803fb48b09..b9aba93523c 100644 --- a/tests/components/iaqualink/test_utils.py +++ b/tests/components/iaqualink/test_utils.py @@ -15,9 +15,10 @@ async def test_await_or_reraise(hass: HomeAssistant) -> None: async_noop = async_returns(None) await await_or_reraise(async_noop()) - with pytest.raises(Exception): - async_ex = async_raises(Exception) + with pytest.raises(Exception) as exc_info: + async_ex = async_raises(Exception("Test exception")) await await_or_reraise(async_ex()) + assert str(exc_info.value) == "Test exception" with pytest.raises(HomeAssistantError): async_ex = async_raises(AqualinkServiceException) diff --git a/tests/components/insteon/mock_setup.py b/tests/components/insteon/mock_setup.py new file mode 100644 index 00000000000..c0d90509a50 --- /dev/null +++ b/tests/components/insteon/mock_setup.py @@ -0,0 +1,44 @@ +"""Utility to setup the Insteon integration.""" + +from homeassistant.components.insteon.api import async_load_api +from homeassistant.components.insteon.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import MOCK_USER_INPUT_PLM +from .mock_devices import MockDevices + +from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + + +async def async_mock_setup( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + config_data: dict | None = None, + config_options: dict | None = None, +): + """Set up for tests.""" + config_data = MOCK_USER_INPUT_PLM if config_data is None else config_data + config_options = {} if config_options is None else config_options + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="abcde12345", + data=config_data, + options=config_options, + ) + config_entry.add_to_hass(hass) + async_load_api(hass) + + ws_client = await hass_ws_client(hass) + devices = MockDevices() + await devices.async_load() + + dev_reg = dr.async_get(hass) + # Create device registry entry for mock node + ha_device = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "11.11.11")}, + name="Device 11.11.11", + ) + return ws_client, devices, ha_device, dev_reg diff --git a/tests/components/insteon/test_api_config.py b/tests/components/insteon/test_api_config.py new file mode 100644 index 00000000000..7c922338638 --- /dev/null +++ b/tests/components/insteon/test_api_config.py @@ -0,0 +1,391 @@ +"""Test the Insteon APIs for configuring the integration.""" + +from unittest.mock import patch + +from homeassistant.components.insteon.api.device import ID, TYPE +from homeassistant.components.insteon.const import ( + CONF_HUB_VERSION, + CONF_OVERRIDE, + CONF_X10, +) +from homeassistant.core import HomeAssistant + +from .const import ( + MOCK_DEVICE, + MOCK_HOSTNAME, + MOCK_USER_INPUT_HUB_V1, + MOCK_USER_INPUT_HUB_V2, + MOCK_USER_INPUT_PLM, +) +from .mock_connection import mock_failed_connection, mock_successful_connection +from .mock_setup import async_mock_setup + +from tests.typing import WebSocketGenerator + + +class MockProtocol: + """A mock Insteon protocol object.""" + + connected = True + + +async def test_get_config( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting the Insteon configuration.""" + + ws_client, _, _, _ = await async_mock_setup(hass, hass_ws_client) + await ws_client.send_json({ID: 2, TYPE: "insteon/config/get"}) + msg = await ws_client.receive_json() + result = msg["result"] + + assert result["modem_config"] == {"device": MOCK_DEVICE} + + +async def test_get_modem_schema_plm( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting the Insteon PLM modem configuration schema.""" + + ws_client, _, _, _ = await async_mock_setup(hass, hass_ws_client) + await ws_client.send_json({ID: 2, TYPE: "insteon/config/get_modem_schema"}) + msg = await ws_client.receive_json() + result = msg["result"][0] + + assert result["default"] == MOCK_DEVICE + assert result["name"] == "device" + assert result["required"] + + +async def test_get_modem_schema_hub( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting the Insteon PLM modem configuration schema.""" + + ws_client, devices, _, _ = await async_mock_setup( + hass, + hass_ws_client, + config_data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, + ) + await ws_client.send_json({ID: 2, TYPE: "insteon/config/get_modem_schema"}) + msg = await ws_client.receive_json() + result = msg["result"][0] + + assert result["default"] == MOCK_HOSTNAME + assert result["name"] == "host" + assert result["required"] + + +async def test_update_modem_config_plm( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting the Insteon PLM modem configuration schema.""" + + ws_client, mock_devices, _, _ = await async_mock_setup( + hass, + hass_ws_client, + ) + with ( + patch( + "homeassistant.components.insteon.api.config.async_connect", + new=mock_successful_connection, + ), + patch("homeassistant.components.insteon.api.config.devices", mock_devices), + patch("homeassistant.components.insteon.api.config.async_close"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/config/update_modem_config", + "config": MOCK_USER_INPUT_PLM, + } + ) + msg = await ws_client.receive_json() + result = msg["result"] + + assert result["status"] == "success" + + +async def test_update_modem_config_hub_v2( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting the Insteon HubV2 modem configuration schema.""" + + ws_client, mock_devices, _, _ = await async_mock_setup( + hass, + hass_ws_client, + config_data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, + config_options={"dev_path": "/some/path"}, + ) + with ( + patch( + "homeassistant.components.insteon.api.config.async_connect", + new=mock_successful_connection, + ), + patch("homeassistant.components.insteon.api.config.devices", mock_devices), + patch("homeassistant.components.insteon.api.config.async_close"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/config/update_modem_config", + "config": MOCK_USER_INPUT_HUB_V2, + } + ) + msg = await ws_client.receive_json() + result = msg["result"] + + assert result["status"] == "success" + + +async def test_update_modem_config_hub_v1( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting the Insteon HubV1 modem configuration schema.""" + + ws_client, mock_devices, _, _ = await async_mock_setup( + hass, + hass_ws_client, + config_data={**MOCK_USER_INPUT_HUB_V1, CONF_HUB_VERSION: 1}, + ) + with ( + patch( + "homeassistant.components.insteon.api.config.async_connect", + new=mock_successful_connection, + ), + patch("homeassistant.components.insteon.api.config.devices", mock_devices), + patch("homeassistant.components.insteon.api.config.async_close"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/config/update_modem_config", + "config": MOCK_USER_INPUT_HUB_V1, + } + ) + msg = await ws_client.receive_json() + result = msg["result"] + + assert result["status"] == "success" + + +async def test_update_modem_config_bad( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test updating the Insteon modem configuration with bad connection information.""" + + ws_client, mock_devices, _, _ = await async_mock_setup( + hass, + hass_ws_client, + ) + with ( + patch( + "homeassistant.components.insteon.api.config.async_connect", + new=mock_failed_connection, + ), + patch("homeassistant.components.insteon.api.config.devices", mock_devices), + patch("homeassistant.components.insteon.api.config.async_close"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/config/update_modem_config", + "config": MOCK_USER_INPUT_PLM, + } + ) + msg = await ws_client.receive_json() + result = msg["error"] + assert result["code"] == "connection_failed" + + +async def test_update_modem_config_bad_reconnect( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test updating the Insteon modem configuration with bad connection information so reconnect to old.""" + + ws_client, mock_devices, _, _ = await async_mock_setup( + hass, + hass_ws_client, + ) + with ( + patch( + "homeassistant.components.insteon.api.config.async_connect", + new=mock_failed_connection, + ), + patch("homeassistant.components.insteon.api.config.devices", mock_devices), + patch("homeassistant.components.insteon.api.config.async_close"), + ): + mock_devices.modem.protocol = MockProtocol() + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/config/update_modem_config", + "config": MOCK_USER_INPUT_PLM, + } + ) + msg = await ws_client.receive_json() + result = msg["error"] + assert result["code"] == "connection_failed" + + +async def test_add_device_override( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test adding a device configuration override.""" + + ws_client, _, _, _ = await async_mock_setup(hass, hass_ws_client) + override = { + "address": "99.99.99", + "cat": "0x01", + "subcat": "0x03", + } + await ws_client.send_json( + {ID: 2, TYPE: "insteon/config/device_override/add", "override": override} + ) + msg = await ws_client.receive_json() + assert msg["success"] + + config_entry = hass.config_entries.async_get_entry("abcde12345") + assert len(config_entry.options[CONF_OVERRIDE]) == 1 + assert config_entry.options[CONF_OVERRIDE][0]["address"] == "99.99.99" + + +async def test_add_device_override_duplicate( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test adding a duplicate device configuration override.""" + + override = { + "address": "99.99.99", + "cat": "0x01", + "subcat": "0x03", + } + + ws_client, _, _, _ = await async_mock_setup( + hass, hass_ws_client, config_options={CONF_OVERRIDE: [override]} + ) + await ws_client.send_json( + {ID: 2, TYPE: "insteon/config/device_override/add", "override": override} + ) + msg = await ws_client.receive_json() + assert msg["error"] + + +async def test_remove_device_override( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test removing a device configuration override.""" + + override = { + "address": "99.99.99", + "cat": "0x01", + "subcat": "0x03", + } + overrides = [ + override, + { + "address": "88.88.88", + "cat": "0x02", + "subcat": "0x05", + }, + ] + + ws_client, _, _, _ = await async_mock_setup( + hass, hass_ws_client, config_options={CONF_OVERRIDE: overrides} + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/config/device_override/remove", + "device_address": "99.99.99", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + config_entry = hass.config_entries.async_get_entry("abcde12345") + assert len(config_entry.options[CONF_OVERRIDE]) == 1 + assert config_entry.options[CONF_OVERRIDE][0]["address"] == "88.88.88" + + +async def test_add_device_override_with_x10( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test adding a device configuration override when X10 configuration exists.""" + + x10_device = {"housecode": "a", "unitcode": 1, "platform": "switch"} + ws_client, _, _, _ = await async_mock_setup( + hass, hass_ws_client, config_options={CONF_X10: [x10_device]} + ) + override = { + "address": "99.99.99", + "cat": "0x01", + "subcat": "0x03", + } + await ws_client.send_json( + {ID: 2, TYPE: "insteon/config/device_override/add", "override": override} + ) + msg = await ws_client.receive_json() + assert msg["success"] + + config_entry = hass.config_entries.async_get_entry("abcde12345") + assert len(config_entry.options[CONF_X10]) == 1 + + +async def test_remove_device_override_with_x10( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test removing a device configuration override when X10 configuration exists.""" + + override = { + "address": "99.99.99", + "cat": "0x01", + "subcat": "0x03", + } + overrides = [ + override, + { + "address": "88.88.88", + "cat": "0x02", + "subcat": "0x05", + }, + ] + x10_device = {"housecode": "a", "unitcode": 1, "platform": "switch"} + + ws_client, _, _, _ = await async_mock_setup( + hass, + hass_ws_client, + config_options={CONF_OVERRIDE: overrides, CONF_X10: [x10_device]}, + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/config/device_override/remove", + "device_address": "99.99.99", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + config_entry = hass.config_entries.async_get_entry("abcde12345") + assert len(config_entry.options[CONF_X10]) == 1 + + +async def test_remove_device_override_no_overrides( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test removing a device override when no overrides are configured.""" + + ws_client, _, _, _ = await async_mock_setup(hass, hass_ws_client) + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/config/device_override/remove", + "device_address": "99.99.99", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + config_entry = hass.config_entries.async_get_entry("abcde12345") + assert not config_entry.options.get(CONF_OVERRIDE) diff --git a/tests/components/insteon/test_api_device.py b/tests/components/insteon/test_api_device.py index f3c67d479d0..29d601eb3ef 100644 --- a/tests/components/insteon/test_api_device.py +++ b/tests/components/insteon/test_api_device.py @@ -18,48 +18,29 @@ from homeassistant.components.insteon.api.device import ( TYPE, async_device_name, ) -from homeassistant.components.insteon.const import DOMAIN, MULTIPLE +from homeassistant.components.insteon.const import ( + CONF_OVERRIDE, + CONF_X10, + DOMAIN, + MULTIPLE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from .const import MOCK_USER_INPUT_PLM from .mock_devices import MockDevices +from .mock_setup import async_mock_setup from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator -async def _async_setup(hass, hass_ws_client): - """Set up for tests.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data=MOCK_USER_INPUT_PLM, - options={}, - ) - config_entry.add_to_hass(hass) - async_load_api(hass) - - ws_client = await hass_ws_client(hass) - devices = MockDevices() - await devices.async_load() - - dev_reg = dr.async_get(hass) - # Create device registry entry for mock node - ha_device = dev_reg.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers={(DOMAIN, "11.11.11")}, - name="Device 11.11.11", - ) - return ws_client, devices, ha_device, dev_reg - - -async def test_get_device_api( +async def test_get_config( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test getting an Insteon device.""" - ws_client, devices, ha_device, _ = await _async_setup(hass, hass_ws_client) + ws_client, devices, ha_device, _ = await async_mock_setup(hass, hass_ws_client) with patch.object(insteon.api.device, "devices", devices): await ws_client.send_json( {ID: 2, TYPE: "insteon/device/get", DEVICE_ID: ha_device.id} @@ -76,7 +57,7 @@ async def test_no_ha_device( ) -> None: """Test response when no HA device exists.""" - ws_client, devices, _, _ = await _async_setup(hass, hass_ws_client) + ws_client, devices, _, _ = await async_mock_setup(hass, hass_ws_client) with patch.object(insteon.api.device, "devices", devices): await ws_client.send_json( {ID: 2, TYPE: "insteon/device/get", DEVICE_ID: "not_a_device"} @@ -141,7 +122,7 @@ async def test_get_ha_device_name( ) -> None: """Test getting the HA device name from an Insteon address.""" - _, devices, _, device_reg = await _async_setup(hass, hass_ws_client) + _, devices, _, device_reg = await async_mock_setup(hass, hass_ws_client) with patch.object(insteon.api.device, "devices", devices): # Test a real HA and Insteon device @@ -164,7 +145,7 @@ async def test_add_device_api( ) -> None: """Test adding an Insteon device.""" - ws_client, devices, _, _ = await _async_setup(hass, hass_ws_client) + ws_client, devices, _, _ = await async_mock_setup(hass, hass_ws_client) with patch.object(insteon.api.device, "devices", devices): await ws_client.send_json({ID: 2, TYPE: "insteon/device/add", MULTIPLE: True}) @@ -194,7 +175,7 @@ async def test_cancel_add_device( ) -> None: """Test cancelling adding of a new device.""" - ws_client, devices, _, _ = await _async_setup(hass, hass_ws_client) + ws_client, devices, _, _ = await async_mock_setup(hass, hass_ws_client) with patch.object(insteon.api.aldb, "devices", devices): await ws_client.send_json( @@ -205,3 +186,127 @@ async def test_cancel_add_device( ) msg = await ws_client.receive_json() assert msg["success"] + + +async def test_add_x10_device( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test adding an X10 device.""" + + ws_client, _, _, _ = await async_mock_setup(hass, hass_ws_client) + x10_device = {"housecode": "a", "unitcode": 1, "platform": "switch"} + await ws_client.send_json( + {ID: 2, TYPE: "insteon/device/add_x10", "x10_device": x10_device} + ) + msg = await ws_client.receive_json() + assert msg["success"] + + config_entry = hass.config_entries.async_get_entry("abcde12345") + assert len(config_entry.options[CONF_X10]) == 1 + assert config_entry.options[CONF_X10][0]["housecode"] == "a" + assert config_entry.options[CONF_X10][0]["unitcode"] == 1 + assert config_entry.options[CONF_X10][0]["platform"] == "switch" + + +async def test_add_x10_device_duplicate( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test adding a duplicate X10 device.""" + + x10_device = {"housecode": "a", "unitcode": 1, "platform": "switch"} + + ws_client, _, _, _ = await async_mock_setup( + hass, hass_ws_client, config_options={CONF_X10: [x10_device]} + ) + await ws_client.send_json( + {ID: 2, TYPE: "insteon/device/add_x10", "x10_device": x10_device} + ) + msg = await ws_client.receive_json() + assert msg["error"] + assert msg["error"]["code"] == "duplicate" + + +async def test_remove_device( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test removing an Insteon device.""" + ws_client, _, _, _ = await async_mock_setup(hass, hass_ws_client) + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/device/remove", + "device_address": "11.22.33", + "remove_all_refs": True, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + +async def test_remove_x10_device( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test removing an X10 device.""" + ws_client, _, _, _ = await async_mock_setup(hass, hass_ws_client) + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/device/remove", + "device_address": "X10.A.01", + "remove_all_refs": True, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + +async def test_remove_one_x10_device( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test one X10 device without removing others.""" + x10_device = {"housecode": "a", "unitcode": 1, "platform": "light", "dim_steps": 22} + x10_devices = [ + x10_device, + {"housecode": "a", "unitcode": 2, "platform": "switch"}, + ] + ws_client, _, _, _ = await async_mock_setup( + hass, hass_ws_client, config_options={CONF_X10: x10_devices} + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/device/remove", + "device_address": "X10.A.01", + "remove_all_refs": True, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + config_entry = hass.config_entries.async_get_entry("abcde12345") + assert len(config_entry.options[CONF_X10]) == 1 + assert config_entry.options[CONF_X10][0]["housecode"] == "a" + assert config_entry.options[CONF_X10][0]["unitcode"] == 2 + + +async def test_remove_device_with_overload( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test removing an Insteon device that has a device overload.""" + overload = {"address": "99.99.99", "cat": 1, "subcat": 3} + overloads = {CONF_OVERRIDE: [overload]} + ws_client, _, _, _ = await async_mock_setup( + hass, hass_ws_client, config_options=overloads + ) + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/device/remove", + "device_address": "99.99.99", + "remove_all_refs": True, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + config_entry = hass.config_entries.async_get_entry("abcde12345") + assert not config_entry.options.get(CONF_OVERRIDE) diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index 7cc0eefc0b5..4d3fb815463 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -8,38 +8,14 @@ from voluptuous_serialize import convert from homeassistant import config_entries from homeassistant.components import dhcp, usb from homeassistant.components.insteon.config_flow import ( - STEP_ADD_OVERRIDE, - STEP_ADD_X10, - STEP_CHANGE_HUB_CONFIG, - STEP_CHANGE_PLM_CONFIG, STEP_HUB_V1, STEP_HUB_V2, STEP_PLM, STEP_PLM_MANUALLY, - STEP_REMOVE_OVERRIDE, - STEP_REMOVE_X10, -) -from homeassistant.components.insteon.const import ( - CONF_CAT, - CONF_DIM_STEPS, - CONF_HOUSECODE, - CONF_HUB_VERSION, - CONF_OVERRIDE, - CONF_SUBCAT, - CONF_UNITCODE, - CONF_X10, - DOMAIN, ) +from homeassistant.components.insteon.const import CONF_HUB_VERSION, DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - CONF_ADDRESS, - CONF_DEVICE, - CONF_HOST, - CONF_PASSWORD, - CONF_PLATFORM, - CONF_PORT, - CONF_USERNAME, -) +from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -52,11 +28,8 @@ from .const import ( PATCH_ASYNC_SETUP, PATCH_ASYNC_SETUP_ENTRY, PATCH_CONNECTION, - PATCH_CONNECTION_CLOSE, - PATCH_DEVICES, PATCH_USB_LIST, ) -from .mock_devices import MockDevices from tests.common import MockConfigEntry @@ -294,379 +267,6 @@ async def test_failed_connection_hub(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} -async def _options_init_form(hass, entry_id, step): - """Run the init options form.""" - with patch(PATCH_ASYNC_SETUP_ENTRY, return_value=True): - result = await hass.config_entries.options.async_init(entry_id) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "init" - - return await hass.config_entries.options.async_configure( - result["flow_id"], - {"next_step_id": step}, - ) - - -async def _options_form( - hass, flow_id, user_input, connection=mock_successful_connection -): - """Test an options form.""" - mock_devices = MockDevices(connected=True) - await mock_devices.async_load() - mock_devices.modem = mock_devices["AA.AA.AA"] - with ( - patch(PATCH_CONNECTION, new=connection), - patch(PATCH_ASYNC_SETUP_ENTRY, return_value=True) as mock_setup_entry, - patch(PATCH_DEVICES, mock_devices), - patch(PATCH_CONNECTION_CLOSE), - ): - result = await hass.config_entries.options.async_configure(flow_id, user_input) - return result, mock_setup_entry - - -async def test_options_change_hub_config(hass: HomeAssistant) -> None: - """Test changing Hub v2 config.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={}, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form( - hass, config_entry.entry_id, STEP_CHANGE_HUB_CONFIG - ) - - user_input = { - CONF_HOST: "2.3.4.5", - CONF_PORT: 9999, - CONF_USERNAME: "new username", - CONF_PASSWORD: "new password", - } - result, _ = await _options_form(hass, result["flow_id"], user_input) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == {} - assert config_entry.data == {**user_input, CONF_HUB_VERSION: 2} - - -async def test_options_change_hub_bad_config(hass: HomeAssistant) -> None: - """Test changing Hub v2 with bad config.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={}, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form( - hass, config_entry.entry_id, STEP_CHANGE_HUB_CONFIG - ) - - user_input = { - CONF_HOST: "2.3.4.5", - CONF_PORT: 9999, - CONF_USERNAME: "new username", - CONF_PASSWORD: "new password", - } - result, _ = await _options_form( - hass, result["flow_id"], user_input, mock_failed_connection - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"]["base"] == "cannot_connect" - - -async def test_options_change_plm_config(hass: HomeAssistant) -> None: - """Test changing PLM config.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data=MOCK_USER_INPUT_PLM, - options={}, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form( - hass, config_entry.entry_id, STEP_CHANGE_PLM_CONFIG - ) - - user_input = {CONF_DEVICE: "/dev/ttyUSB0"} - result, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == {} - assert config_entry.data == user_input - - -async def test_options_change_plm_bad_config(hass: HomeAssistant) -> None: - """Test changing PLM config.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data=MOCK_USER_INPUT_PLM, - options={}, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form( - hass, config_entry.entry_id, STEP_CHANGE_PLM_CONFIG - ) - - user_input = {CONF_DEVICE: "/dev/ttyUSB0"} - result, _ = await _options_form( - hass, result["flow_id"], user_input, mock_failed_connection - ) - - assert result["type"] is FlowResultType.FORM - - assert result["type"] is FlowResultType.FORM - assert result["errors"]["base"] == "cannot_connect" - - -async def test_options_add_device_override(hass: HomeAssistant) -> None: - """Test adding a device override.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={}, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form(hass, config_entry.entry_id, STEP_ADD_OVERRIDE) - - user_input = { - CONF_ADDRESS: "1a2b3c", - CONF_CAT: "0x04", - CONF_SUBCAT: "0xaa", - } - result, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert len(config_entry.options[CONF_OVERRIDE]) == 1 - assert config_entry.options[CONF_OVERRIDE][0][CONF_ADDRESS] == "1A.2B.3C" - assert config_entry.options[CONF_OVERRIDE][0][CONF_CAT] == 4 - assert config_entry.options[CONF_OVERRIDE][0][CONF_SUBCAT] == 170 - - result2 = await _options_init_form(hass, config_entry.entry_id, STEP_ADD_OVERRIDE) - - user_input = { - CONF_ADDRESS: "4d5e6f", - CONF_CAT: "05", - CONF_SUBCAT: "bb", - } - result3, _ = await _options_form(hass, result2["flow_id"], user_input) - - assert len(config_entry.options[CONF_OVERRIDE]) == 2 - assert config_entry.options[CONF_OVERRIDE][1][CONF_ADDRESS] == "4D.5E.6F" - assert config_entry.options[CONF_OVERRIDE][1][CONF_CAT] == 5 - assert config_entry.options[CONF_OVERRIDE][1][CONF_SUBCAT] == 187 - - # If result1 eq result2 the changes will not save - assert result["data"] != result3["data"] - - -async def test_options_remove_device_override(hass: HomeAssistant) -> None: - """Test removing a device override.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={ - CONF_OVERRIDE: [ - {CONF_ADDRESS: "1A.2B.3C", CONF_CAT: 6, CONF_SUBCAT: 100}, - {CONF_ADDRESS: "4D.5E.6F", CONF_CAT: 7, CONF_SUBCAT: 200}, - ] - }, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form(hass, config_entry.entry_id, STEP_REMOVE_OVERRIDE) - - user_input = {CONF_ADDRESS: "1A.2B.3C"} - result, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert len(config_entry.options[CONF_OVERRIDE]) == 1 - - -async def test_options_remove_device_override_with_x10(hass: HomeAssistant) -> None: - """Test removing a device override when an X10 device is configured.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={ - CONF_OVERRIDE: [ - {CONF_ADDRESS: "1A.2B.3C", CONF_CAT: 6, CONF_SUBCAT: 100}, - {CONF_ADDRESS: "4D.5E.6F", CONF_CAT: 7, CONF_SUBCAT: 200}, - ], - CONF_X10: [ - { - CONF_HOUSECODE: "d", - CONF_UNITCODE: 5, - CONF_PLATFORM: "light", - CONF_DIM_STEPS: 22, - } - ], - }, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form(hass, config_entry.entry_id, STEP_REMOVE_OVERRIDE) - - user_input = {CONF_ADDRESS: "1A.2B.3C"} - result, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert len(config_entry.options[CONF_OVERRIDE]) == 1 - assert len(config_entry.options[CONF_X10]) == 1 - - -async def test_options_add_x10_device(hass: HomeAssistant) -> None: - """Test adding an X10 device.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={}, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form(hass, config_entry.entry_id, STEP_ADD_X10) - - user_input = { - CONF_HOUSECODE: "c", - CONF_UNITCODE: 12, - CONF_PLATFORM: "light", - CONF_DIM_STEPS: 18, - } - result2, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert len(config_entry.options[CONF_X10]) == 1 - assert config_entry.options[CONF_X10][0][CONF_HOUSECODE] == "c" - assert config_entry.options[CONF_X10][0][CONF_UNITCODE] == 12 - assert config_entry.options[CONF_X10][0][CONF_PLATFORM] == "light" - assert config_entry.options[CONF_X10][0][CONF_DIM_STEPS] == 18 - - result = await _options_init_form(hass, config_entry.entry_id, STEP_ADD_X10) - user_input = { - CONF_HOUSECODE: "d", - CONF_UNITCODE: 10, - CONF_PLATFORM: "binary_sensor", - CONF_DIM_STEPS: 15, - } - result3, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert len(config_entry.options[CONF_X10]) == 2 - assert config_entry.options[CONF_X10][1][CONF_HOUSECODE] == "d" - assert config_entry.options[CONF_X10][1][CONF_UNITCODE] == 10 - assert config_entry.options[CONF_X10][1][CONF_PLATFORM] == "binary_sensor" - assert config_entry.options[CONF_X10][1][CONF_DIM_STEPS] == 15 - - # If result2 eq result3 the changes will not save - assert result2["data"] != result3["data"] - - -async def test_options_remove_x10_device(hass: HomeAssistant) -> None: - """Test removing an X10 device.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={ - CONF_X10: [ - { - CONF_HOUSECODE: "C", - CONF_UNITCODE: 4, - CONF_PLATFORM: "light", - CONF_DIM_STEPS: 18, - }, - { - CONF_HOUSECODE: "D", - CONF_UNITCODE: 10, - CONF_PLATFORM: "binary_sensor", - CONF_DIM_STEPS: 15, - }, - ] - }, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form(hass, config_entry.entry_id, STEP_REMOVE_X10) - - user_input = {CONF_DEVICE: "Housecode: C, Unitcode: 4"} - result, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert len(config_entry.options[CONF_X10]) == 1 - - -async def test_options_remove_x10_device_with_override(hass: HomeAssistant) -> None: - """Test removing an X10 device when a device override is configured.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={ - CONF_X10: [ - { - CONF_HOUSECODE: "C", - CONF_UNITCODE: 4, - CONF_PLATFORM: "light", - CONF_DIM_STEPS: 18, - }, - { - CONF_HOUSECODE: "D", - CONF_UNITCODE: 10, - CONF_PLATFORM: "binary_sensor", - CONF_DIM_STEPS: 15, - }, - ], - CONF_OVERRIDE: [{CONF_ADDRESS: "1A.2B.3C", CONF_CAT: 1, CONF_SUBCAT: 18}], - }, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form(hass, config_entry.entry_id, STEP_REMOVE_X10) - - user_input = {CONF_DEVICE: "Housecode: C, Unitcode: 4"} - result, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert len(config_entry.options[CONF_X10]) == 1 - assert len(config_entry.options[CONF_OVERRIDE]) == 1 - - -async def test_options_override_bad_data(hass: HomeAssistant) -> None: - """Test for bad data in a device override.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={}, - ) - - config_entry.add_to_hass(hass) - result = await _options_init_form(hass, config_entry.entry_id, STEP_ADD_OVERRIDE) - - user_input = { - CONF_ADDRESS: "zzzzzz", - CONF_CAT: "bad", - CONF_SUBCAT: "data", - } - result, _ = await _options_form(hass, result["flow_id"], user_input) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "input_error"} - - async def test_discovery_via_usb(hass: HomeAssistant) -> None: """Test usb flow.""" discovery_info = usb.UsbServiceInfo( diff --git a/tests/components/insteon/test_init.py b/tests/components/insteon/test_init.py index a4e8da03345..c5524ff1919 100644 --- a/tests/components/insteon/test_init.py +++ b/tests/components/insteon/test_init.py @@ -1,6 +1,5 @@ """Test the init file for the Insteon component.""" -import asyncio from unittest.mock import patch import pytest @@ -11,7 +10,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import MOCK_USER_INPUT_PLM, PATCH_CONNECTION +from .const import MOCK_USER_INPUT_PLM from .mock_devices import MockDevices from tests.common import MockConfigEntry @@ -70,22 +69,24 @@ async def test_setup_entry_failed_connection( async def test_import_frontend_dev_url(hass: HomeAssistant) -> None: """Test importing a dev_url config entry.""" - config = {} - config[DOMAIN] = {CONF_DEV_PATH: "/some/path"} + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_USER_INPUT_PLM, options={CONF_DEV_PATH: "/some/path"} + ) + config_entry.add_to_hass(hass) with ( patch.object(insteon, "async_connect", new=mock_successful_connection), - patch.object(insteon, "close_insteon_connection"), + patch.object(insteon, "async_close") as mock_close, patch.object(insteon, "devices", new=MockDevices()), - patch( - PATCH_CONNECTION, - new=mock_successful_connection, - ), ): assert await async_setup_component( hass, insteon.DOMAIN, - config, + {}, ) await hass.async_block_till_done() - await asyncio.sleep(0.01) + assert hass.data[DOMAIN][CONF_DEV_PATH] == "/some/path" + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert insteon.devices.async_save.call_count == 1 + assert mock_close.called diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index 4df733a93fc..1e6d6815921 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -22,14 +22,4 @@ PRAYER_TIMES = { "Midnight": "2020-01-01T00:45:00+00:00", } -NEW_PRAYER_TIMES = { - "Fajr": "2020-01-02T06:00:00+00:00", - "Sunrise": "2020-01-02T07:25:00+00:00", - "Dhuhr": "2020-01-02T12:30:00+00:00", - "Asr": "2020-01-02T15:32:00+00:00", - "Maghrib": "2020-01-02T17:45:00+00:00", - "Isha": "2020-01-02T18:53:00+00:00", - "Midnight": "2020-01-02T00:43:00+00:00", -} - NOW = datetime(2020, 1, 1, 00, 00, 0, tzinfo=dt_util.UTC) diff --git a/tests/components/islamic_prayer_times/test_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py index be8eca210d3..cb37a6b147d 100644 --- a/tests/components/islamic_prayer_times/test_config_flow.py +++ b/tests/components/islamic_prayer_times/test_config_flow.py @@ -1,10 +1,6 @@ """Tests for Islamic Prayer Times config flow.""" -from unittest.mock import patch - -from prayer_times_calculator import InvalidResponseError import pytest -from requests.exceptions import ConnectionError as ConnError from homeassistant import config_entries from homeassistant.components import islamic_prayer_times @@ -33,49 +29,15 @@ async def test_flow_works(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - with patch( - "homeassistant.components.islamic_prayer_times.config_flow.async_validate_location", - return_value={}, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_INPUT - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Home" -@pytest.mark.parametrize( - ("exception", "error"), - [ - (InvalidResponseError, "invalid_location"), - (ConnError, "conn_error"), - ], -) -async def test_flow_error( - hass: HomeAssistant, exception: Exception, error: str -) -> None: - """Test flow errors.""" - result = await hass.config_entries.flow.async_init( - islamic_prayer_times.DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - with patch( - "homeassistant.components.islamic_prayer_times.config_flow.PrayerTimesCalculator.fetch_prayer_times", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_INPUT - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["errors"]["base"] == error - - async def test_options(hass: HomeAssistant) -> None: """Test updating options.""" entry = MockConfigEntry( diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index aa865ee05a4..c5d4933e24a 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -1,24 +1,21 @@ """Tests for Islamic Prayer Times init.""" -from datetime import timedelta from unittest.mock import patch from freezegun import freeze_time -from prayer_times_calculator.exceptions import InvalidResponseError import pytest from homeassistant.components import islamic_prayer_times from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, STATE_UNAVAILABLE +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -import homeassistant.util.dt as dt_util -from . import NEW_PRAYER_TIMES, NOW, PRAYER_TIMES +from . import NOW, PRAYER_TIMES -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry @pytest.fixture(autouse=True) @@ -37,7 +34,7 @@ async def test_successful_config_entry(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", return_value=PRAYER_TIMES, ): await hass.config_entries.async_setup(entry.entry_id) @@ -46,25 +43,6 @@ async def test_successful_config_entry(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.LOADED -async def test_setup_failed(hass: HomeAssistant) -> None: - """Test Islamic Prayer Times failed due to an error.""" - - entry = MockConfigEntry( - domain=islamic_prayer_times.DOMAIN, - data={}, - ) - entry.add_to_hass(hass) - - # test request error raising ConfigEntryNotReady - with patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", - side_effect=InvalidResponseError(), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_RETRY - - async def test_unload_entry(hass: HomeAssistant) -> None: """Test removing Islamic Prayer Times.""" entry = MockConfigEntry( @@ -74,7 +52,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", return_value=PRAYER_TIMES, ): await hass.config_entries.async_setup(entry.entry_id) @@ -91,7 +69,7 @@ async def test_options_listener(hass: HomeAssistant) -> None: with ( patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", return_value=PRAYER_TIMES, ) as mock_fetch_prayer_times, freeze_time(NOW), @@ -107,49 +85,6 @@ async def test_options_listener(hass: HomeAssistant) -> None: assert mock_fetch_prayer_times.call_count == 2 -async def test_update_failed(hass: HomeAssistant) -> None: - """Test integrations tries to update after 1 min if update fails.""" - entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) - entry.add_to_hass(hass) - - with ( - patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", - return_value=PRAYER_TIMES, - ), - freeze_time(NOW), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.LOADED - - with patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times" - ) as FetchPrayerTimes: - FetchPrayerTimes.side_effect = [ - InvalidResponseError, - NEW_PRAYER_TIMES, - ] - midnight_time = dt_util.parse_datetime(PRAYER_TIMES["Midnight"]) - assert midnight_time - future = midnight_time + timedelta(days=1, minutes=1) - with freeze_time(future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("sensor.islamic_prayer_times_fajr_prayer") - assert state.state == STATE_UNAVAILABLE - - # coordinator tries to update after 1 minute - future = future + timedelta(minutes=1) - with freeze_time(future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - state = hass.states.get("sensor.islamic_prayer_times_fajr_prayer") - assert state.state == "2020-01-02T06:00:00+00:00" - - @pytest.mark.parametrize( ("object_id", "old_unique_id"), [ @@ -184,7 +119,7 @@ async def test_migrate_unique_id( with ( patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", return_value=PRAYER_TIMES, ), freeze_time(NOW), @@ -207,7 +142,7 @@ async def test_migration_from_1_1_to_1_2(hass: HomeAssistant) -> None: with ( patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", return_value=PRAYER_TIMES, ), freeze_time(NOW), diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py index 22629819e05..1f8d28dfb6f 100644 --- a/tests/components/islamic_prayer_times/test_sensor.py +++ b/tests/components/islamic_prayer_times/test_sensor.py @@ -40,7 +40,7 @@ async def test_islamic_prayer_times_sensors( with ( patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", return_value=PRAYER_TIMES, ), freeze_time(NOW), diff --git a/tests/components/lg_netcast/__init__.py b/tests/components/lg_netcast/__init__.py new file mode 100644 index 00000000000..ce3e09aeb65 --- /dev/null +++ b/tests/components/lg_netcast/__init__.py @@ -0,0 +1,116 @@ +"""Tests for LG Netcast TV.""" + +from unittest.mock import patch +from xml.etree import ElementTree + +from pylgnetcast import AccessTokenError, LgNetCastClient, SessionIdError +import requests + +from homeassistant.components.lg_netcast import DOMAIN +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_HOST, + CONF_ID, + CONF_MODEL, + CONF_NAME, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +FAIL_TO_BIND_IP = "1.2.3.4" + +IP_ADDRESS = "192.168.1.239" +DEVICE_TYPE = "TV" +MODEL_NAME = "MockLGModelName" +FRIENDLY_NAME = "LG Smart TV" +UNIQUE_ID = "1234" +ENTITY_ID = f"{MP_DOMAIN}.{MODEL_NAME.lower()}" + +FAKE_SESSION_ID = "987654321" +FAKE_PIN = "123456" + + +def _patched_lgnetcast_client( + *args, + session_error=False, + fail_connection: bool = True, + invalid_details: bool = False, + always_404: bool = False, + no_unique_id: bool = False, + **kwargs, +): + client = LgNetCastClient(*args, **kwargs) + + def _get_fake_session_id(): + if not client.access_token: + raise AccessTokenError("Fake Access Token Requested") + if session_error: + raise SessionIdError("Can not get session id from TV.") + return FAKE_SESSION_ID + + def _get_fake_query_device_info(): + if fail_connection: + raise requests.exceptions.ConnectTimeout("Mocked Failed Connection") + if always_404: + return None + if invalid_details: + raise ElementTree.ParseError("Mocked Parsed Error") + return { + "uuid": UNIQUE_ID if not no_unique_id else None, + "model_name": MODEL_NAME, + "friendly_name": FRIENDLY_NAME, + } + + client._get_session_id = _get_fake_session_id + client.query_device_info = _get_fake_query_device_info + + return client + + +def _patch_lg_netcast( + *, + session_error: bool = False, + fail_connection: bool = False, + invalid_details: bool = False, + always_404: bool = False, + no_unique_id: bool = False, +): + def _generate_fake_lgnetcast_client(*args, **kwargs): + return _patched_lgnetcast_client( + *args, + session_error=session_error, + fail_connection=fail_connection, + invalid_details=invalid_details, + always_404=always_404, + no_unique_id=no_unique_id, + **kwargs, + ) + + return patch( + "homeassistant.components.lg_netcast.config_flow.LgNetCastClient", + new=_generate_fake_lgnetcast_client, + ) + + +async def setup_lgnetcast(hass: HomeAssistant, unique_id: str = UNIQUE_ID): + """Initialize lg netcast and media_player for tests.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: IP_ADDRESS, + CONF_ACCESS_TOKEN: FAKE_PIN, + CONF_NAME: MODEL_NAME, + CONF_MODEL: MODEL_NAME, + CONF_ID: unique_id, + }, + title=MODEL_NAME, + unique_id=unique_id, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/lg_netcast/conftest.py b/tests/components/lg_netcast/conftest.py new file mode 100644 index 00000000000..4faee2c6f06 --- /dev/null +++ b/tests/components/lg_netcast/conftest.py @@ -0,0 +1,11 @@ +"""Common fixtures and objects for the LG Netcast integration tests.""" + +import pytest + +from tests.common import async_mock_service + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") diff --git a/tests/components/lg_netcast/test_config_flow.py b/tests/components/lg_netcast/test_config_flow.py new file mode 100644 index 00000000000..c159b8fb9d2 --- /dev/null +++ b/tests/components/lg_netcast/test_config_flow.py @@ -0,0 +1,252 @@ +"""Define tests for the LG Netcast config flow.""" + +from datetime import timedelta +from unittest.mock import DEFAULT, patch + +from homeassistant import data_entry_flow +from homeassistant.components.lg_netcast.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_HOST, + CONF_ID, + CONF_MODEL, + CONF_NAME, +) +from homeassistant.core import HomeAssistant + +from . import ( + FAKE_PIN, + FRIENDLY_NAME, + IP_ADDRESS, + MODEL_NAME, + UNIQUE_ID, + _patch_lg_netcast, +) + +from tests.common import MockConfigEntry + + +async def test_show_form(hass: HomeAssistant) -> None: + """Test that the form is served with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_user_invalid_host(hass: HomeAssistant) -> None: + """Test that errors are shown when the host is invalid.""" + with _patch_lg_netcast(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "invalid/host"} + ) + + assert result["errors"] == {CONF_HOST: "invalid_host"} + + +async def test_manual_host(hass: HomeAssistant) -> None: + """Test manual host configuration.""" + with _patch_lg_netcast(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "authorize" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["step_id"] == "authorize" + assert result2["errors"] is not None + assert result2["errors"][CONF_ACCESS_TOKEN] == "invalid_access_token" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: FAKE_PIN} + ) + + assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result3["title"] == FRIENDLY_NAME + assert result3["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ACCESS_TOKEN: FAKE_PIN, + CONF_NAME: FRIENDLY_NAME, + CONF_MODEL: MODEL_NAME, + CONF_ID: UNIQUE_ID, + } + + +async def test_manual_host_no_connection_during_authorize(hass: HomeAssistant) -> None: + """Test manual host configuration.""" + with _patch_lg_netcast(fail_connection=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_manual_host_invalid_details_during_authorize( + hass: HomeAssistant, +) -> None: + """Test manual host configuration.""" + with _patch_lg_netcast(invalid_details=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_manual_host_unsuccessful_details_response(hass: HomeAssistant) -> None: + """Test manual host configuration.""" + with _patch_lg_netcast(always_404=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_manual_host_no_unique_id_response(hass: HomeAssistant) -> None: + """Test manual host configuration.""" + with _patch_lg_netcast(no_unique_id=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "invalid_host" + + +async def test_invalid_session_id(hass: HomeAssistant) -> None: + """Test Invalid Session ID.""" + with _patch_lg_netcast(session_error=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "authorize" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: FAKE_PIN} + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["step_id"] == "authorize" + assert result2["errors"] is not None + assert result2["errors"]["base"] == "cannot_connect" + + +async def test_import(hass: HomeAssistant) -> None: + """Test that the import works.""" + with _patch_lg_netcast(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_ACCESS_TOKEN: FAKE_PIN, + CONF_NAME: MODEL_NAME, + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == UNIQUE_ID + assert result["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ACCESS_TOKEN: FAKE_PIN, + CONF_NAME: MODEL_NAME, + CONF_MODEL: MODEL_NAME, + CONF_ID: UNIQUE_ID, + } + + +async def test_import_not_online(hass: HomeAssistant) -> None: + """Test that the import works.""" + with _patch_lg_netcast(fail_connection=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_ACCESS_TOKEN: FAKE_PIN, + CONF_NAME: MODEL_NAME, + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_duplicate_error(hass): + """Test that errors are shown when duplicates are added during import.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + data={ + CONF_HOST: IP_ADDRESS, + CONF_ACCESS_TOKEN: FAKE_PIN, + CONF_NAME: MODEL_NAME, + CONF_ID: UNIQUE_ID, + }, + ) + config_entry.add_to_hass(hass) + + with _patch_lg_netcast(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_ACCESS_TOKEN: FAKE_PIN, + CONF_NAME: MODEL_NAME, + CONF_ID: UNIQUE_ID, + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_display_access_token_aborted(hass: HomeAssistant): + """Test Access token display is cancelled.""" + + def _async_track_time_interval( + hass: HomeAssistant, + action, + interval: timedelta, + *, + name=None, + cancel_on_shutdown=None, + ): + hass.async_create_task(action()) + return DEFAULT + + with ( + _patch_lg_netcast(session_error=True), + patch( + "homeassistant.components.lg_netcast.config_flow.async_track_time_interval" + ) as mock_interval, + ): + mock_interval.side_effect = _async_track_time_interval + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: IP_ADDRESS} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "authorize" + assert not result["errors"] + + assert mock_interval.called + + hass.config_entries.flow.async_abort(result["flow_id"]) + assert mock_interval.return_value.called diff --git a/tests/components/lg_netcast/test_device_trigger.py b/tests/components/lg_netcast/test_device_trigger.py new file mode 100644 index 00000000000..05911acc41d --- /dev/null +++ b/tests/components/lg_netcast/test_device_trigger.py @@ -0,0 +1,148 @@ +"""The tests for LG NEtcast device triggers.""" + +import pytest + +from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.components.lg_netcast import DOMAIN, device_trigger +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from . import ENTITY_ID, UNIQUE_ID, setup_lgnetcast + +from tests.common import MockConfigEntry, async_get_device_automations + + +async def test_get_triggers( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test we get the expected triggers.""" + await setup_lgnetcast(hass) + + device = device_registry.async_get_device(identifiers={(DOMAIN, UNIQUE_ID)}) + assert device is not None + + turn_on_trigger = { + "platform": "device", + "domain": DOMAIN, + "type": "lg_netcast.turn_on", + "device_id": device.id, + "metadata": {}, + } + + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + assert turn_on_trigger in triggers + + +async def test_if_fires_on_turn_on_request( + hass: HomeAssistant, calls: list[ServiceCall], device_registry: dr.DeviceRegistry +) -> None: + """Test for turn_on triggers firing.""" + await setup_lgnetcast(hass) + + device = device_registry.async_get_device(identifiers={(DOMAIN, UNIQUE_ID)}) + assert device is not None + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "lg_netcast.turn_on", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.device_id }}", + "id": "{{ trigger.id }}", + }, + }, + }, + { + "trigger": { + "platform": "lg_netcast.turn_on", + "entity_id": ENTITY_ID, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": ENTITY_ID}, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[0].data["some"] == device.id + assert calls[0].data["id"] == 0 + assert calls[1].data["some"] == ENTITY_ID + assert calls[1].data["id"] == 0 + + +async def test_failure_scenarios( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test failure scenarios.""" + await setup_lgnetcast(hass) + + # Test wrong trigger platform type + with pytest.raises(HomeAssistantError): + await device_trigger.async_attach_trigger( + hass, {"type": "wrong.type", "device_id": "invalid_device_id"}, None, {} + ) + + # Test invalid device id + with pytest.raises(HomeAssistantError): + await device_trigger.async_validate_trigger_config( + hass, + { + "platform": "device", + "domain": DOMAIN, + "type": "lg_netcast.turn_on", + "device_id": "invalid_device_id", + }, + ) + + entry = MockConfigEntry(domain="fake", state=ConfigEntryState.LOADED, data={}) + entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, identifiers={("fake", "fake")} + ) + + config = { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "lg_netcast.turn_on", + } + + # Test that device id from non lg_netcast domain raises exception + with pytest.raises(InvalidDeviceAutomationConfig): + await device_trigger.async_validate_trigger_config(hass, config) + + # Test that only valid triggers are attached diff --git a/tests/components/lg_netcast/test_trigger.py b/tests/components/lg_netcast/test_trigger.py new file mode 100644 index 00000000000..e75dac501c3 --- /dev/null +++ b/tests/components/lg_netcast/test_trigger.py @@ -0,0 +1,189 @@ +"""The tests for LG Netcast device triggers.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components import automation +from homeassistant.components.lg_netcast import DOMAIN +from homeassistant.const import SERVICE_RELOAD +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from . import ENTITY_ID, UNIQUE_ID, setup_lgnetcast + +from tests.common import MockEntity, MockEntityPlatform + + +async def test_lg_netcast_turn_on_trigger_device_id( + hass: HomeAssistant, calls: list[ServiceCall], device_registry: dr.DeviceRegistry +) -> None: + """Test for turn_on trigger by device_id firing.""" + await setup_lgnetcast(hass) + + device = device_registry.async_get_device(identifiers={(DOMAIN, UNIQUE_ID)}) + assert device, repr(device_registry.devices) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "lg_netcast.turn_on", + "device_id": device.id, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": device.id, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + 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 + + with patch("homeassistant.config.load_yaml_dict", return_value={}): + await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, 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) == 0 + + +async def test_lg_netcast_turn_on_trigger_entity_id(hass: HomeAssistant, calls): + """Test for turn_on triggers by entity firing.""" + await setup_lgnetcast(hass) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "lg_netcast.turn_on", + "entity_id": ENTITY_ID, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + 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"] == ENTITY_ID + assert calls[0].data["id"] == 0 + + +async def test_wrong_trigger_platform_type( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test wrong trigger platform type.""" + await setup_lgnetcast(hass) + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "lg_netcast.wrong_type", + "entity_id": ENTITY_ID, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + }, + ], + }, + ) + + assert ( + "ValueError: Unknown LG Netcast TV trigger platform lg_netcast.wrong_type" + in caplog.text + ) + + +async def test_trigger_invalid_entity_id( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test turn on trigger using invalid entity_id.""" + await setup_lgnetcast(hass) + + platform = MockEntityPlatform(hass) + + invalid_entity = f"{DOMAIN}.invalid" + await platform.async_add_entities([MockEntity(name=invalid_entity)]) + + await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "lg_netcast.turn_on", + "entity_id": invalid_entity, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ENTITY_ID, + "id": "{{ trigger.id }}", + }, + }, + } + ], + }, + ) + + assert ( + f"ValueError: Entity {invalid_entity} is not a valid lg_netcast entity" + in caplog.text + ) diff --git a/tests/components/media_extractor/__init__.py b/tests/components/media_extractor/__init__.py index 7aac726501b..79130f1ea4b 100644 --- a/tests/components/media_extractor/__init__.py +++ b/tests/components/media_extractor/__init__.py @@ -36,9 +36,13 @@ class MockYoutubeDL: """Initialize mock object for YoutubeDL.""" self.params = params - def extract_info(self, url: str, *, process: bool = False) -> dict[str, Any]: + def extract_info( + self, url: str, *, download: bool = True, process: bool = False + ) -> dict[str, Any]: """Return info.""" self._fixture = _get_base_fixture(url) + if not download: + return load_json_object_fixture(f"media_extractor/{self._fixture}.json") return load_json_object_fixture(f"media_extractor/{self._fixture}_info.json") def process_ie_result( diff --git a/tests/components/media_extractor/fixtures/no_formats.json b/tests/components/media_extractor/fixtures/no_formats.json new file mode 100644 index 00000000000..aefb1525738 --- /dev/null +++ b/tests/components/media_extractor/fixtures/no_formats.json @@ -0,0 +1,87 @@ +{ + "id": "223644256", + "uploader": "BRUTTOBAND", + "uploader_id": "111488150", + "uploader_url": "https://soundcloud.com/bruttoband", + "timestamp": 1442140228, + "title": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "description": "", + "thumbnails": [ + { + "id": "mini", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-mini.jpg", + "width": 16, + "height": 16 + }, + { + "id": "tiny", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-tiny.jpg", + "width": 20, + "height": 20 + }, + { + "id": "small", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-small.jpg", + "width": 32, + "height": 32 + }, + { + "id": "badge", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-badge.jpg", + "width": 47, + "height": 47 + }, + { + "id": "t67x67", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t67x67.jpg", + "width": 67, + "height": 67 + }, + { + "id": "large", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-large.jpg", + "width": 100, + "height": 100 + }, + { + "id": "t300x300", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t300x300.jpg", + "width": 300, + "height": 300 + }, + { + "id": "crop", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-crop.jpg", + "width": 400, + "height": 400 + }, + { + "id": "t500x500", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t500x500.jpg", + "width": 500, + "height": 500 + }, + { + "id": "original", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "preference": 10 + } + ], + "duration": 229.089, + "webpage_url": "https://soundcloud.com/bruttoband/brutto-11", + "license": "all-rights-reserved", + "view_count": 291779, + "like_count": 3347, + "comment_count": 14, + "repost_count": 59, + "genre": "Brutto", + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "original_url": "https://soundcloud.com/bruttoband/brutto-11", + "webpage_url_basename": "brutto-11", + "webpage_url_domain": "soundcloud.com", + "extractor": "soundcloud", + "extractor_key": "Soundcloud", + "heatmap": [], + "automatic_captions": {} +} diff --git a/tests/components/media_extractor/fixtures/soundcloud.json b/tests/components/media_extractor/fixtures/soundcloud.json new file mode 100644 index 00000000000..ee430e43982 --- /dev/null +++ b/tests/components/media_extractor/fixtures/soundcloud.json @@ -0,0 +1,114 @@ +{ + "id": "223644255", + "uploader": "BRUTTOBAND", + "uploader_id": "111488150", + "uploader_url": "https://soundcloud.com/bruttoband", + "timestamp": 1442140228, + "title": "BRUTTO - \u0420\u043e\u0434\u043d\u044b \u043a\u0440\u0430\u0439", + "description": "", + "thumbnails": [ + { + "id": "mini", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-mini.jpg", + "width": 16, + "height": 16 + }, + { + "id": "tiny", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-tiny.jpg", + "width": 20, + "height": 20 + }, + { + "id": "small", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-small.jpg", + "width": 32, + "height": 32 + }, + { + "id": "badge", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-badge.jpg", + "width": 47, + "height": 47 + }, + { + "id": "t67x67", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t67x67.jpg", + "width": 67, + "height": 67 + }, + { + "id": "large", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-large.jpg", + "width": 100, + "height": 100 + }, + { + "id": "t300x300", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t300x300.jpg", + "width": 300, + "height": 300 + }, + { + "id": "crop", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-crop.jpg", + "width": 400, + "height": 400 + }, + { + "id": "t500x500", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-t500x500.jpg", + "width": 500, + "height": 500 + }, + { + "id": "original", + "url": "https://i1.sndcdn.com/artworks-000129385232-ysbisn-original.jpg", + "preference": 10 + } + ], + "duration": 229.089, + "webpage_url": "https://soundcloud.com/bruttoband/brutto-11", + "license": "all-rights-reserved", + "view_count": 291779, + "like_count": 3347, + "comment_count": 14, + "repost_count": 59, + "genre": "Brutto", + "formats": [ + { + "url": "https://cf-hls-media.sndcdn.com/playlist/50remGX1OqRY.128.mp3/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1tZWRpYS5zbmRjZG4uY29tL3BsYXlsaXN0LzUwcmVtR1gxT3FSWS4xMjgubXAzL3BsYXlsaXN0Lm0zdTgqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk1NTAzOTYzfX19XX0_&Signature=eNeIoSTgRZL89YBJYXpmRg0AVGk3M0gV4E4rYPYbFw6pTePHO4o8Mv6HwdK85FOMsaUHZvYgzc35uWPhAr1SUqqjnm--xwN8VUrDkCPgdv97Vrs9qJ9QElHKnlWhK2-BDs3Y7sDcAurA00L2uReB-vjI-4K65WBApYBTaUGnOACimoVAOWHmtigO0Ap5DxlEh7fqqwi88enEvVDE-98v5uX9FcV9lq9AfVwEtfqbPsjVJyh6WbWAB3PJDJElvV13RgKmzVvbFluLElYlDud9WMsHjztdWhdaRzGOj1AfcQcwkQbQlBRiAKMtqrRlzAAXnBfLvMF3DOvdYWeCwJeCXA__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "hls_mp3_128", + "protocol": "m3u8_native", + "preference": null, + "vcodec": "none" + }, + { + "url": "https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk1NTAzOTYzfX19XX0_&Signature=JAG~zJ~2NOWOgiuHLCSYWwdjUVuYWR2fBvmxPGSnLzMgX2xqu5~WfOk-gOyRUbHhnKnybUbP70cr6~t~Qx0KEU5mwIy2H0YhOXDHFX5RJVQlj1iCVuko-hAFJc7RtZuKTP5oCWOM-R2a6HfYN88YAIqgwWbGvTKin1CAgHaICeoM2p5O50n-kp05KgCw3RKcRutkYT-RVcWkmXtY4D4Jtw~LuBERDNyErseTHzmruDCkaYkVNlTcaIdgygQjgxVlgZiIRj-p0vRNO0qv5Bc0LfNMBzYm9fTAr86c~TzxyvQRhwHOPYp-DCXcs1K6i9x4BVvHWLOSHr0Dhd3X4fe5kw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "mp3", + "abr": 128, + "format_id": "http_mp3_128", + "protocol": "http", + "preference": null, + "vcodec": "none" + }, + { + "url": "https://cf-hls-opus-media.sndcdn.com/playlist/50remGX1OqRY.64.opus/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1vcHVzLW1lZGlhLnNuZGNkbi5jb20vcGxheWxpc3QvNTByZW1HWDFPcVJZLjY0Lm9wdXMvcGxheWxpc3QubTN1OCoiLCJDb25kaXRpb24iOnsiRGF0ZUxlc3NUaGFuIjp7IkFXUzpFcG9jaFRpbWUiOjE2OTU1MDM5NjR9fX1dfQ__&Signature=HqyeKoK4gx1Bd3jId5v1-9ltDY2SN7fzGp6M7tW3kWluv8Ns0SgANPKG3~Tzo8hUrQzAYVvwMbO2F75b6NBeBr4xx0SRCeKotZdArzBFT4Wtrz4HtEddLyPjp12vWYCDuOshd1sTjrvaBAd9TAFTZuwyal1OKpLMsqK0QN-KFH-5GsmLDZTPWcoVkMqC7XBmNWTq0G1mtVeP57TN~9T7qEYqRLFIDXdm2HKSPSR4BB2gjaZUK22pBUPSGVr-ziBSpNVHvNISy2QdObjS5zjuAe8bl-npQ3PlFatYECDI3Gc~wjCNIJPdTpCbHUsX36SSR4dnKlgW1nYGx~eED7dppA__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ", + "ext": "opus", + "abr": 64, + "format_id": "hls_opus_64", + "protocol": "m3u8_native", + "preference": null, + "vcodec": "none" + } + ], + "original_url": "https://soundcloud.com/bruttoband/brutto-11", + "webpage_url_basename": "brutto-11", + "webpage_url_domain": "soundcloud.com", + "extractor": "soundcloud", + "extractor_key": "Soundcloud", + "heatmap": [], + "automatic_captions": {} +} diff --git a/tests/components/media_extractor/fixtures/youtube_1.json b/tests/components/media_extractor/fixtures/youtube_1.json new file mode 100644 index 00000000000..e1283274f63 --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_1.json @@ -0,0 +1,1430 @@ +{ + "id": "dQw4w9WgXcQ", + "title": "Rick Astley - Never Gonna Give You Up (Official Music Video)", + "formats": [ + { + "asr": null, + "filesize": 80166145, + "format_id": "137", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 3024.566, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=137&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=80166145&dur=212.040&lmt=1694045208995966&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAJvqkzrOZjbqQLPANU-Q0Ti57XZCS5MLEZMrme2Vqqj2AiEAoU5oDVbWI-82LxhSDuTtTvpgKEspgfrw7aPzQ8Di40w%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.640028", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 40874930, + "format_id": "248", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 1542.159, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=248&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=40874930&dur=212.040&lmt=1694044655610179&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgM6ztX8BqXVEkyq4FTukRfb0mlWfDdll8wN_8iZvFoDMCIQDvRawrloLUqZWDjgf2ZZKkQPPX2NZQm5mUcIHjX04bWA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 16780212, + "format_id": "136", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 633.096, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=136&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=16780212&dur=212.040&lmt=1694045071129751&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgG69VUvtxkC7_DuWzobsIDSBoAq9K8NfzCDI1BRkqC4ICIQD-G-4SOmZuQKmSkka0p8USe-GX_RzmuxsNPZj89r-9WA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401F", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 15359727, + "format_id": "247", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 579.502, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=247&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=15359727&dur=212.040&lmt=1694043486219683&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgclGPS3eSxjSMzpc-gOTA8Vsr4yfK6UCVyG5LUot-jMkCIQCdkrhr5s2GjZH5i8d_WciMXSN6kjqG9A6BCMzxqpeuRw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.31.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 8683274, + "format_id": "135", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 327.608, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=135&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=8683274&dur=212.040&lmt=1694045045723793&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhANvnqVNT-trAFyACiWh8EllyhTzAuStHpLlDrTan7LxXAiEAy_Yajm6EEJUwcAVnEBRukcxc5-CB8UTY5BjB9oR1TeM%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 10927666, + "format_id": "244", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 412.286, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=244&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=10927666&dur=212.040&lmt=1694043369037289&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAKBxnJIQLH7cWZFfZuMs5yhQ66jdt35KMdmqi5nmGIgnAiAzZ28nc8BNIKhKlhKBr5w6gWmvz-vm8E-PnNWigmhwgA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.30.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 5678772, + "format_id": "134", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 214.252, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=134&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=5678772&dur=212.040&lmt=1694045013473544&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAOHb4lBYlXkxFj1ZMXhNDcw1CQPWiB2c6Y6vOTGevdqJAiEAt644Dv84Eqzc6yfe1GG3sDMwYeLRUKA_KYHbSeJeKIo%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 6902164, + "format_id": "243", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 260.409, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=243&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=6902164&dur=212.040&lmt=1694043349554753&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAM3xj4hJ22Ur3eTOxA0LselI9THQg1Qb2gryxihUmPFLAiEAuQYROAwdEs6XdFszg8SRgCgojRUr1y9VS3096aQXnjc%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 3019976, + "format_id": "133", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 113.939, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=133&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=3019976&dur=212.040&lmt=1694045014258984&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgWmsCuyZEqz2NashfdLp92iJqaqRtA8bYJJhohjGFxzgCIQD3aQZ90zKGHu-JXiJZMViWuCb0UeZ-MesxOGi_gMWHxA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D4015", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 4021173, + "format_id": "242", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 151.713, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=242&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=4021173&dur=212.040&lmt=1694043379783601&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgAVwANVM5s1vtgkPKId_2b9bw2d_Lhbvkvm2J2OJM-fUCIQDwcC5FLMxOF3g6nZq1vpf0d7dyKnp0plE1Niy3rZ6Cdg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 1863601, + "format_id": "160", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 70.311, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=160&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=1863601&dur=212.040&lmt=1694045032286738&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAL_zAjcV7CL3hke-z49D6nQ7k5dCTVweXQdj4_cVHIc2AiB9bkIVgy7GYGFUGo36PYjnlN_8KNnyxiNhh0M76Fjjgw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 2404581, + "format_id": "278", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 90.721, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=278&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=2404581&dur=212.040&lmt=1694043382822868&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAOetHAZolpx87k21SDKePQP6gZHC3CWiQ_DtEQd1bDRvAiEA8GGA-2C5lIFuucuPqdnS4FZiGdKYgWUTlJ-9yQEnSR0%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 22050, + "filesize": 1294944, + "format_id": "139", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 48.823, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=139&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=1294944&dur=212.183&lmt=1694042119353699&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAMKE8c3kivZrqSOCOcLzUCa1erqAaOj6K7SWFAcCJyCXAiBOFkaL_lvsXhZeLwyOUP98LBTGxUHEurO_IWZOeRCkAQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 44100, + "filesize": 3433514, + "format_id": "140", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.51, + "url": "https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZZamLdK-6dsP1vWlkAY&ip=45.93.75.130&id=o-AE88cX3U8S0nqUyaTcInhPRdfxWwxMu3bolA1C1aEj2f&itag=140&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-25ge7nz6&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=1311250&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=3433514&dur=212.091&lmt=1694042124987733&mt=1695502401&fvip=1&keepalive=yes&fexp=24007246&c=IOS&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAKw2jXdoZhn8sjUu-1MSxfV1p0TzRyqjuooRwoQohtOwAiANfeuxDTlTpi_f9scAC0n01xOejhRLD0m-6pl3oo7wIQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAODiH5vGneSVUexV9_V6nZzlqakYIjA64qDFHDB-fYgKAiEA0gnzEhZwqeUUXjDukB4wIJXS3evZuLV7Dtp5cpZZXUA%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 22050, + "filesize": 2086732, + "format_id": "17", + "format_note": "144p", + "source_preference": -1, + "fps": 6, + "audio_channels": 1, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 78.693, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=17&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2F3gpp&gir=yes&clen=2086732&dur=212.137&lmt=1694042486266781&mt=1695502401&fvip=3&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAIOMPqxbtJwYRIrAYmr0I9rEovBipWNTTg9AMju1ehECAiEA7vjnz-TCwh2zQQm4vmXW0nGpft4nX42Ql_hwHHCA-Yk%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 176, + "language": "en", + "language_preference": -1, + "preference": -2, + "ext": "3gp", + "vcodec": "mp4v.20.3", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 44100, + "filesize": null, + "format_id": "18", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": 2, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 343.32, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=18&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045104514388&mt=1695502401&fvip=3&fexp=24007246&beids=24350018&c=ANDROID&txp=4538434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgFhTg7TavCE2HBWS9I3agqj3CG2RqrvxLJt6JgHtN4O4CIF-IDHhEPLlkGP2QtwJ19sSumUVPqVElVXjrM-qqYIae&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 640, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.42001E", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 44100, + "filesize": null, + "format_id": "22", + "format_note": "720p", + "source_preference": -5, + "fps": 25, + "audio_channels": 2, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 762.182, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1695502401&fvip=3&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgHVwajP0J2fiJ1ERoAonpxghXGzDmEXh3rvJ399UEMWECIFdBjiVUOk7QdiFBxQ4QqojJd8p_PfL25TV_8TBrp_Kb&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 1280, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.64001F", + "acodec": "mp4a.40.2", + "dynamic_range": null, + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 31265835, + "format_id": "399", + "format_note": "1080p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 1080, + "quality": 9.0, + "has_drm": false, + "tbr": 1179.62, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=399&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=31265835&dur=212.040&lmt=1694042163788395&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAMbD0sObTPrf1j0GESI-SRztzhMi98xn1XBMfFsnMjLFAiEAnMCImljVChi4G_wjA9UE2EN9xQHJ7LhuEO9HeNlR334%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 1920, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.08M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 17466721, + "format_id": "398", + "format_note": "720p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 720, + "quality": 8.0, + "has_drm": false, + "tbr": 658.997, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=398&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=17466721&dur=212.040&lmt=1694042319819525&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgfJ1sWUmxG82ls65giBWKTwsH7UP4ItT0soOPZSEtKg4CIQC_GFYhkfiktXrWOoKWW2j50GkQX7dE7mfWzvjh-XnIXA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 1280, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.05M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 9434981, + "format_id": "397", + "format_note": "480p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 480, + "quality": 7.0, + "has_drm": false, + "tbr": 355.969, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=397&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=9434981&dur=212.040&lmt=1694042458043976&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgFFbe3pmUWAFqJCycmW7hGbeJuC8dfEax2p6v9J-y9GQCIB-2d1ss2yBL3yhesngue7dM5AsJqLNOMLnCD51o-1zW&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 854, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.04M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 5438397, + "format_id": "396", + "format_note": "360p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 360, + "quality": 6.0, + "has_drm": false, + "tbr": 205.183, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=396&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=5438397&dur=212.040&lmt=1694042190822892&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgNr6BZ03kG8p2KTAv7gOZ01pEcCWwnkWvjOdxmGn1X4ACICrqnbbGqLvm0jpEqXYOXMISHcPt7vQVzwohM84tfeYb&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 640, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.01M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 3024455, + "format_id": "395", + "format_note": "240p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 240, + "quality": 5.0, + "has_drm": false, + "tbr": 114.108, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=395&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=3024455&dur=212.040&lmt=1694042297309821&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAM7Up-h9A3SMwIYMo6V5t4oM7BpkjnEIcO_s7BTR1hfzAiA72-vEcn4y21NtpQkzpTZI_BdjCeCUez43ohuzw4MJsA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 426, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 1416915, + "format_id": "394", + "format_note": "144p", + "source_preference": -1, + "fps": 25, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 53.458, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=394&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=1416915&dur=212.040&lmt=1694042192787352&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4537434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgLYhy9j4feWSuTyTnJr2MF4xiEpALLDeez2_BwF__Qq8CIQDBTg9R-8YOcUtA4-R-Gu8A7o_66wGf69Vky62ZE-T0Zw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "av01.0.00M.08", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 847252, + "format_id": "597", + "format_note": "144p", + "source_preference": -1, + "fps": 13, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 31.959, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=597&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&gir=yes&clen=847252&dur=212.080&lmt=1694042194934376&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgH6Tk52MhzNtfm-6d1XHdQfIh12aqbEohhH-ffBZP9z0CIQDJwPFA7eTz6LdcZaBlfnlogft7pgtrXHvm6DIHWCODUg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "mp4", + "vcodec": "avc1.4d400b", + "acodec": "none", + "dynamic_range": null, + "container": "mp4_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": null, + "filesize": 643306, + "format_id": "598", + "format_note": "144p", + "source_preference": -1, + "fps": 13, + "audio_channels": null, + "height": 144, + "quality": 0.0, + "has_drm": false, + "tbr": 24.266, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=598&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=643306&dur=212.080&lmt=1694042224218554&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgHwZWhUJuuFMMAva_Katkrgk3FGNcBlHCwBVwV1jGz4ACIQCerrScqjke9mtPVPwYZraaCp4u7VkFz1hIzx-Fl_7HzQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": 256, + "language": null, + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "vp9", + "acodec": "none", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 48000, + "filesize": 1232413, + "format_id": "249", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 46.492, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=249&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=1232413&dur=212.061&lmt=1694040798737498&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgD3BhwkfqYjk1FEud7AdjzD9RJImYWaebeN9Ip7HuX3ICIQDCzT7tMYmDyb_fz4TB4GPwroXqO55NV5h7Ao-IoPq0Kw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 48000, + "filesize": 1630086, + "format_id": "250", + "format_note": "low", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 2.0, + "has_drm": false, + "tbr": 61.494, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=250&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=1630086&dur=212.061&lmt=1694040798724510&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgJ1nALE0kFCC4pg2mj0LpB_ZwivihtQo6ugYw-AzKJAsCIQD5q8PFpJtloWUmuK2A80NC7c2hr_9OUldFCXOCLyPN3Q%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 48000, + "filesize": 3437753, + "format_id": "251", + "format_note": "medium", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 3.0, + "has_drm": false, + "tbr": 129.689, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=251&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=3437753&dur=212.061&lmt=1694040798752663&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAPSOofxlLab49bVcmvVP8wmIHVWvqDyOd11oJdP1RPFfAiAOCKp1VodP12z6FqdWxp_2xYcS2J949BbgFWqlfGkHjA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 22050, + "filesize": 817805, + "format_id": "599", + "format_note": "ultralow", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 1.0, + "has_drm": false, + "tbr": 30.833, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=599&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=audio%2Fmp4&gir=yes&clen=817805&dur=212.183&lmt=1694040788792847&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAKEpb371lM9F_wlPf3i5D6zreL34as0UmzOJxw5TXqlrAiEAjNU124xhGmkotlkRSCWdF15IBB-frezyqNQY1AV-l4M%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "m4a", + "vcodec": "none", + "acodec": "mp4a.40.5", + "dynamic_range": null, + "container": "m4a_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "asr": 48000, + "filesize": 832823, + "format_id": "600", + "format_note": "ultralow", + "source_preference": -1, + "fps": null, + "audio_channels": 2, + "height": null, + "quality": 1.0, + "has_drm": false, + "tbr": 31.418, + "url": "https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=600&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=audio%2Fwebm&gir=yes&clen=832823&dur=212.061&lmt=1694040798740210&mt=1695502401&fvip=3&keepalive=yes&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgRqdkURprOblK7GurvCvDxSunECdm96nzQwzwHuDvUKcCIQCmeMMeDP816FMH0GgpugoQhe4z4X6nDYoY3PtQhShtWA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D", + "width": null, + "language": "en", + "language_preference": -1, + "preference": null, + "ext": "webm", + "vcodec": "none", + "acodec": "opus", + "dynamic_range": null, + "container": "webm_dash", + "downloader_options": { + "http_chunk_size": 10485760 + } + }, + { + "format_id": "233", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/233/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D1294944%3Bdur%3D212.183%3Bgir%3Dyes%3Bitag%3D139%3Blmt%3D1694042119353699/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRgIhANECzBL2bCCpseaXL3qapc_gEQkpP-2eTqyPCspG44PXAiEA4J6i9mS_2vJB4TtIwHjg7-f-KCyiYSs-kL5dcEkToRg%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgUS67qZ67NkB4J8491hAMcCKs400sACGWUhNcrXWefx4CIB64Ny0g5mTEFnTryntu2vexyLjfXtNHmvEAYgosB_9w/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "language": "en", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1 + }, + { + "format_id": "234", + "format_note": "Default", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/234/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/goi/133/sgoap/clen%3D3433514%3Bdur%3D212.091%3Bgir%3Dyes%3Bitag%3D140%3Blmt%3D1694042124987733/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,goi,sgoap,vprv,playlist_type/sig/AOq0QJ8wRgIhAM_Q8sGDeFsxPWBEUPdlx6Mul7XMV1uCmH_5jdrqR06cAiEAqpNga8OlFdW_uqNwYxIL70Ki64lw0hKT700Z8dZPTtA%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgM8VqbzvtcXd5PhUv9cffclj4q86NQYiKEJw9CuwxlbYCIQDntLRgkHqq_HrUvzSiwWLph8lvrnAgSps0aAilpdDKfg%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "language": "en", + "ext": "mp4", + "protocol": "m3u8_native", + "preference": null, + "quality": -1, + "has_drm": false, + "vcodec": "none", + "source_preference": -1 + }, + { + "format_id": "229", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/229/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D3019976%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D133%3Blmt%3D1694045014258984/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgDXYLvO8hsKnxOFI4QK0KR6_bH3y963ahqlBfJqHHOBgCIQDlYY2nTWarn59nFcVOD35IVk5obSssFUeidm3u4n6FBw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAKnlpIqSLjxTjoejuijzzpuB-Di1I2eiDdzTDDWNboffAiBbdWGc04XUqoe4imJg9kVp3fWTDOFXFVnhAXfqp3Qp6w%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 225.675, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 426, + "height": 240, + "vcodec": "avc1.4D4015", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "230", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/230/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D5678772%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D134%3Blmt%3D1694045013473544/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAIQvJojwVvM5frF6Na5JJ5zkd1VdUfPLdqPW5Tht2eNkAiEA3437jLFLon8Rpbsp5krc66qddvGP4rj8sbwJd4rlHmU%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAIL_XGGyZVNILzOXnviLoYEMYJfAngPM1eBZGgN6wEr-AiAInhTuU6hkWCkpZGr9dPeXYSfa3brLjZNivRNbpJB8QA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 478.155, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 640, + "height": 360, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "231", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/231/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D8683274%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D135%3Blmt%3D1694045045723793/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgdgnN5tIO26lbTUty02k4r8k9-oaX_7m4LsLXMRBE7n8CIEb2DJRXZ0dGH_ZDbtYAapQKCiCxqht8Bznh0LmS83cw/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgfNxyHiNcbQeIRKSwnBRTymUYFDqdhNZqEdRJ5-dl_uICIQD6XyM3ReaIZkg4DsK6ys3VjdMroKJUBHPx4pZGYbqJ0Q%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 660.067, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 7, + "has_drm": false, + "width": 854, + "height": 480, + "vcodec": "avc1.4D401E", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "232", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/232/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D16780212%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D136%3Blmt%3D1694045071129751/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRgIhAKklfVW5UAGwbMKG5dyOkjEW9RqlFeZXuS9RKazS7hgHAiEAluSFc2bFqy_0nb32n7mR-SOR0gCmAdFwl35gbDldf3w%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAOqcypFYB4AyrOahc7gihR0-jqv-Gzc8JHdRtQEn3r9wAiEA2cqI-R7Cjr-UaDu-B9miweYpBXWzDeC8PoxK_0bkm5c%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 1130.986, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 8, + "has_drm": false, + "width": 1280, + "height": 720, + "vcodec": "avc1.4D401F", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "269", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/269/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D1863601%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D160%3Blmt%3D1694045032286738/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAOI4zFdpx6CuAJUVfQO8mSPKS-WskVy5lco9PRAL-TfDAiABtG5PX_rqg5Vr77L9IKeZgKU4Mbt-YLWmvxQos9prAg%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAJA2-IQoPA4HYhx5ehZh9_b91jlS-QLvYO8xOp8HXN2uAiAUYQXpYWShcC4WGSKU0_MMNwdKqeQgdYPtqIXTVTKZuA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 156.229, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "avc1.4D400C", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "270", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/270/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgovp/clen%3D80166145%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D137%3Blmt%3D1694045208995966/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgbzf0MRvdmSh-0Na1BR8xckB4DoMcL2nNJl3vDRew0AICIANhvJC9Q1hAqDjjLubtM7DoNaY-PtJpVlbfaL81F3l8/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIhAOya7lZyTdnNmoyCacVPgOcsyLDSKevmW3xFt_afVsWfAiB-hASkkk9GrfTuT-6adP2aXYrMXkiB-Y8nuX-wrWUmSg%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 4901.412, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "avc1.640028", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "602", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/602/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D643306%3Bdur%3D212.080%3Bgir%3Dyes%3Bitag%3D598%3Blmt%3D1694042224218554/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAJ5BHwzV7tAkb9pRDEsdmzFTJOrsuq-IZSmBaa8ZgWU4AiBoqSh-knrE3feDNHwFm_0fAM_qNFn3xvV98kmX_-pYPA%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAIQe5bcuBu9He-VtMYGRHkjZuDoUvmnuIbyjxf6sncbKAiEA9iegdULdUppfIh2N3Lz4Kt0PwtdV-c5G1gRDaO-U7t0%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 80.559, + "ext": "mp4", + "fps": 13.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "vp09.00.10.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "603", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/603/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D2404581%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D278%3Blmt%3D1694043382822868/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRAIgW4kL5-Bqq0OV_FyB5Df0QcqkyUTYid2eN4BUzn8sp98CIFqLxBBSz7H3PaXJ4NycNae2P0--5ri0HHMItBr8PKIP/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRgIhAJ6-KuK2swgLKLyCSnGkgsoVy2VR9SuNpx6Crrz9mc9GAiEAqbUS5dWqCZkA7oSKAONrBYKbsjgiXwT1EV6Uxj8ToOU%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 153.593, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 0, + "has_drm": false, + "width": 256, + "height": 144, + "vcodec": "vp09.00.11.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "604", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/604/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D4021173%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D242%3Blmt%3D1694043379783601/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAIW_PHJodC3iY33S6s7ju5X9_6oByqQFda5vPWR8jwrgAiB_csQrznhta4iTLmj6Xzybwgfe5CRA6TFV1KbQ21QJZw%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgHGms27SWPkQSOt2slvmBWboDwV_BrqW_RoRlpdqD5rACIQCVpBzzlQxE44nHEJ4hoYD2QUvIm732saxlZ2fLjfljJQ%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 287.523, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 5, + "has_drm": false, + "width": 426, + "height": 240, + "vcodec": "vp09.00.20.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "605", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/605/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D6902164%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D243%3Blmt%3D1694043349554753/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIgJd3aB7R2t5a0XNGTqDIuYSimhFpK2hEvDD1-itRftKkCIQCqe4F0OhI5PSp0tSYEXlngrmJgfTGIuVZUMH8saPZlnQ%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgQveRyDrh7DOwnJgI7dzbB3XLvqrPvKwutQI7ZjCtIs0CIHRxPzpMlfC9QmQMTu2SIGs7QP8bP1Nn65JxYGRecFCt/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 566.25, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 6, + "has_drm": false, + "width": 640, + "height": 360, + "vcodec": "vp09.00.21.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "606", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/606/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D10927666%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D244%3Blmt%3D1694043369037289/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAIIZhHj_PxMIRxj2AvOoouUWwZPnPs3-autC7-_Qu1dnAiAtQJp9ZV1TVQXd02g1viHWghB6tKSD5_jcRHzLPHIAeA%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgXJh3M-PAlfv0g5H_brRmCBl1Z0w0b5y9mqIdEsZSp-0CIQD4j4piciikRuQI3KX-HFizmq-dPxMc-aqVBFYw43-NRA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 733.359, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 7, + "has_drm": false, + "width": 854, + "height": 480, + "vcodec": "vp09.00.30.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "609", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/609/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D15359727%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D247%3Blmt%3D1694043486219683/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhANJRh709Wuh23a9Wj2UUKFE9qjHRMscBHd3fQjuNjK5zAiB4gh40D5HmwOx3JuqptUi44o5EtkdzK0IQEunFmwOPiA%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgRcLcLuy1kw90oGfrplalJdXQ4t9tjEQNH-bp7lGNsCICIQDLlCQYGjyHjnkZONzlaYidWV7-_stKKzzkhz3xEsOP4w%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 1179.472, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 8, + "has_drm": false, + "width": 1280, + "height": 720, + "vcodec": "vp09.00.31.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "614", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/614/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D40874930%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D248%3Blmt%3D1694044655610179/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4535434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIga8rGnkxIK7d-77rdKN1yHtRP9NyUJGXfRyVba5rKVRoCIQD7mJ1LOowgdfuJQuXTvarIbd54VwB6hM5O05zpPdFJDQ%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRAIgGwgwIro32WyMRnb_Ccp6z_iH1YZLIwF2D8nnhQOoyJwCIGvOkZvz50XkJPrLReF_rHyHcsgE9PM_hcpudysB6YN9/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 2831.123, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": -1 + }, + { + "format_id": "616", + "format_index": null, + "url": "https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/itag/616/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/wft/1/sgovp/clen%3D99471214%3Bdur%3D212.040%3Bgir%3Dyes%3Bitag%3D356%3Blmt%3D1694043438471036/hls_chunk_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31,26/mn/sn-5hne6nzy,sn-25ge7nz6/ms/au,onr/mv/m/mvi/3/pl/22/initcwndbps/1311250/vprv/1/playlist_type/DVR/dover/13/txp/4532434/mt/1695502401/fvip/1/short_key/1/keepalive/yes/fexp/24007246/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,wft,sgovp,vprv,playlist_type/sig/AOq0QJ8wRQIhAPTG-DKQd4Rtv1dvvExqPNGfPU_wBRbsGSIYRqJ3UCDEAiBBYBgPR_gAJAiCr2eHvR3hu6uWUEUCvEN5pr5Dm2_5gA%3D%3D/lsparams/hls_chunk_host,mh,mm,mn,ms,mv,mvi,pl,initcwndbps/lsig/AG3C_xAwRQIgIzhyfPERJMLLzgSld4XG3lYTJKhsmpOrVD2v_siZfEgCIQCPOKf2Or4aqJhe--d_2Qh_ljI39BS6JH7x6BPXC7f_NA%3D%3D/playlist/index.m3u8", + "manifest_url": "https://manifest.googlevideo.com/api/manifest/hls_variant/expire/1695524274/ei/UlEPZZamLdK-6dsP1vWlkAY/ip/45.93.75.130/id/750c38c3d5a05dc4/source/youtube/requiressl/yes/playback_host/rr3---sn-5hne6nzy.googlevideo.com/mh/7c/mm/31%2C26/mn/sn-5hne6nzy%2Csn-25ge7nz6/ms/au%2Conr/mv/m/mvi/3/pl/22/hfr/1/demuxed/1/tts_caps/1/maudio/1/initcwndbps/1311250/vprv/1/go/1/mt/1695502401/fvip/1/nvgoi/1/short_key/1/ncsapi/1/keepalive/yes/fexp/24007246/dover/13/itag/0/playlist_type/DVR/sparams/expire%2Cei%2Cip%2Cid%2Csource%2Crequiressl%2Chfr%2Cdemuxed%2Ctts_caps%2Cmaudio%2Cvprv%2Cgo%2Citag%2Cplaylist_type/sig/AOq0QJ8wRAIgVqAL59tazORSvoQGDDOnSykYFq5uZpRuPHMC9uFJi-8CIB35p6PNovYkmOKgp4hgRrNprxu-TvLYlHdP4MsxNJgt/lsparams/playback_host%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps/lsig/AG3C_xAwRQIgYBcJvvjykFc2vD1MGR4R95tcg4uoXutvWjGr5CDO7XoCIQDKUqdUFDFde44FsyWMfj2qVOJp_GcX1UGoyQ847aMpVg%3D%3D/file/index.m3u8", + "tbr": 5704.254, + "ext": "mp4", + "fps": 25.0, + "protocol": "m3u8_native", + "preference": null, + "quality": 9, + "has_drm": false, + "width": 1920, + "height": 1080, + "vcodec": "vp09.00.40.08", + "acodec": "none", + "dynamic_range": null, + "source_preference": 99, + "format_note": "Premium" + }, + { + "format_id": "sb0", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M$M.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "width": 160, + "height": 90, + "fps": 0.5094339622641509, + "rows": 5, + "columns": 5, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M0.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M1.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M2.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M3.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 49.07407407407407 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L2/M4.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCHYrXWHttYu2902drAXv1Wg3kN4g", + "duration": 15.703703703703724 + } + ] + }, + { + "format_id": "sb1", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M$M.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "width": 80, + "height": 45, + "fps": 0.5094339622641509, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M0.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "duration": 196.29629629629628 + }, + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L1/M1.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCTXAc1RH5-ZIGm3FlRiYRUkzQXug", + "duration": 15.703703703703724 + } + ] + }, + { + "format_id": "sb2", + "format_note": "storyboard", + "ext": "mhtml", + "protocol": "mhtml", + "acodec": "none", + "vcodec": "none", + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCSw4ypjBBVyVfNU-jl-4aLZArqkA", + "width": 48, + "height": 27, + "fps": 0.4716981132075472, + "rows": 10, + "columns": 10, + "fragments": [ + { + "url": "https://i.ytimg.com/sb/dQw4w9WgXcQ/storyboard3_L0/default.jpg?sqp=-oaymwGbA0g48quKqQOSA4gBAZUBAAAEQpgBMqABPKgBBLABELABDbABDLABELABFbABH7ABJrABLbABDrABDrABD7ABErABF7ABK7ABLLABKbABD7ABDrABELABFbABH7ABKrABMrABKbABD7ABEbABFLABGLABJrABPbABOLABLbABEbABFLABHrABKrABMbABS7ABR7ABNrABFbABHLABKbABLrABObABR7ABTbABP7ABJbABLrABN7ABPbABR7ABUrABUbABRbABM7ABQLABQrABQ7ABTLABRLABRrABQ7gBEbgBEbgBFbgBI7gBRLgBQ7gBQ7gBQ7gBEbgBE7gBFrgBL7gBQ7gBQ7gBQ7gBQ7gBFbgBFrgBKbgBQ7gBQ7gBQ7gBQ7gBQ7gBI7gBL7gBQ7gBQ7gBQ7gBQ7gBQ7gBQ7gBRLgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQ7gBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQ7gBQ7gBQ7gBQrgBQrgBQrgBQrgBQqLzl_8DBgjuj9umBg==&sigh=rs$AOn4CLCSw4ypjBBVyVfNU-jl-4aLZArqkA", + "duration": 212.0 + } + ] + } + ], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mqdefault.jpg", + "height": 180, + "width": 320, + "preference": -11 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg", + "height": 360, + "width": 480, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sddefault.jpg", + "height": 480, + "width": 640, + "preference": -5 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/default.webp", + "height": 90, + "width": 120, + "preference": -12 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mqdefault.webp", + "height": 180, + "width": 320, + "preference": -10 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hqdefault.webp", + "height": 360, + "width": 480, + "preference": -6 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sddefault.webp", + "height": 480, + "width": 640, + "preference": -4 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLDd2KtelLHaNSXrI9_5K-NvTscKNw", + "height": 94, + "width": 168, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLBUpEOOWUXWkNyijQuZ4UPzp2BE-w", + "height": 110, + "width": 196, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBCyhr8AqpJ1SxKVU6SyK5ODJ_IpA", + "height": 138, + "width": 246, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLB_p0PncTtkrhaNDZtntrE3gKkoYw", + "height": 188, + "width": 336, + "preference": -7 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp", + "height": 1080, + "width": 1920, + "preference": 0 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg", + "height": 720, + "width": 1280, + "preference": -1 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq720.webp", + "preference": -2 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq720.jpg", + "preference": -3 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/0.webp", + "preference": -8 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/0.jpg", + "preference": -9 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/default.jpg", + "preference": -13 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd1.webp", + "preference": -14 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd1.jpg", + "preference": -15 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd2.webp", + "preference": -16 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd2.jpg", + "preference": -17 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sd3.webp", + "preference": -18 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/sd3.jpg", + "preference": -19 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq1.webp", + "preference": -20 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq1.jpg", + "preference": -21 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq2.webp", + "preference": -22 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq2.jpg", + "preference": -23 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/hq3.webp", + "preference": -24 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hq3.jpg", + "preference": -25 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq1.webp", + "preference": -26 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq1.jpg", + "preference": -27 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq2.webp", + "preference": -28 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq2.jpg", + "preference": -29 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/mq3.webp", + "preference": -30 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/mq3.jpg", + "preference": -31 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/1.webp", + "preference": -32 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/1.jpg", + "preference": -33 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/2.webp", + "preference": -34 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/2.jpg", + "preference": -35 + }, + { + "url": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/3.webp", + "preference": -36 + }, + { + "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/3.jpg", + "preference": -37 + } + ], + "thumbnail": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/maxresdefault.webp", + "description": "The official video for \u201cNever Gonna Give You Up\u201d by Rick Astley\n\n\u2018Hold Me In Your Arms\u2019 \u2013 deluxe blue vinyl, 2CD and digital deluxe out 12th May 2023 Pre-order here \u2013 https://rick-astley.lnk.to/HMIYA2023ID\n\n\u201cNever Gonna Give You Up\u201d was a global smash on its release in July 1987, topping the charts in 25 countries including Rick\u2019s native UK and the US Billboard Hot 100. It also won the Brit Award for Best single in 1988. Stock Aitken and Waterman wrote and produced the track which was the lead-off single and lead track from Rick\u2019s debut LP \u201cWhenever You Need Somebody\u201d. The album was itself a UK number one and would go on to sell over 15 million copies worldwide.\n\nThe legendary video was directed by Simon West \u2013 who later went on to make Hollywood blockbusters such as Con Air, Lara Croft \u2013 Tomb Raider and The Expendables 2. The video passed the 1bn YouTube views milestone on 28 July 2021.\n\nSubscribe to the official Rick Astley YouTube channel: https://RickAstley.lnk.to/YTSubID\n\nFollow Rick Astley:\nFacebook: https://RickAstley.lnk.to/FBFollowID \nTwitter: https://RickAstley.lnk.to/TwitterID \nInstagram: https://RickAstley.lnk.to/InstagramID \nWebsite: https://RickAstley.lnk.to/storeID \nTikTok: https://RickAstley.lnk.to/TikTokID\n\nListen to Rick Astley:\nSpotify: https://RickAstley.lnk.to/SpotifyID \nApple Music: https://RickAstley.lnk.to/AppleMusicID \nAmazon Music: https://RickAstley.lnk.to/AmazonMusicID \nDeezer: https://RickAstley.lnk.to/DeezerID \n\nLyrics:\nWe\u2019re no strangers to love\nYou know the rules and so do I\nA full commitment\u2019s what I\u2019m thinking of\nYou wouldn\u2019t get this from any other guy\n\nI just wanna tell you how I\u2019m feeling\nGotta make you understand\n\nNever gonna give you up\nNever gonna let you down\nNever gonna run around and desert you\nNever gonna make you cry\nNever gonna say goodbye\nNever gonna tell a lie and hurt you\n\nWe\u2019ve known each other for so long\nYour heart\u2019s been aching but you\u2019re too shy to say it\nInside we both know what\u2019s been going on\nWe know the game and we\u2019re gonna play it\n\nAnd if you ask me how I\u2019m feeling\nDon\u2019t tell me you\u2019re too blind to see\n\nNever gonna give you up\nNever gonna let you down\nNever gonna run around and desert you\nNever gonna make you cry\nNever gonna say goodbye\nNever gonna tell a lie and hurt you\n\n#RickAstley #NeverGonnaGiveYouUp #WheneverYouNeedSomebody #OfficialMusicVideo", + "channel_id": "UCuAXFkgsw1L7xaCfnd5JJOw", + "channel_url": "https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw", + "duration": 212, + "view_count": 1450567564, + "average_rating": null, + "age_limit": 0, + "webpage_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "categories": ["Music"], + "tags": [ + "rick astley", + "Never Gonna Give You Up", + "nggyu", + "never gonna give you up lyrics", + "rick rolled", + "Rick Roll", + "rick astley official", + "rickrolled", + "Fortnite song", + "Fortnite event", + "Fortnite dance", + "fortnite never gonna give you up", + "rick roll", + "rickrolling", + "rick rolling", + "never gonna give you up", + "80s music", + "rick astley new", + "animated video", + "rickroll", + "meme songs", + "never gonna give u up lyrics", + "Rick Astley 2022", + "never gonna let you down", + "animated", + "rick rolls 2022", + "never gonna give you up karaoke" + ], + "playable_in_embed": true, + "live_status": "not_live", + "release_timestamp": null, + "_format_sort_fields": [ + "quality", + "res", + "fps", + "hdr:12", + "source", + "vcodec:vp9.2", + "channels", + "acodec", + "lang", + "proto" + ], + "automatic_captions": {}, + "subtitles": {}, + "comment_count": 2300000, + "chapters": null, + "heatmap": [], + "like_count": 16869622, + "channel": "Rick Astley", + "channel_follower_count": 3890000, + "channel_is_verified": true, + "uploader": "Rick Astley", + "uploader_id": "@RickAstleyYT", + "uploader_url": "https://www.youtube.com/@RickAstleyYT", + "upload_date": "20091025", + "availability": "public", + "__post_extractor": null, + "original_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "webpage_url_basename": "watch", + "webpage_url_domain": "youtube.com", + "extractor": "youtube", + "extractor_key": "Youtube" +} diff --git a/tests/components/media_extractor/fixtures/youtube_empty_playlist.json b/tests/components/media_extractor/fixtures/youtube_empty_playlist.json new file mode 100644 index 00000000000..37f22693528 --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_empty_playlist.json @@ -0,0 +1,49 @@ +{ + "id": "PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJO", + "title": "Very important videos", + "availability": "public", + "channel_follower_count": null, + "description": "Not original", + "tags": [], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEwCKgBEF5IWvKriqkDIwgBFQAAiEIYAfABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLDBCH5IQ0obogxXhAzIH8pE0d7r1Q", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEwCMQBEG5IWvKriqkDIwgBFQAAiEIYAfABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLAybhgn-CoPMjBE-0VfBDqvy0jyOQ", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwExCPYBEIoBSFryq4qpAyMIARUAAIhCGAHwAQH4Ad4DgALoAooCDAgAEAEYPCBlKD8wDw==&rs=AOn4CLDutIdjr5zTE9G78eWf83-mGXYnUA", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwExCNACELwBSFryq4qpAyMIARUAAIhCGAHwAQH4Ad4DgALoAooCDAgAEAEYPCBlKD8wDw==&rs=AOn4CLD2884fHuvAv8ysHA48LD3uArB6bA", + "height": 188, + "width": 336 + } + ], + "modified_date": "20230813", + "view_count": 5770834, + "playlist_count": 3, + "channel": "ZulTarx", + "channel_id": "UChOLuQpsxxmJiJUeSU2tSTw", + "uploader_id": "@Armand314", + "uploader": "ZulTarx", + "channel_url": "https://www.youtube.com/channel/UChOLuQpsxxmJiJUeSU2tSTw", + "uploader_url": "https://www.youtube.com/@Armand314", + "_type": "playlist", + "entries": [], + "extractor_key": "YoutubeTab", + "extractor": "youtube:tab", + "webpage_url": "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJO", + "original_url": "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJO", + "webpage_url_basename": "playlist", + "webpage_url_domain": "youtube.com", + "heatmap": [], + "automatic_captions": {} +} diff --git a/tests/components/media_extractor/fixtures/youtube_playlist.json b/tests/components/media_extractor/fixtures/youtube_playlist.json new file mode 100644 index 00000000000..053b243a1be --- /dev/null +++ b/tests/components/media_extractor/fixtures/youtube_playlist.json @@ -0,0 +1,179 @@ +{ + "id": "PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJP", + "title": "Very important videos", + "availability": "public", + "channel_follower_count": null, + "description": "Not original", + "tags": [], + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEwCKgBEF5IWvKriqkDIwgBFQAAiEIYAfABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLDBCH5IQ0obogxXhAzIH8pE0d7r1Q", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwEwCMQBEG5IWvKriqkDIwgBFQAAiEIYAfABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLAybhgn-CoPMjBE-0VfBDqvy0jyOQ", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwExCPYBEIoBSFryq4qpAyMIARUAAIhCGAHwAQH4Ad4DgALoAooCDAgAEAEYPCBlKD8wDw==&rs=AOn4CLDutIdjr5zTE9G78eWf83-mGXYnUA", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwExCNACELwBSFryq4qpAyMIARUAAIhCGAHwAQH4Ad4DgALoAooCDAgAEAEYPCBlKD8wDw==&rs=AOn4CLD2884fHuvAv8ysHA48LD3uArB6bA", + "height": 188, + "width": 336 + } + ], + "modified_date": "20230813", + "view_count": 5770834, + "playlist_count": 3, + "channel": "ZulTarx", + "channel_id": "UChOLuQpsxxmJiJUeSU2tSTw", + "uploader_id": "@Armand314", + "uploader": "ZulTarx", + "channel_url": "https://www.youtube.com/channel/UChOLuQpsxxmJiJUeSU2tSTw", + "uploader_url": "https://www.youtube.com/@Armand314", + "_type": "playlist", + "entries": [ + { + "_type": "url", + "ie_key": "Youtube", + "id": "q6EoRBvdVPQ", + "url": "https://www.youtube.com/watch?v=q6EoRBvdVPQ", + "title": "Yee", + "description": null, + "duration": 10, + "channel_id": "UC-fD_qwTEQQ1L-MUWx_mNvg", + "channel": "revergo", + "channel_url": "https://www.youtube.com/channel/UC-fD_qwTEQQ1L-MUWx_mNvg", + "uploader": "revergo", + "uploader_id": "@revergo", + "uploader_url": "https://www.youtube.com/@revergo", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLAJYg16HMBdEsv9lYBJyNqA5G3anQ", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE1CMQBEG5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AHeA4AC6AKKAgwIABABGDwgZSg_MA8=&rs=AOn4CLAgCNP9UuQas-D59hHHM-RqkUvA6g", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE2CPYBEIoBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLCTWaY5897XxhcpRyVtGQQNuMHfTg", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/q6EoRBvdVPQ/hqdefault.jpg?sqp=-oaymwE2CNACELwBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgB3gOAAugCigIMCAAQARg8IGUoPzAP&rs=AOn4CLCeS6NC75yTYvyP4DsehZ3oXNuxMQ", + "height": 188, + "width": 336 + } + ], + "timestamp": null, + "release_timestamp": null, + "availability": null, + "view_count": 96000000, + "live_status": null, + "channel_is_verified": null + }, + { + "_type": "url", + "ie_key": "Youtube", + "id": "8YWl7tDGUPA", + "url": "https://www.youtube.com/watch?v=8YWl7tDGUPA", + "title": "color red", + "description": null, + "duration": 17, + "channel_id": "UCbYMTn6xKV0IKshL4pRCV3g", + "channel": "Alex Jimenez", + "channel_url": "https://www.youtube.com/channel/UCbYMTn6xKV0IKshL4pRCV3g", + "uploader": "Alex Jimenez", + "uploader_id": "@alexjimenez1237", + "uploader_url": "https://www.youtube.com/@alexjimenez1237", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/8YWl7tDGUPA/hqdefault.jpg?sqp=-oaymwE1CKgBEF5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AG2BIACwAKKAgwIABABGGUgXShUMA8=&rs=AOn4CLBqzngIx-4i_HFvqloetUfeN8yrYw", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/8YWl7tDGUPA/hqdefault.jpg?sqp=-oaymwE1CMQBEG5IVfKriqkDKAgBFQAAiEIYAXABwAEG8AEB-AG2BIACwAKKAgwIABABGGUgXShUMA8=&rs=AOn4CLB7mWPQmdL2QBLxTHhrgbFj2jFaCg", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/8YWl7tDGUPA/hqdefault.jpg?sqp=-oaymwE2CPYBEIoBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgBtgSAAsACigIMCAAQARhlIF0oVDAP&rs=AOn4CLA9YAIO3g_DnClsuc5LjMQn4O9ZQQ", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/8YWl7tDGUPA/hqdefault.jpg?sqp=-oaymwE2CNACELwBSFXyq4qpAygIARUAAIhCGAFwAcABBvABAfgBtgSAAsACigIMCAAQARhlIF0oVDAP&rs=AOn4CLDPHY6aG08hlTJMlc-LJt9ywtpWEg", + "height": 188, + "width": 336 + } + ], + "timestamp": null, + "release_timestamp": null, + "availability": null, + "view_count": 30000000, + "live_status": null, + "channel_is_verified": null + }, + { + "_type": "url", + "ie_key": "Youtube", + "id": "6bnanI9jXps", + "url": "https://www.youtube.com/watch?v=6bnanI9jXps", + "title": "Terrible Mall Commercial", + "description": null, + "duration": 31, + "channel_id": "UCLmnB20wsih9F5N0o5K0tig", + "channel": "quantim", + "channel_url": "https://www.youtube.com/channel/UCLmnB20wsih9F5N0o5K0tig", + "uploader": "quantim", + "uploader_id": "@Potatoflesh", + "uploader_url": "https://www.youtube.com/@Potatoflesh", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/6bnanI9jXps/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAsyI0ZJA9STG8vlSdRkKk55ls5Dg", + "height": 94, + "width": 168 + }, + { + "url": "https://i.ytimg.com/vi/6bnanI9jXps/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLD2bZ9S8AB4UGsZlx_8TjBoL72enA", + "height": 110, + "width": 196 + }, + { + "url": "https://i.ytimg.com/vi/6bnanI9jXps/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCKNlgvl_7lKoFq8vyDYZRtTs4woA", + "height": 138, + "width": 246 + }, + { + "url": "https://i.ytimg.com/vi/6bnanI9jXps/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLBeZv8F8IyICmKD9qjo9pTMJmM8ug", + "height": 188, + "width": 336 + } + ], + "timestamp": null, + "release_timestamp": null, + "availability": null, + "view_count": 26000000, + "live_status": null, + "channel_is_verified": null + } + ], + "extractor_key": "YoutubeTab", + "extractor": "youtube:tab", + "webpage_url": "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJP", + "original_url": "https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJP", + "webpage_url_basename": "playlist", + "webpage_url_domain": "youtube.com", + "heatmap": [], + "automatic_captions": {} +} diff --git a/tests/components/media_extractor/snapshots/test_init.ambr b/tests/components/media_extractor/snapshots/test_init.ambr index d70c370b60c..ed56f40af73 100644 --- a/tests/components/media_extractor/snapshots/test_init.ambr +++ b/tests/components/media_extractor/snapshots/test_init.ambr @@ -1,4 +1,24 @@ # serializer version: 1 +# name: test_extract_media_service[https://soundcloud.com/bruttoband/brutto-11] + dict({ + 'url': 'https://cf-hls-opus-media.sndcdn.com/playlist/50remGX1OqRY.64.opus/playlist.m3u8?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLWhscy1vcHVzLW1lZGlhLnNuZGNkbi5jb20vcGxheWxpc3QvNTByZW1HWDFPcVJZLjY0Lm9wdXMvcGxheWxpc3QubTN1OCoiLCJDb25kaXRpb24iOnsiRGF0ZUxlc3NUaGFuIjp7IkFXUzpFcG9jaFRpbWUiOjE2OTU1MDM5NjR9fX1dfQ__&Signature=HqyeKoK4gx1Bd3jId5v1-9ltDY2SN7fzGp6M7tW3kWluv8Ns0SgANPKG3~Tzo8hUrQzAYVvwMbO2F75b6NBeBr4xx0SRCeKotZdArzBFT4Wtrz4HtEddLyPjp12vWYCDuOshd1sTjrvaBAd9TAFTZuwyal1OKpLMsqK0QN-KFH-5GsmLDZTPWcoVkMqC7XBmNWTq0G1mtVeP57TN~9T7qEYqRLFIDXdm2HKSPSR4BB2gjaZUK22pBUPSGVr-ziBSpNVHvNISy2QdObjS5zjuAe8bl-npQ3PlFatYECDI3Gc~wjCNIJPdTpCbHUsX36SSR4dnKlgW1nYGx~eED7dppA__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ', + }) +# --- +# name: test_extract_media_service[https://test.com/abc] + dict({ + 'url': 'https://cf-media.sndcdn.com/50remGX1OqRY.128.mp3?Policy=eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vNTByZW1HWDFPcVJZLjEyOC5tcDMqIiwiQ29uZGl0aW9uIjp7IkRhdGVMZXNzVGhhbiI6eyJBV1M6RXBvY2hUaW1lIjoxNjk0Nzk4NTkzfX19XX0_&Signature=flALJvEBnzS0ZOOhf0-07Ap~NURw2Gn2OqkeKKTTMX5HRGJw9eXFay79tcC4GsMMXWUgWoCx-n3yelpyilE2MOEIufBNUbjqRfMSJaX5YhYxjQdoDYuiU~gqBzJyPw9pKzr6P8~5HNKL3Idr0CNhUzdV6FQLaUPKMMibq9ghV833mUmdyvdk1~GZBc8MOg9GrTdcigGgpPzd-vrIMICMvFzFnwBOeOotxX2Vfqf9~wVekBKGlvB9A~7TlZ71lv9Fl9u4m8rse9E-mByweVc1M784ehJV3~tRPjuF~FXXWKP8x0nGJmoq7RAnG7iFIt~fQFmsfOq2o~PG7dHMRPh7hw__&Key-Pair-Id=APKAI6TU7MMXM5DG6EPQ', + }) +# --- +# name: test_extract_media_service[https://www.youtube.com/playlist?list=PLZ4DbyIWUwCq4V8bIEa8jm2ozHZVuREJP] + dict({ + 'url': 'https://www.youtube.com/watch?v=q6EoRBvdVPQ', + }) +# --- +# name: test_extract_media_service[https://www.youtube.com/watch?v=dQw4w9WgXcQ] + dict({ + 'url': 'https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1695524274&ei=UlEPZeSINMKn6dsPt6mr8Ac&ip=45.93.75.130&id=o-AA7xSUDqvzXPAn6m_Jd8d0jhyk4er7c5zjbyZCHyREaQ&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1311250&spc=UWF9f6JvGMIlJJFOWUL6L4bH2pl28C4&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1695502401&fvip=3&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgHVwajP0J2fiJ1ERoAonpxghXGzDmEXh3rvJ399UEMWECIFdBjiVUOk7QdiFBxQ4QqojJd8p_PfL25TV_8TBrp_Kb&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhANJRDHqeYwWk4W8XXGlTNmYA3OOiuzK3PEWtVpK4NimlAiEAwK0nj8vGtcP0IJMZVBGh8SC5KRFzPfx_yJn41ZOUGyQ%3D', + }) +# --- # name: test_no_target_entity ReadOnlyDict({ 'device_id': list([ diff --git a/tests/components/media_extractor/test_init.py b/tests/components/media_extractor/test_init.py index e47f0ae1470..388ea3be1fd 100644 --- a/tests/components/media_extractor/test_init.py +++ b/tests/components/media_extractor/test_init.py @@ -9,9 +9,14 @@ import pytest from syrupy import SnapshotAssertion from yt_dlp import DownloadError -from homeassistant.components.media_extractor import DOMAIN +from homeassistant.components.media_extractor.const import ( + ATTR_URL, + DOMAIN, + SERVICE_EXTRACT_MEDIA_URL, +) from homeassistant.components.media_player import SERVICE_PLAY_MEDIA from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from tests.common import load_json_object_fixture @@ -30,6 +35,58 @@ async def test_play_media_service_is_registered(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert hass.services.has_service(DOMAIN, SERVICE_PLAY_MEDIA) + assert hass.services.has_service(DOMAIN, SERVICE_EXTRACT_MEDIA_URL) + + +@pytest.mark.parametrize( + "url", + [ + YOUTUBE_VIDEO, + SOUNDCLOUD_TRACK, + NO_FORMATS_RESPONSE, + YOUTUBE_PLAYLIST, + ], +) +async def test_extract_media_service( + hass: HomeAssistant, + mock_youtube_dl: MockYoutubeDL, + snapshot: SnapshotAssertion, + empty_media_extractor_config: dict[str, Any], + url: str, +) -> None: + """Test play media service is registered.""" + await async_setup_component(hass, DOMAIN, empty_media_extractor_config) + await hass.async_block_till_done() + + assert ( + await hass.services.async_call( + DOMAIN, + SERVICE_EXTRACT_MEDIA_URL, + {ATTR_URL: url}, + blocking=True, + return_response=True, + ) + == snapshot + ) + + +async def test_extracting_playlist_no_entries( + hass: HomeAssistant, + mock_youtube_dl: MockYoutubeDL, + empty_media_extractor_config: dict[str, Any], +) -> None: + """Test extracting a playlist without entries.""" + + await async_setup_component(hass, DOMAIN, empty_media_extractor_config) + await hass.async_block_till_done() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_EXTRACT_MEDIA_URL, + {ATTR_URL: YOUTUBE_EMPTY_PLAYLIST}, + blocking=True, + return_response=True, + ) @pytest.mark.parametrize( diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 62cf12958d3..1253a856bbf 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -47,12 +47,36 @@ class ReadResult: return False +@pytest.fixture(name="check_config_loaded") +def check_config_loaded_fixture(): + """Set default for check_config_loaded.""" + return True + + +@pytest.fixture(name="register_words") +def register_words_fixture(): + """Set default for register_words.""" + return [0x00, 0x00] + + +@pytest.fixture(name="config_addon") +def config_addon_fixture(): + """Add extra configuration items.""" + return None + + +@pytest.fixture(name="do_exception") +def do_exception_fixture(): + """Remove side_effect to pymodbus calls.""" + return False + + @pytest.fixture(name="mock_pymodbus") -def mock_pymodbus_fixture(): +def mock_pymodbus_fixture(do_exception, register_words): """Mock pymodbus.""" mock_pb = mock.AsyncMock() mock_pb.close = mock.MagicMock() - read_result = ReadResult([]) + read_result = ReadResult(register_words if register_words else []) mock_pb.read_coils.return_value = read_result mock_pb.read_discrete_inputs.return_value = read_result mock_pb.read_input_registers.return_value = read_result @@ -61,6 +85,16 @@ def mock_pymodbus_fixture(): mock_pb.write_registers.return_value = read_result mock_pb.write_coil.return_value = read_result mock_pb.write_coils.return_value = read_result + if do_exception: + exc = ModbusException("mocked pymodbus exception") + mock_pb.read_coils.side_effect = exc + mock_pb.read_discrete_inputs.side_effect = exc + mock_pb.read_input_registers.side_effect = exc + mock_pb.read_holding_registers.side_effect = exc + mock_pb.write_register.side_effect = exc + mock_pb.write_registers.side_effect = exc + mock_pb.write_coil.side_effect = exc + mock_pb.write_coils.side_effect = exc with ( mock.patch( "homeassistant.components.modbus.modbus.AsyncModbusTcpClient", @@ -81,33 +115,9 @@ def mock_pymodbus_fixture(): yield mock_pb -@pytest.fixture(name="check_config_loaded") -def check_config_loaded_fixture(): - """Set default for check_config_loaded.""" - return True - - -@pytest.fixture(name="register_words") -def register_words_fixture(): - """Set default for register_words.""" - return [0x00, 0x00] - - -@pytest.fixture(name="config_addon") -def config_addon_fixture(): - """Add entra configuration items.""" - return None - - -@pytest.fixture(name="do_exception") -def do_exception_fixture(): - """Remove side_effect to pymodbus calls.""" - return False - - @pytest.fixture(name="mock_modbus") async def mock_modbus_fixture( - hass, caplog, register_words, check_config_loaded, config_addon, do_config + hass, caplog, check_config_loaded, config_addon, do_config, mock_pymodbus ): """Load integration modbus using mocked pymodbus.""" conf = copy.deepcopy(do_config) @@ -132,57 +142,23 @@ async def mock_modbus_fixture( } ] } - mock_pb = mock.AsyncMock() - mock_pb.close = mock.MagicMock() + now = dt_util.utcnow() with mock.patch( - "homeassistant.components.modbus.modbus.AsyncModbusTcpClient", - return_value=mock_pb, + "homeassistant.helpers.event.dt_util.utcnow", + return_value=now, autospec=True, ): - now = dt_util.utcnow() - with mock.patch( - "homeassistant.helpers.event.dt_util.utcnow", - return_value=now, - autospec=True, - ): - result = await async_setup_component(hass, DOMAIN, config) - assert result or not check_config_loaded - await hass.async_block_till_done() - yield mock_pb - - -@pytest.fixture(name="mock_pymodbus_exception") -async def mock_pymodbus_exception_fixture(hass, do_exception, mock_modbus): - """Trigger update call with time_changed event.""" - if do_exception: - exc = ModbusException("fail read_coils") - mock_modbus.read_coils.side_effect = exc - mock_modbus.read_discrete_inputs.side_effect = exc - mock_modbus.read_input_registers.side_effect = exc - mock_modbus.read_holding_registers.side_effect = exc - - -@pytest.fixture(name="mock_pymodbus_return") -async def mock_pymodbus_return_fixture(hass, register_words, mock_modbus): - """Trigger update call with time_changed event.""" - read_result = ReadResult(register_words if register_words else []) - mock_modbus.read_coils.return_value = read_result - mock_modbus.read_discrete_inputs.return_value = read_result - mock_modbus.read_input_registers.return_value = read_result - mock_modbus.read_holding_registers.return_value = read_result - mock_modbus.write_register.return_value = read_result - mock_modbus.write_registers.return_value = read_result - mock_modbus.write_coil.return_value = read_result - mock_modbus.write_coils.return_value = read_result - return mock_modbus + result = await async_setup_component(hass, DOMAIN, config) + assert result or not check_config_loaded + await hass.async_block_till_done() + return mock_pymodbus @pytest.fixture(name="mock_do_cycle") async def mock_do_cycle_fixture( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - mock_pymodbus_exception, - mock_pymodbus_return, + mock_modbus, ) -> FrozenDateTimeFactory: """Trigger update call with time_changed event.""" freezer.tick(timedelta(seconds=1)) @@ -207,11 +183,12 @@ async def mock_test_state_fixture(hass, request): return request.param -@pytest.fixture(name="mock_ha") -async def mock_ha_fixture(hass, mock_pymodbus_return): +@pytest.fixture(name="mock_modbus_ha") +async def mock_modbus_ha_fixture(hass, mock_modbus): """Load homeassistant to allow service calls.""" assert await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() + return mock_modbus @pytest.fixture(name="caplog_setup_text") diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 567618de3c6..7ae933998cf 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -207,7 +207,7 @@ async def test_all_binary_sensor(hass: HomeAssistant, expected, mock_do_cycle) - ], ) async def test_service_binary_sensor_update( - hass: HomeAssistant, mock_modbus, mock_ha + hass: HomeAssistant, mock_modbus_ha ) -> None: """Run test for service homeassistant.update_entity.""" @@ -217,7 +217,7 @@ async def test_service_binary_sensor_update( await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_OFF - mock_modbus.read_coils.return_value = ReadResult([0x01]) + mock_modbus_ha.read_coils.return_value = ReadResult([0x01]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 093dee67895..94778cdcbd2 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -496,10 +496,10 @@ async def test_temperature_error(hass: HomeAssistant, expected, mock_do_cycle) - ], ) async def test_service_climate_update( - hass: HomeAssistant, mock_modbus, mock_ha, result, register_words + hass: HomeAssistant, mock_modbus_ha, result, register_words ) -> None: """Run test for service homeassistant.update_entity.""" - mock_modbus.read_holding_registers.return_value = ReadResult(register_words) + mock_modbus_ha.read_holding_registers.return_value = ReadResult(register_words) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) @@ -611,10 +611,10 @@ async def test_service_climate_update( ], ) async def test_service_climate_fan_update( - hass: HomeAssistant, mock_modbus, mock_ha, result, register_words + hass: HomeAssistant, mock_modbus_ha, result, register_words ) -> None: """Run test for service homeassistant.update_entity.""" - mock_modbus.read_holding_registers.return_value = ReadResult(register_words) + mock_modbus_ha.read_holding_registers.return_value = ReadResult(register_words) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) @@ -751,10 +751,10 @@ async def test_service_climate_fan_update( ], ) async def test_service_climate_swing_update( - hass: HomeAssistant, mock_modbus, mock_ha, result, register_words + hass: HomeAssistant, mock_modbus_ha, result, register_words ) -> None: """Run test for service homeassistant.update_entity.""" - mock_modbus.read_holding_registers.return_value = ReadResult(register_words) + mock_modbus_ha.read_holding_registers.return_value = ReadResult(register_words) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) @@ -844,10 +844,10 @@ async def test_service_climate_swing_update( ], ) async def test_service_climate_set_temperature( - hass: HomeAssistant, temperature, result, mock_modbus, mock_ha + hass: HomeAssistant, temperature, result, mock_modbus_ha ) -> None: """Test set_temperature.""" - mock_modbus.read_holding_registers.return_value = ReadResult(result) + mock_modbus_ha.read_holding_registers.return_value = ReadResult(result) await hass.services.async_call( CLIMATE_DOMAIN, "set_temperature", @@ -954,10 +954,10 @@ async def test_service_climate_set_temperature( ], ) async def test_service_set_hvac_mode( - hass: HomeAssistant, hvac_mode, result, mock_modbus, mock_ha + hass: HomeAssistant, hvac_mode, result, mock_modbus_ha ) -> None: """Test set HVAC mode.""" - mock_modbus.read_holding_registers.return_value = ReadResult(result) + mock_modbus_ha.read_holding_registers.return_value = ReadResult(result) await hass.services.async_call( CLIMATE_DOMAIN, @@ -1018,10 +1018,10 @@ async def test_service_set_hvac_mode( ], ) async def test_service_set_fan_mode( - hass: HomeAssistant, fan_mode, result, mock_modbus, mock_ha + hass: HomeAssistant, fan_mode, result, mock_modbus_ha ) -> None: """Test set Fan mode.""" - mock_modbus.read_holding_registers.return_value = ReadResult(result) + mock_modbus_ha.read_holding_registers.return_value = ReadResult(result) await hass.services.async_call( CLIMATE_DOMAIN, "set_fan_mode", @@ -1081,10 +1081,10 @@ async def test_service_set_fan_mode( ], ) async def test_service_set_swing_mode( - hass: HomeAssistant, swing_mode, result, mock_modbus, mock_ha + hass: HomeAssistant, swing_mode, result, mock_modbus_ha ) -> None: """Test set Swing mode.""" - mock_modbus.read_holding_registers.return_value = ReadResult(result) + mock_modbus_ha.read_holding_registers.return_value = ReadResult(result) await hass.services.async_call( CLIMATE_DOMAIN, "set_swing_mode", diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index fa9e617d96d..0860b3136ba 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -182,13 +182,13 @@ async def test_register_cover(hass: HomeAssistant, expected, mock_do_cycle) -> N }, ], ) -async def test_service_cover_update(hass: HomeAssistant, mock_modbus, mock_ha) -> None: +async def test_service_cover_update(hass: HomeAssistant, mock_modbus_ha) -> None: """Run test for service homeassistant.update_entity.""" await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_CLOSED - mock_modbus.read_holding_registers.return_value = ReadResult([0x01]) + mock_modbus_ha.read_holding_registers.return_value = ReadResult([0x01]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) @@ -255,30 +255,30 @@ async def test_restore_state_cover( }, ], ) -async def test_service_cover_move(hass: HomeAssistant, mock_modbus, mock_ha) -> None: +async def test_service_cover_move(hass: HomeAssistant, mock_modbus_ha) -> None: """Run test for service homeassistant.update_entity.""" - mock_modbus.read_holding_registers.return_value = ReadResult([0x01]) + mock_modbus_ha.read_holding_registers.return_value = ReadResult([0x01]) await hass.services.async_call( "cover", "open_cover", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_OPEN - mock_modbus.read_holding_registers.return_value = ReadResult([0x00]) + mock_modbus_ha.read_holding_registers.return_value = ReadResult([0x00]) await hass.services.async_call( "cover", "close_cover", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_CLOSED - await mock_modbus.reset() - mock_modbus.read_holding_registers.side_effect = ModbusException("fail write_") + await mock_modbus_ha.reset() + mock_modbus_ha.read_holding_registers.side_effect = ModbusException("fail write_") await hass.services.async_call( "cover", "close_cover", {"entity_id": ENTITY_ID}, blocking=True ) - assert mock_modbus.read_holding_registers.called + assert mock_modbus_ha.read_holding_registers.called assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - mock_modbus.read_coils.side_effect = ModbusException("fail write_") + mock_modbus_ha.read_coils.side_effect = ModbusException("fail write_") await hass.services.async_call( "cover", "close_cover", {"entity_id": ENTITY_ID2}, blocking=True ) diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 9719de3601b..d52b9dc309a 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -262,7 +262,6 @@ async def test_fan_service_turn( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus, - mock_pymodbus_return, ) -> None: """Run test for service turn_on/turn_off.""" @@ -323,13 +322,13 @@ async def test_fan_service_turn( }, ], ) -async def test_service_fan_update(hass: HomeAssistant, mock_modbus, mock_ha) -> None: +async def test_service_fan_update(hass: HomeAssistant, mock_modbus_ha) -> None: """Run test for service homeassistant.update_entity.""" await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_OFF - mock_modbus.read_coils.return_value = ReadResult([0x01]) + mock_modbus_ha.read_coils.return_value = ReadResult([0x01]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 1219a04fb0c..82c65576f02 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -1366,7 +1366,6 @@ async def mock_modbus_read_pymodbus_fixture( do_type, do_scan_interval, do_return, - do_exception, caplog, mock_pymodbus, freezer: FrozenDateTimeFactory, @@ -1374,10 +1373,6 @@ async def mock_modbus_read_pymodbus_fixture( """Load integration modbus using mocked pymodbus.""" caplog.clear() caplog.set_level(logging.ERROR) - mock_pymodbus.read_coils.side_effect = do_exception - mock_pymodbus.read_discrete_inputs.side_effect = do_exception - mock_pymodbus.read_input_registers.side_effect = do_exception - mock_pymodbus.read_holding_registers.side_effect = do_exception mock_pymodbus.read_coils.return_value = do_return mock_pymodbus.read_discrete_inputs.return_value = do_return mock_pymodbus.read_input_registers.return_value = do_return @@ -1646,7 +1641,7 @@ async def test_shutdown( ], ) async def test_stop_restart( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_pymodbus_return + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus ) -> None: """Run test for service stop.""" @@ -1657,7 +1652,7 @@ async def test_stop_restart( await hass.async_block_till_done() assert hass.states.get(entity_id).state == "17" - mock_pymodbus_return.reset_mock() + mock_modbus.reset_mock() caplog.clear() data = { ATTR_HUB: TEST_MODBUS_NAME, @@ -1665,23 +1660,23 @@ async def test_stop_restart( await hass.services.async_call(DOMAIN, SERVICE_STOP, data, blocking=True) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - assert mock_pymodbus_return.close.called + assert mock_modbus.close.called assert f"modbus {TEST_MODBUS_NAME} communication closed" in caplog.text - mock_pymodbus_return.reset_mock() + mock_modbus.reset_mock() caplog.clear() await hass.services.async_call(DOMAIN, SERVICE_RESTART, data, blocking=True) await hass.async_block_till_done() - assert not mock_pymodbus_return.close.called - assert mock_pymodbus_return.connect.called + assert not mock_modbus.close.called + assert mock_modbus.connect.called assert f"modbus {TEST_MODBUS_NAME} communication open" in caplog.text - mock_pymodbus_return.reset_mock() + mock_modbus.reset_mock() caplog.clear() await hass.services.async_call(DOMAIN, SERVICE_RESTART, data, blocking=True) await hass.async_block_till_done() - assert mock_pymodbus_return.close.called - assert mock_pymodbus_return.connect.called + assert mock_modbus.close.called + assert mock_modbus.connect.called assert f"modbus {TEST_MODBUS_NAME} communication closed" in caplog.text assert f"modbus {TEST_MODBUS_NAME} communication open" in caplog.text @@ -1711,7 +1706,7 @@ async def test_write_no_client(hass: HomeAssistant, mock_modbus) -> None: async def test_integration_reload( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, - mock_pymodbus_return, + mock_modbus, freezer: FrozenDateTimeFactory, ) -> None: """Run test for integration reload.""" @@ -1732,7 +1727,7 @@ async def test_integration_reload( @pytest.mark.parametrize("do_config", [{}]) async def test_integration_reload_failed( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_pymodbus_return + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus ) -> None: """Run test for integration connect failure on reload.""" caplog.set_level(logging.INFO) @@ -1741,9 +1736,7 @@ async def test_integration_reload_failed( yaml_path = get_fixture_path("configuration.yaml", "modbus") with ( mock.patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path), - mock.patch.object( - mock_pymodbus_return, "connect", side_effect=ModbusException("error") - ), + mock.patch.object(mock_modbus, "connect", side_effect=ModbusException("error")), ): await hass.services.async_call(DOMAIN, SERVICE_RELOAD, blocking=True) await hass.async_block_till_done() @@ -1754,7 +1747,7 @@ async def test_integration_reload_failed( @pytest.mark.parametrize("do_config", [{}]) async def test_integration_setup_failed( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_pymodbus_return + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus ) -> None: """Run test for integration setup on reload.""" with mock.patch.object( diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index e5e1b56d77b..e74da085180 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -262,7 +262,6 @@ async def test_light_service_turn( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus, - mock_pymodbus_return, ) -> None: """Run test for service turn_on/turn_off.""" @@ -300,12 +299,6 @@ async def test_light_service_turn( ) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID2).state == STATE_UNAVAILABLE - mock_modbus.write_coil.side_effect = ModbusException("fail write_") - await hass.services.async_call( - "light", "turn_off", service_data={"entity_id": ENTITY_ID} - ) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE @pytest.mark.parametrize( @@ -323,13 +316,13 @@ async def test_light_service_turn( }, ], ) -async def test_service_light_update(hass: HomeAssistant, mock_modbus, mock_ha) -> None: +async def test_service_light_update(hass: HomeAssistant, mock_modbus_ha) -> None: """Run test for service homeassistant.update_entity.""" await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_OFF - mock_modbus.read_coils.return_value = ReadResult([0x01]) + mock_modbus_ha.read_coils.return_value = ReadResult([0x01]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 524acc0dabb..71cb64cc1b6 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -1391,14 +1391,14 @@ async def test_restore_state_sensor( }, ], ) -async def test_service_sensor_update(hass: HomeAssistant, mock_modbus, mock_ha) -> None: +async def test_service_sensor_update(hass: HomeAssistant, mock_modbus_ha) -> None: """Run test for service homeassistant.update_entity.""" - mock_modbus.read_input_registers.return_value = ReadResult([27]) + mock_modbus_ha.read_input_registers.return_value = ReadResult([27]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == "27" - mock_modbus.read_input_registers.return_value = ReadResult([32]) + mock_modbus_ha.read_input_registers.return_value = ReadResult([32]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 4eb0a5b3a18..bdb95c667c7 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -277,7 +277,6 @@ async def test_switch_service_turn( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus, - mock_pymodbus_return, ) -> None: """Run test for service turn_on/turn_off.""" assert MODBUS_DOMAIN in hass.config.components @@ -337,13 +336,13 @@ async def test_switch_service_turn( }, ], ) -async def test_service_switch_update(hass: HomeAssistant, mock_modbus, mock_ha) -> None: +async def test_service_switch_update(hass: HomeAssistant, mock_modbus_ha) -> None: """Run test for service homeassistant.update_entity.""" await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) assert hass.states.get(ENTITY_ID).state == STATE_OFF - mock_modbus.read_coils.return_value = ReadResult([0x01]) + mock_modbus_ha.read_coils.return_value = ReadResult([0x01]) await hass.services.async_call( "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True ) @@ -368,9 +367,7 @@ async def test_service_switch_update(hass: HomeAssistant, mock_modbus, mock_ha) }, ], ) -async def test_delay_switch( - hass: HomeAssistant, mock_modbus, mock_pymodbus_return -) -> None: +async def test_delay_switch(hass: HomeAssistant, mock_modbus) -> None: """Run test for switch verify delay.""" mock_modbus.read_holding_registers.return_value = ReadResult([0x01]) now = dt_util.utcnow() diff --git a/tests/components/mqtt/test_notify.py b/tests/components/mqtt/test_notify.py new file mode 100644 index 00000000000..bc833b79eb0 --- /dev/null +++ b/tests/components/mqtt/test_notify.py @@ -0,0 +1,474 @@ +"""The tests for the MQTT notify platform.""" + +import copy +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import mqtt, notify +from homeassistant.components.notify import ATTR_MESSAGE +from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .test_common import ( + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_entity_debug_info_message, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_publishing_with_custom_encoding, + help_test_reloadable, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient + +DEFAULT_CONFIG = { + mqtt.DOMAIN: {notify.DOMAIN: {"name": "test", "command_topic": "test-topic"}} +} + + +@pytest.mark.freeze_time("2021-11-08 13:31:44+00:00") +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + notify.DOMAIN: { + "command_topic": "command-topic", + "name": "test", + "object_id": "test_notify", + "qos": "2", + } + } + } + ], +) +async def test_sending_mqtt_commands( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the sending MQTT commands.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("notify.test_notify") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "test" + + await hass.services.async_call( + notify.DOMAIN, + notify.SERVICE_SEND_MESSAGE, + {ATTR_MESSAGE: "Beer message", ATTR_ENTITY_ID: "notify.test_notify"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", "Beer message", 2, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("notify.test_notify") + assert state.state == "2021-11-08T13:31:44+00:00" + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + notify.DOMAIN: { + "command_topic": "command-topic", + "command_template": '{ "{{ entity_id }}": "{{ value }}" }', + "name": "test", + } + } + } + ], +) +async def test_command_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the sending of MQTT commands through a command template.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("notify.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "test" + + await hass.services.async_call( + notify.DOMAIN, + notify.SERVICE_SEND_MESSAGE, + {ATTR_MESSAGE: "Beer message", ATTR_ENTITY_ID: "notify.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", '{ "notify.test": "Beer message" }', 0, False + ) + mqtt_mock.async_publish.reset_mock() + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_when_connection_lost( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry, notify.DOMAIN + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_without_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_payload( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG, True + ) + + +async def test_custom_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by custom payload with defined topic.""" + await help_test_custom_availability_payload( + hass, + mqtt_mock_entry, + notify.DOMAIN, + DEFAULT_CONFIG, + True, + ) + + +async def test_setting_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG, None + ) + + +async def test_setting_attribute_with_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, + mqtt_mock_entry, + caplog, + notify.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_update_with_json_attrs_bad_json( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, + mqtt_mock_entry, + caplog, + notify.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_discovery_update_attr( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, + mqtt_mock_entry, + caplog, + notify.DOMAIN, + DEFAULT_CONFIG, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + notify.DOMAIN: [ + { + "name": "Test 1", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique id option only creates one notify entity per unique_id.""" + await help_test_unique_id(hass, mqtt_mock_entry, notify.DOMAIN) + + +async def test_discovery_removal_notify( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test removal of discovered notify.""" + data = '{ "name": "test", "command_topic": "test_topic" }' + await help_test_discovery_removal( + hass, mqtt_mock_entry, caplog, notify.DOMAIN, data + ) + + +async def test_discovery_update_notify( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered notify.""" + config1 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][notify.DOMAIN]) + config2 = copy.deepcopy(DEFAULT_CONFIG[mqtt.DOMAIN][notify.DOMAIN]) + config1["name"] = "Beer" + config2["name"] = "Milk" + + await help_test_discovery_update( + hass, + mqtt_mock_entry, + caplog, + notify.DOMAIN, + config1, + config2, + ) + + +async def test_discovery_update_unchanged_notify( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered notify.""" + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + with patch( + "homeassistant.components.mqtt.notify.MqttNotify.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, + mqtt_mock_entry, + caplog, + notify.DOMAIN, + data1, + discovery_update, + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic" }' + await help_test_discovery_broken( + hass, mqtt_mock_entry, caplog, notify.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT notify device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT notify device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry, notify.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT debug info.""" + await help_test_entity_debug_info_message( + hass, + mqtt_mock_entry, + notify.DOMAIN, + DEFAULT_CONFIG, + notify.SERVICE_SEND_MESSAGE, + command_topic="test-topic", + command_payload="Milk", + state_topic=None, + service_parameters={"message": "Milk"}, + ) + + +@pytest.mark.parametrize( + ("service", "topic", "parameters", "payload", "template"), + [ + ( + notify.SERVICE_SEND_MESSAGE, + "command_topic", + {"message": "Beer test"}, + "Beer test", + "command_template", + ), + ], +) +async def test_publishing_with_custom_encoding( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, +) -> None: + """Test publishing MQTT payload with different encoding.""" + domain = notify.DOMAIN + config = DEFAULT_CONFIG + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock_entry, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = notify.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry() + platform = notify.DOMAIN + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test unloading the config entry.""" + domain = notify.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + ) diff --git a/tests/components/oncue/__init__.py b/tests/components/oncue/__init__.py index df1452b176e..d88774307c0 100644 --- a/tests/components/oncue/__init__.py +++ b/tests/components/oncue/__init__.py @@ -3,7 +3,7 @@ from contextlib import contextmanager from unittest.mock import patch -from aiooncue import OncueDevice, OncueSensor +from aiooncue import LoginFailedException, OncueDevice, OncueSensor MOCK_ASYNC_FETCH_ALL = { "123456": OncueDevice( @@ -861,3 +861,21 @@ def _patch_login_and_data_unavailable_device(): yield return _patcher() + + +def _patch_login_and_data_auth_failure(): + @contextmanager + def _patcher(): + with ( + patch( + "homeassistant.components.oncue.Oncue.async_login", + side_effect=LoginFailedException, + ), + patch( + "homeassistant.components.oncue.Oncue.async_fetch_all", + side_effect=LoginFailedException, + ), + ): + yield + + return _patcher() diff --git a/tests/components/oncue/test_config_flow.py b/tests/components/oncue/test_config_flow.py index 2f327dec052..3907242e26c 100644 --- a/tests/components/oncue/test_config_flow.py +++ b/tests/components/oncue/test_config_flow.py @@ -6,6 +6,7 @@ from aiooncue import LoginFailedException from homeassistant import config_entries from homeassistant.components.oncue.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -42,7 +43,7 @@ async def test_form(hass: HomeAssistant) -> None: "username": "TEST-username", "password": "test-password", } - assert len(mock_setup_entry.mock_calls) == 1 + assert mock_setup_entry.call_count == 1 async def test_form_invalid_auth(hass: HomeAssistant) -> None: @@ -64,7 +65,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: ) assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result2["errors"] == {CONF_PASSWORD: "invalid_auth"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -138,3 +139,54 @@ async def test_already_configured(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" + + +async def test_reauth(hass: HomeAssistant) -> None: + """Test reauth flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "any", + CONF_PASSWORD: "old", + }, + ) + config_entry.add_to_hass(hass) + config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 1 + flow = flows[0] + + with patch( + "homeassistant.components.oncue.config_flow.Oncue.async_login", + side_effect=LoginFailedException, + ): + result2 = await hass.config_entries.flow.async_configure( + flow["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"password": "invalid_auth"} + + with ( + patch("homeassistant.components.oncue.config_flow.Oncue.async_login"), + patch( + "homeassistant.components.oncue.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result2 = await hass.config_entries.flow.async_configure( + flow["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert config_entry.data[CONF_PASSWORD] == "test-password" + assert mock_setup_entry.call_count == 1 diff --git a/tests/components/oncue/test_init.py b/tests/components/oncue/test_init.py index 2da3e04e4c3..cf93b51dee1 100644 --- a/tests/components/oncue/test_init.py +++ b/tests/components/oncue/test_init.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import timedelta from unittest.mock import patch from aiooncue import LoginFailedException @@ -12,10 +13,11 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from . import _patch_login_and_data +from . import _patch_login_and_data, _patch_login_and_data_auth_failure -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_config_entry_reload(hass: HomeAssistant) -> None: @@ -67,3 +69,26 @@ async def test_config_entry_retry_later(hass: HomeAssistant) -> None: await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_late_auth_failure(hass: HomeAssistant) -> None: + """Test auth fails after already setup.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "any", CONF_PASSWORD: "any"}, + unique_id="any", + ) + config_entry.add_to_hass(hass) + with _patch_login_and_data(): + await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + with _patch_login_and_data_auth_failure(): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 1 + flow = flows[0] + assert flow["context"]["source"] == "reauth" diff --git a/tests/components/qld_bushfire/test_geo_location.py b/tests/components/qld_bushfire/test_geo_location.py index 522c5fabe90..20659182726 100644 --- a/tests/components/qld_bushfire/test_geo_location.py +++ b/tests/components/qld_bushfire/test_geo_location.py @@ -169,7 +169,7 @@ async def test_setup(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> Non [mock_entry_1, mock_entry_4, mock_entry_3], ) async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) all_states = hass.states.async_all() assert len(all_states) == 3 diff --git a/tests/components/repairs/test_init.py b/tests/components/repairs/test_init.py index ec34409eb74..75088f6c370 100644 --- a/tests/components/repairs/test_init.py +++ b/tests/components/repairs/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, Mock +from awesomeversion.exceptions import AwesomeVersionStrategyException from freezegun.api import FrozenDateTimeFactory import pytest @@ -145,7 +146,7 @@ async def test_create_issue_invalid_version( "translation_placeholders": {"abc": "123"}, } - with pytest.raises(Exception): + with pytest.raises(AwesomeVersionStrategyException): async_create_issue( hass, issue["domain"], diff --git a/tests/components/rest/test_init.py b/tests/components/rest/test_init.py index 38a1661a831..0fda89cc329 100644 --- a/tests/components/rest/test_init.py +++ b/tests/components/rest/test_init.py @@ -475,3 +475,62 @@ async def test_config_schema_via_packages(hass: HomeAssistant) -> None: assert len(config["rest"]) == 2 assert config["rest"][0]["resource"] == "http://url1" assert config["rest"][1]["resource"] == "http://url2" + + +@respx.mock +async def test_setup_minimum_payload_template(hass: HomeAssistant) -> None: + """Test setup with minimum configuration (payload_template).""" + + respx.post("http://localhost", json={"data": "value"}).respond( + status_code=HTTPStatus.OK, + json={ + "sensor1": "1", + "sensor2": "2", + "binary_sensor1": "on", + "binary_sensor2": "off", + }, + ) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "payload_template": '{% set payload = {"data": "value"} %}{{ payload | to_json }}', + "method": "POST", + "verify_ssl": "false", + "timeout": 30, + "sensor": [ + { + "unit_of_measurement": UnitOfInformation.MEGABYTES, + "name": "sensor1", + "value_template": "{{ value_json.sensor1 }}", + }, + { + "unit_of_measurement": UnitOfInformation.MEGABYTES, + "name": "sensor2", + "value_template": "{{ value_json.sensor2 }}", + }, + ], + "binary_sensor": [ + { + "name": "binary_sensor1", + "value_template": "{{ value_json.binary_sensor1 }}", + }, + { + "name": "binary_sensor2", + "value_template": "{{ value_json.binary_sensor2 }}", + }, + ], + } + ] + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 4 + + assert hass.states.get("sensor.sensor1").state == "1" + assert hass.states.get("sensor.sensor2").state == "2" + assert hass.states.get("binary_sensor.binary_sensor1").state == "on" + assert hass.states.get("binary_sensor.binary_sensor2").state == "off" diff --git a/tests/components/sanix/__init__.py b/tests/components/sanix/__init__.py new file mode 100644 index 00000000000..ef1a9c63fbe --- /dev/null +++ b/tests/components/sanix/__init__.py @@ -0,0 +1,13 @@ +"""Tests for Sanix.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/sanix/conftest.py b/tests/components/sanix/conftest.py new file mode 100644 index 00000000000..d1f4424b166 --- /dev/null +++ b/tests/components/sanix/conftest.py @@ -0,0 +1,76 @@ +"""Sanix tests configuration.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, patch +from zoneinfo import ZoneInfo + +import pytest +from sanix import ( + ATTR_API_BATTERY, + ATTR_API_DEVICE_NO, + ATTR_API_DISTANCE, + ATTR_API_FILL_PERC, + ATTR_API_SERVICE_DATE, + ATTR_API_SSID, + ATTR_API_STATUS, + ATTR_API_TIME, +) +from sanix.models import Measurement + +from homeassistant.components.sanix.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.const import CONF_TOKEN + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_sanix(): + """Build a fixture for the Sanix API that connects successfully and returns measurements.""" + fixture = load_json_object_fixture("get_measurements.json", DOMAIN) + with ( + patch( + "homeassistant.components.sanix.config_flow.Sanix", + autospec=True, + ) as mock_sanix_api, + patch( + "homeassistant.components.sanix.Sanix", + new=mock_sanix_api, + ), + ): + mock_sanix_api.return_value.fetch_data.return_value = Measurement( + battery=fixture[ATTR_API_BATTERY], + device_no=fixture[ATTR_API_DEVICE_NO], + distance=fixture[ATTR_API_DISTANCE], + fill_perc=fixture[ATTR_API_FILL_PERC], + service_date=datetime.strptime( + fixture[ATTR_API_SERVICE_DATE], "%d.%m.%Y" + ).date(), + ssid=fixture[ATTR_API_SSID], + status=fixture[ATTR_API_STATUS], + time=datetime.strptime(fixture[ATTR_API_TIME], "%d.%m.%Y %H:%M:%S").replace( + tzinfo=ZoneInfo("Europe/Warsaw") + ), + ) + yield mock_sanix_api + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Sanix", + unique_id="1810088", + data={CONF_SERIAL_NUMBER: "1234", CONF_TOKEN: "abcd"}, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sanix.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/sanix/fixtures/get_measurements.json b/tests/components/sanix/fixtures/get_measurements.json new file mode 100644 index 00000000000..de6f4c41311 --- /dev/null +++ b/tests/components/sanix/fixtures/get_measurements.json @@ -0,0 +1,10 @@ +{ + "device_no": "SANIX-1810088", + "status": "1", + "time": "30.12.2023 03:10:21", + "ssid": "Wifi", + "battery": "100", + "distance": "109", + "fill_perc": 32, + "service_date": "15.06.2024" +} diff --git a/tests/components/sanix/snapshots/test_sensor.ambr b/tests/components/sanix/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..84c97ce68b1 --- /dev/null +++ b/tests/components/sanix/snapshots/test_sensor.ambr @@ -0,0 +1,292 @@ +# serializer version: 1 +# name: test_all_entities[sensor.sanix_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sanix_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'sanix', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1810088-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sanix_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Sanix Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sanix_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.sanix_device_number-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sanix_device_number', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Device number', + 'platform': 'sanix', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_no', + 'unique_id': '1810088-device_no', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sanix_device_number-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sanix Device number', + }), + 'context': , + 'entity_id': 'sensor.sanix_device_number', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'SANIX-1810088', + }) +# --- +# name: test_all_entities[sensor.sanix_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sanix_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance', + 'platform': 'sanix', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1810088-distance', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.sanix_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Sanix Distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sanix_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '109', + }) +# --- +# name: test_all_entities[sensor.sanix_filled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sanix_filled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filled', + 'platform': 'sanix', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fill_perc', + 'unique_id': '1810088-fill_perc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.sanix_filled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sanix Filled', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.sanix_filled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_all_entities[sensor.sanix_service_date-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sanix_service_date', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Service date', + 'platform': 'sanix', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'service_date', + 'unique_id': '1810088-service_date', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sanix_service_date-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Sanix Service date', + }), + 'context': , + 'entity_id': 'sensor.sanix_service_date', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-06-15', + }) +# --- +# name: test_all_entities[sensor.sanix_ssid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sanix_ssid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'SSID', + 'platform': 'sanix', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ssid', + 'unique_id': '1810088-ssid', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.sanix_ssid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sanix SSID', + }), + 'context': , + 'entity_id': 'sensor.sanix_ssid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Wifi', + }) +# --- diff --git a/tests/components/sanix/test_config_flow.py b/tests/components/sanix/test_config_flow.py new file mode 100644 index 00000000000..abd91ee306c --- /dev/null +++ b/tests/components/sanix/test_config_flow.py @@ -0,0 +1,112 @@ +"""Define tests for the Sanix config flow.""" + +from unittest.mock import MagicMock + +import pytest +from sanix.exceptions import SanixException, SanixInvalidAuthException + +from homeassistant.components.sanix.const import ( + CONF_SERIAL_NUMBER, + DOMAIN, + MANUFACTURER, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +CONFIG = {CONF_SERIAL_NUMBER: "1810088", CONF_TOKEN: "75868dcf8ea4c64e2063f6c4e70132d2"} + + +async def test_create_entry( + hass: HomeAssistant, mock_sanix: MagicMock, mock_setup_entry +) -> None: + """Test that the user step works.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MANUFACTURER + assert result["data"] == { + CONF_SERIAL_NUMBER: "1810088", + CONF_TOKEN: "75868dcf8ea4c64e2063f6c4e70132d2", + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (SanixInvalidAuthException("Invalid auth"), "invalid_auth"), + (SanixException("Something went wrong"), "unknown"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + exception: Exception, + error: str, + mock_sanix: MagicMock, + mock_setup_entry, +) -> None: + """Test Form exceptions.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_sanix.return_value.fetch_data.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + + mock_sanix.return_value.fetch_data.side_effect = None + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Sanix" + assert result["data"] == { + CONF_SERIAL_NUMBER: "1810088", + CONF_TOKEN: "75868dcf8ea4c64e2063f6c4e70132d2", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_error( + hass: HomeAssistant, mock_sanix: MagicMock, mock_config_entry: MockConfigEntry +) -> None: + """Test that errors are shown when duplicates are added.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/sanix/test_init.py b/tests/components/sanix/test_init.py new file mode 100644 index 00000000000..467737628fe --- /dev/null +++ b/tests/components/sanix/test_init.py @@ -0,0 +1,27 @@ +"""Test the Sanix init module.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.sanix import setup_integration + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_sanix: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/sanix/test_sensor.py b/tests/components/sanix/test_sensor.py new file mode 100644 index 00000000000..d9729ca3c25 --- /dev/null +++ b/tests/components/sanix/test_sensor.py @@ -0,0 +1,39 @@ +"""Test the Sanix sensor module.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_sanix: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.sanix.PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot(name=f"{entity_entry.entity_id}-state") diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 6ff640e012a..ae8a288e3a5 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -370,9 +370,9 @@ async def test_remove_entry_installedapp_unknown_error( ) -> None: """Test raises exceptions removing the installed app.""" # Arrange - smartthings_mock.delete_installed_app.side_effect = Exception + smartthings_mock.delete_installed_app.side_effect = ValueError # Act - with pytest.raises(Exception): + with pytest.raises(ValueError): await smartthings.async_remove_entry(hass, config_entry) # Assert assert smartthings_mock.delete_installed_app.call_count == 1 @@ -403,9 +403,9 @@ async def test_remove_entry_app_unknown_error( ) -> None: """Test raises exceptions removing the app.""" # Arrange - smartthings_mock.delete_app.side_effect = Exception + smartthings_mock.delete_app.side_effect = ValueError # Act - with pytest.raises(Exception): + with pytest.raises(ValueError): await smartthings.async_remove_entry(hass, config_entry) # Assert assert smartthings_mock.delete_installed_app.call_count == 1 diff --git a/tests/components/sms/__init__.py b/tests/components/sms/__init__.py new file mode 100644 index 00000000000..09b4b0941fb --- /dev/null +++ b/tests/components/sms/__init__.py @@ -0,0 +1 @@ +"""Tests for SMS integration.""" diff --git a/tests/components/sms/const.py b/tests/components/sms/const.py new file mode 100644 index 00000000000..ae875e6d58e --- /dev/null +++ b/tests/components/sms/const.py @@ -0,0 +1,143 @@ +"""Constants for tests of the SMS component.""" + +import datetime + +SMS_STATUS_SINGLE = { + "SIMUnRead": 0, + "SIMUsed": 1, + "SIMSize": 30, + "PhoneUnRead": 0, + "PhoneUsed": 0, + "PhoneSize": 50, + "TemplatesUsed": 0, +} + +NEXT_SMS_SINGLE = [ + { + "SMSC": { + "Location": 0, + "Name": "", + "Format": "Text", + "Validity": "NA", + "Number": "+358444111111", + "DefaultNumber": "", + }, + "UDH": { + "Type": "NoUDH", + "Text": b"", + "ID8bit": 0, + "ID16bit": 0, + "PartNumber": -1, + "AllParts": 0, + }, + "Folder": 1, + "InboxFolder": 1, + "Memory": "SM", + "Location": 1, + "Name": "", + "Number": "+358444222222", + "Text": "Short message", + "Type": "Deliver", + "Coding": "Default_No_Compression", + "DateTime": datetime.datetime(2024, 3, 23, 20, 15, 37), + "SMSCDateTime": datetime.datetime(2024, 3, 23, 20, 15, 41), + "DeliveryStatus": 0, + "ReplyViaSameSMSC": 0, + "State": "UnRead", + "Class": -1, + "MessageReference": 0, + "ReplaceMessage": 0, + "RejectDuplicates": 0, + "Length": 7, + } +] + +SMS_STATUS_MULTIPLE = { + "SIMUnRead": 0, + "SIMUsed": 2, + "SIMSize": 30, + "PhoneUnRead": 0, + "PhoneUsed": 0, + "PhoneSize": 50, + "TemplatesUsed": 0, +} + +NEXT_SMS_MULTIPLE_1 = [ + { + "SMSC": { + "Location": 0, + "Name": "", + "Format": "Text", + "Validity": "NA", + "Number": "+358444111111", + "DefaultNumber": "", + }, + "UDH": { + "Type": "ConcatenatedMessages", + "Text": b"\x05\x00\x03\x00\x02\x01", + "ID8bit": 0, + "ID16bit": -1, + "PartNumber": 1, + "AllParts": 2, + }, + "Folder": 1, + "InboxFolder": 1, + "Memory": "SM", + "Location": 1, + "Name": "", + "Number": "+358444222222", + "Text": "Longer test again: 01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123", + "Type": "Deliver", + "Coding": "Default_No_Compression", + "DateTime": datetime.datetime(2024, 3, 25, 19, 53, 56), + "SMSCDateTime": datetime.datetime(2024, 3, 25, 19, 54, 6), + "DeliveryStatus": 0, + "ReplyViaSameSMSC": 0, + "State": "UnRead", + "Class": -1, + "MessageReference": 0, + "ReplaceMessage": 0, + "RejectDuplicates": 0, + "Length": 153, + } +] + +NEXT_SMS_MULTIPLE_2 = [ + { + "SMSC": { + "Location": 0, + "Name": "", + "Format": "Text", + "Validity": "NA", + "Number": "+358444111111", + "DefaultNumber": "", + }, + "UDH": { + "Type": "ConcatenatedMessages", + "Text": b"\x05\x00\x03\x00\x02\x02", + "ID8bit": 0, + "ID16bit": -1, + "PartNumber": 2, + "AllParts": 2, + }, + "Folder": 1, + "InboxFolder": 1, + "Memory": "SM", + "Location": 2, + "Name": "", + "Number": "+358444222222", + "Text": "4567890123456789012345678901", + "Type": "Deliver", + "Coding": "Default_No_Compression", + "DateTime": datetime.datetime(2024, 3, 25, 19, 53, 56), + "SMSCDateTime": datetime.datetime(2024, 3, 25, 19, 54, 7), + "DeliveryStatus": 0, + "ReplyViaSameSMSC": 0, + "State": "UnRead", + "Class": -1, + "MessageReference": 0, + "ReplaceMessage": 0, + "RejectDuplicates": 0, + "Length": 28, + } +] diff --git a/tests/components/sms/test_gateway.py b/tests/components/sms/test_gateway.py new file mode 100644 index 00000000000..132ba9bc1f3 --- /dev/null +++ b/tests/components/sms/test_gateway.py @@ -0,0 +1,52 @@ +"""Test the SMS Gateway.""" + +from unittest.mock import MagicMock + +from homeassistant.components.sms.gateway import Gateway +from homeassistant.core import HomeAssistant + +from .const import ( + NEXT_SMS_MULTIPLE_1, + NEXT_SMS_MULTIPLE_2, + NEXT_SMS_SINGLE, + SMS_STATUS_MULTIPLE, + SMS_STATUS_SINGLE, +) + + +async def test_get_and_delete_all_sms_single_message(hass: HomeAssistant) -> None: + """Test that a single message produces a list of entries containing the single message.""" + + # Mock the Gammu state_machine + state_machine = MagicMock() + state_machine.GetSMSStatus = MagicMock(return_value=SMS_STATUS_SINGLE) + state_machine.GetNextSMS = MagicMock(return_value=NEXT_SMS_SINGLE) + state_machine.DeleteSMS = MagicMock() + + response = Gateway({"Connection": None}, hass).get_and_delete_all_sms(state_machine) + + # Assert the length of the list + assert len(response) == 1 + assert len(response[0]) == 1 + + # Assert the content of the message + assert response[0][0]["Text"] == "Short message" + + +async def test_get_and_delete_all_sms_two_part_message(hass: HomeAssistant) -> None: + """Test that a two-part message produces a list of entries containing one combined message.""" + + state_machine = MagicMock() + state_machine.GetSMSStatus = MagicMock(return_value=SMS_STATUS_MULTIPLE) + state_machine.GetNextSMS = MagicMock( + side_effect=iter([NEXT_SMS_MULTIPLE_1, NEXT_SMS_MULTIPLE_2]) + ) + state_machine.DeleteSMS = MagicMock() + + response = Gateway({"Connection": None}, hass).get_and_delete_all_sms(state_machine) + + assert len(response) == 1 + assert len(response[0]) == 2 + + assert response[0][0]["Text"] == NEXT_SMS_MULTIPLE_1[0]["Text"] + assert response[0][1]["Text"] == NEXT_SMS_MULTIPLE_2[0]["Text"] diff --git a/tests/components/snooz/test_config_flow.py b/tests/components/snooz/test_config_flow.py index 209bd50512a..4ed4d6184a7 100644 --- a/tests/components/snooz/test_config_flow.py +++ b/tests/components/snooz/test_config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import Event +from asyncio import Event, sleep from unittest.mock import patch from homeassistant import config_entries @@ -298,9 +298,16 @@ async def _test_pairs( async def _test_pairs_timeout( hass: HomeAssistant, flow_id: str, user_input: dict | None = None ) -> str: + async def _async_process_advertisements( + _hass, _callback, _matcher, _mode, _timeout + ): + """Simulate a timeout waiting for pairing mode.""" + await sleep(0) + raise TimeoutError + with patch( "homeassistant.components.snooz.config_flow.async_process_advertisements", - side_effect=TimeoutError(), + _async_process_advertisements, ): result = await hass.config_entries.flow.async_configure( flow_id, user_input=user_input or {} diff --git a/tests/components/songpal/__init__.py b/tests/components/songpal/__init__.py index 6ebc2ec5ef4..ab585c5a6d5 100644 --- a/tests/components/songpal/__init__.py +++ b/tests/components/songpal/__init__.py @@ -85,6 +85,24 @@ def _create_mocked_device(throw_exception=False, wired_mac=MAC, wireless_mac=Non input2.active = True type(mocked_device).get_inputs = AsyncMock(return_value=[input1, input2]) + sound_mode1 = MagicMock() + sound_mode1.title = "Sound Mode 1" + sound_mode1.value = "sound_mode1" + sound_mode1.isAvailable = True + sound_mode2 = MagicMock() + sound_mode2.title = "Sound Mode 2" + sound_mode2.value = "sound_mode2" + sound_mode2.isAvailable = True + sound_mode3 = MagicMock() + sound_mode3.title = "Sound Mode 3" + sound_mode3.value = "sound_mode3" + sound_mode3.isAvailable = False + + soundField = MagicMock() + soundField.currentValue = "sound_mode2" + soundField.candidate = [sound_mode1, sound_mode2, sound_mode3] + type(mocked_device).get_sound_settings = AsyncMock(return_value=[soundField]) + type(mocked_device).set_power = AsyncMock() type(mocked_device).set_sound_settings = AsyncMock() type(mocked_device).listen_notifications = AsyncMock() diff --git a/tests/components/songpal/test_media_player.py b/tests/components/songpal/test_media_player.py index 4b1abf8709e..88443bf58b9 100644 --- a/tests/components/songpal/test_media_player.py +++ b/tests/components/songpal/test_media_player.py @@ -12,6 +12,7 @@ from songpal import ( SongpalException, VolumeChange, ) +from songpal.notification import SettingChange from homeassistant.components import media_player, songpal from homeassistant.components.media_player import MediaPlayerEntityFeature @@ -47,6 +48,7 @@ SUPPORT_SONGPAL = ( | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SELECT_SOUND_MODE | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF ) @@ -138,6 +140,8 @@ async def test_state(hass: HomeAssistant) -> None: assert attributes["is_volume_muted"] is False assert attributes["source_list"] == ["title1", "title2"] assert attributes["source"] == "title2" + assert attributes["sound_mode_list"] == ["Sound Mode 1", "Sound Mode 2"] + assert attributes["sound_mode"] == "Sound Mode 2" assert attributes["supported_features"] == SUPPORT_SONGPAL device_registry = dr.async_get(hass) @@ -171,6 +175,8 @@ async def test_state_wireless(hass: HomeAssistant) -> None: assert attributes["is_volume_muted"] is False assert attributes["source_list"] == ["title1", "title2"] assert attributes["source"] == "title2" + assert attributes["sound_mode_list"] == ["Sound Mode 1", "Sound Mode 2"] + assert attributes["sound_mode"] == "Sound Mode 2" assert attributes["supported_features"] == SUPPORT_SONGPAL device_registry = dr.async_get(hass) @@ -206,6 +212,8 @@ async def test_state_both(hass: HomeAssistant) -> None: assert attributes["is_volume_muted"] is False assert attributes["source_list"] == ["title1", "title2"] assert attributes["source"] == "title2" + assert attributes["sound_mode_list"] == ["Sound Mode 1", "Sound Mode 2"] + assert attributes["sound_mode"] == "Sound Mode 2" assert attributes["supported_features"] == SUPPORT_SONGPAL device_registry = dr.async_get(hass) @@ -303,6 +311,9 @@ async def test_services(hass: HomeAssistant) -> None: mocked_device2.set_sound_settings.assert_called_once_with("name", "value") mocked_device3.set_sound_settings.assert_called_once_with("name", "value") + await _call(hass, media_player.SERVICE_SELECT_SOUND_MODE, sound_mode="Sound Mode 1") + mocked_device.set_sound_settings.assert_called_with("soundField", "sound_mode1") + async def test_websocket_events(hass: HomeAssistant) -> None: """Test websocket events.""" @@ -315,7 +326,7 @@ async def test_websocket_events(hass: HomeAssistant) -> None: await hass.async_block_till_done() mocked_device.listen_notifications.assert_called_once() - assert mocked_device.on_notification.call_count == 4 + assert mocked_device.on_notification.call_count == 5 notification_callbacks = mocked_device.notification_callbacks @@ -336,6 +347,15 @@ async def test_websocket_events(hass: HomeAssistant) -> None: await notification_callbacks[ContentChange](content_change) assert _get_attributes(hass)["source"] == "title1" + sound_mode_change = MagicMock() + sound_mode_change.target = "soundField" + sound_mode_change.currentValue = "sound_mode1" + await notification_callbacks[SettingChange](sound_mode_change) + assert _get_attributes(hass)["sound_mode"] == "Sound Mode 1" + sound_mode_change.currentValue = "sound_mode2" + await notification_callbacks[SettingChange](sound_mode_change) + assert _get_attributes(hass)["sound_mode"] == "Sound Mode 2" + power_change = MagicMock() power_change.status = False await notification_callbacks[PowerChange](power_change) diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index fad04d341c9..81142e40901 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -757,7 +757,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '77', }) # --- # name: test_sensors[sensor.test_battery_level-statealt] @@ -770,7 +770,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '77', }) # --- # name: test_sensors[sensor.test_battery_range-entry] @@ -816,7 +816,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '266.87', }) # --- # name: test_sensors[sensor.test_battery_range-statealt] @@ -829,7 +829,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '266.87', }) # --- # name: test_sensors[sensor.test_charge_cable-entry] @@ -875,7 +875,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'IEC', }) # --- # name: test_sensors[sensor.test_charge_cable-statealt] @@ -888,7 +888,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'IEC', }) # --- # name: test_sensors[sensor.test_charge_energy_added-entry] @@ -934,7 +934,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_charge_energy_added-statealt] @@ -947,7 +947,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_charge_rate-entry] @@ -993,7 +993,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_charge_rate-statealt] @@ -1006,7 +1006,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_charger_current-entry] @@ -1052,7 +1052,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_charger_current-statealt] @@ -1065,7 +1065,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_charger_power-entry] @@ -1111,7 +1111,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_charger_power-statealt] @@ -1124,7 +1124,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_charger_voltage-entry] @@ -1170,7 +1170,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2', }) # --- # name: test_sensors[sensor.test_charger_voltage-statealt] @@ -1183,7 +1183,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2', }) # --- # name: test_sensors[sensor.test_charging-entry] @@ -1229,7 +1229,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'Stopped', }) # --- # name: test_sensors[sensor.test_charging-statealt] @@ -1242,7 +1242,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'Stopped', }) # --- # name: test_sensors[sensor.test_distance_to_arrival-entry] @@ -1288,7 +1288,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0.039491', }) # --- # name: test_sensors[sensor.test_distance_to_arrival-statealt] @@ -1301,7 +1301,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_driver_temperature_setting-entry] @@ -1347,7 +1347,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '22', }) # --- # name: test_sensors[sensor.test_driver_temperature_setting-statealt] @@ -1360,7 +1360,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '22', }) # --- # name: test_sensors[sensor.test_estimate_battery_range-entry] @@ -1406,7 +1406,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '275.04', }) # --- # name: test_sensors[sensor.test_estimate_battery_range-statealt] @@ -1419,7 +1419,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '275.04', }) # --- # name: test_sensors[sensor.test_fast_charger_type-entry] @@ -1465,7 +1465,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'ACSingleWireCAN', }) # --- # name: test_sensors[sensor.test_fast_charger_type-statealt] @@ -1478,7 +1478,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'ACSingleWireCAN', }) # --- # name: test_sensors[sensor.test_ideal_battery_range-entry] @@ -1524,7 +1524,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '266.87', }) # --- # name: test_sensors[sensor.test_ideal_battery_range-statealt] @@ -1537,7 +1537,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '266.87', }) # --- # name: test_sensors[sensor.test_inside_temperature-entry] @@ -1583,7 +1583,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '29.8', }) # --- # name: test_sensors[sensor.test_inside_temperature-statealt] @@ -1596,7 +1596,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '29.8', }) # --- # name: test_sensors[sensor.test_odometer-entry] @@ -1642,7 +1642,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '6481.019282', }) # --- # name: test_sensors[sensor.test_odometer-statealt] @@ -1655,7 +1655,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '6481.019282', }) # --- # name: test_sensors[sensor.test_outside_temperature-entry] @@ -1701,7 +1701,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '30', }) # --- # name: test_sensors[sensor.test_outside_temperature-statealt] @@ -1714,7 +1714,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '30', }) # --- # name: test_sensors[sensor.test_passenger_temperature_setting-entry] @@ -1760,7 +1760,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '22', }) # --- # name: test_sensors[sensor.test_passenger_temperature_setting-statealt] @@ -1773,7 +1773,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '22', }) # --- # name: test_sensors[sensor.test_power-entry] @@ -1819,7 +1819,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '-7', }) # --- # name: test_sensors[sensor.test_power-statealt] @@ -1832,7 +1832,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '-7', }) # --- # name: test_sensors[sensor.test_shift_state-entry] @@ -2177,7 +2177,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2.775', }) # --- # name: test_sensors[sensor.test_tire_pressure_front_left-statealt] @@ -2190,7 +2190,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2.775', }) # --- # name: test_sensors[sensor.test_tire_pressure_front_right-entry] @@ -2236,7 +2236,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2.8', }) # --- # name: test_sensors[sensor.test_tire_pressure_front_right-statealt] @@ -2249,7 +2249,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2.8', }) # --- # name: test_sensors[sensor.test_tire_pressure_rear_left-entry] @@ -2295,7 +2295,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2.775', }) # --- # name: test_sensors[sensor.test_tire_pressure_rear_left-statealt] @@ -2308,7 +2308,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2.775', }) # --- # name: test_sensors[sensor.test_tire_pressure_rear_right-entry] @@ -2354,7 +2354,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2.775', }) # --- # name: test_sensors[sensor.test_tire_pressure_rear_right-statealt] @@ -2367,7 +2367,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2.775', }) # --- # name: test_sensors[sensor.test_traffic_delay-entry] @@ -2413,7 +2413,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_traffic_delay-statealt] @@ -2426,7 +2426,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '0', }) # --- # name: test_sensors[sensor.test_usable_battery_level-entry] @@ -2472,7 +2472,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '77', }) # --- # name: test_sensors[sensor.test_usable_battery_level-statealt] @@ -2485,7 +2485,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '77', }) # --- # name: test_sensors[sensor.wall_connector_fault_state_code-entry] diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 2bd76accfdd..655d8adf1ea 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -204,7 +204,7 @@ async def test_return_response_error(hass: HomeAssistant, websocket_client) -> N assert msg["id"] == 8 assert msg["type"] == const.TYPE_RESULT assert not msg["success"] - assert msg["error"]["code"] == "unknown_error" + assert msg["error"]["code"] == "service_validation_error" @pytest.mark.parametrize("command", ["call_service", "call_service_action"]) diff --git a/tests/components/websocket_api/test_decorators.py b/tests/components/websocket_api/test_decorators.py index 3e9c13a8b15..0ade5329190 100644 --- a/tests/components/websocket_api/test_decorators.py +++ b/tests/components/websocket_api/test_decorators.py @@ -1,5 +1,7 @@ """Test decorators.""" +import voluptuous as vol + from homeassistant.components import http, websocket_api from homeassistant.core import HomeAssistant @@ -31,9 +33,16 @@ async def test_async_response_request_context( def get_request(hass, connection, msg): handle_request(http.current_request.get(), connection, msg) + @websocket_api.websocket_command( + {"type": "test-get-request-with-arg", vol.Required("arg"): str} + ) + def get_with_arg_request(hass, connection, msg): + handle_request(http.current_request.get(), connection, msg) + websocket_api.async_register_command(hass, executor_get_request) websocket_api.async_register_command(hass, async_get_request) websocket_api.async_register_command(hass, get_request) + websocket_api.async_register_command(hass, get_with_arg_request) await websocket_client.send_json( { @@ -71,6 +80,65 @@ async def test_async_response_request_context( assert not msg["success"] assert msg["error"]["code"] == "not_found" + await websocket_client.send_json( + { + "id": 8, + "type": "test-get-request-with-arg", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 8 + assert not msg["success"] + assert msg["error"]["code"] == "invalid_format" + assert ( + msg["error"]["message"] == "required key not provided @ data['arg']. Got None" + ) + + await websocket_client.send_json( + { + "id": 9, + "type": "test-get-request-with-arg", + "arg": "dog", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 9 + assert msg["success"] + assert msg["result"] == "/api/websocket" + + await websocket_client.send_json( + { + "id": -1, + "type": "test-get-request-with-arg", + "arg": "dog", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == -1 + assert not msg["success"] + assert msg["error"]["code"] == "invalid_format" + assert msg["error"]["message"] == "Message incorrectly formatted." + + await websocket_client.send_json( + { + "id": 10, + "type": "test-get-request", + "not_valid": "dog", + } + ) + + msg = await websocket_client.receive_json() + assert msg["id"] == 10 + assert not msg["success"] + assert msg["error"]["code"] == "invalid_format" + assert msg["error"]["message"] == ( + "extra keys not allowed. " + "Got {'id': 10, 'type': 'test-get-request', 'not_valid': 'dog'}" + ) + async def test_supervisor_only(hass: HomeAssistant, websocket_client) -> None: """Test that only the Supervisor can make requests.""" diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index c5cfba18569..338d1511fc3 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -650,20 +650,25 @@ async def test_update_entity_delay( assert len(client.async_send_command.call_args_list) == 2 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) + + nodes: set[int] = set() assert len(client.async_send_command.call_args_list) == 3 args = client.async_send_command.call_args_list[2][0][0] assert args["command"] == "controller.get_available_firmware_updates" - assert args["nodeId"] == ge_in_wall_dimmer_switch.node_id + nodes.add(args["nodeId"]) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert len(client.async_send_command.call_args_list) == 4 args = client.async_send_command.call_args_list[3][0][0] assert args["command"] == "controller.get_available_firmware_updates" - assert args["nodeId"] == zen_31.node_id + nodes.add(args["nodeId"]) + + assert len(nodes) == 2 + assert nodes == {ge_in_wall_dimmer_switch.node_id, zen_31.node_id} async def test_update_entity_partial_restore_data( diff --git a/tests/fixtures/non_packaged_scripts/alexa_locales.txt b/tests/fixtures/non_packaged_scripts/alexa_locales.txt new file mode 100644 index 00000000000..beb9c8dbc7e --- /dev/null +++ b/tests/fixtures/non_packaged_scripts/alexa_locales.txt @@ -0,0 +1,650 @@ +

List of Alexa Interfaces and Supported Languages

+ + +
+ + + + + +

Implement the Alexa interfaces to build automotive skills, music, radio, and podcast skills, smart home skills, and video skills. Alexa interfaces use the pre-built voice interaction model.

+ +

You can use these interfaces with Alexa Voice Service (AVS) Built-in and Alexa Connect Kit (ACK) enabled devices, also. For more details, see Smart Home Development Options.

+ +

Alexa interfaces

+ +

The following table shows the interfaces that you can implement in your Alexa skills. Follow the link to each interface for full details, including the supported capabilities and example customer utterances.

+ + + + +
Interface + Version + Primary skill type + Supported languages + +
+

Alexa.ApplicationStateReporter

+
+

1.0

+
+

AVS

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.Audio.PlayQueue

+
+

1.0

+
+

Music, Radio, Podcast

+
+

en-US, es-US

+ +
+

Alexa.AuthorizationController

+
+

1.0

+
+

Automotive

+
+

en-CA, en-US, es-MX, es-US, fr-CA

+ +
+

Alexa.AutomationManagement

+
+

1.0

+
+

Smart Home Energy

+
+

en-US

+ +
+

Alexa.Automotive.VehicleData

+
+

1.0

+
+

Automotive

+
+

en-CA, en-US, es-MX, es-US, fr-CA

+ +
+

Alexa.BrightnessController

+
+

3

+
+

Smart Home

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX,es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.Camera.LiveViewController

+
+

1.7

+
+

AVS

+
+

en-US

+ +
+

Alexa.CameraStreamController

+
+

3

+
+

Smart Home Security

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.ChannelController

+
+

3

+
+

Smart Home Entertainment,
+Video

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.ColorController

+
+

3

+
+

Smart Home

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.ColorTemperatureController

+
+

3

+
+

Smart Home

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.Commissionable

+
+

1.0

+
+

Smart Home

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.ConsentManagement.ConsentRequiredReporter

+
+

1.0

+
+

Smart Home

+
+

ja-JP

+ +
+

Alexa.ContactSensor

+
+

3

+
+

Smart Home Security

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.Cooking

+
+

3

+
+

Smart Home Cooking

+
+

en-US

+ +
+

Alexa.Cooking.FoodTemperatureController

+
+

3

+
+

Smart Home Cooking

+
+

en-US

+ +
+

Alexa.Cooking.FoodTemperatureSensor

+
+

3

+
+

Smart Home Cooking

+
+

en-US

+ +
+

Alexa.Cooking.PresetController

+
+

3

+
+

Smart Home Cooking

+
+

en-US

+ +
+

Alexa.Cooking.TemperatureController

+
+

3

+
+

Smart Home Cooking

+
+

en-US

+ +
+

Alexa.Cooking.TemperatureSensor

+
+

3

+
+

Smart Home Cooking

+
+

en-US

+ +
+

Alexa.Cooking.TimeController

+
+

3

+
+

Smart Home Cooking

+
+

en-US

+ +
+

Alexa.DataController

+
+

1.0

+
+

Smart Home

+
+

en-US

+ +
+

Alexa.DeviceUsage.Estimation

+
+

1.0

+
+

Smart Home Energy

+
+

en-US

+ +
+

Alexa.DeviceUsage.Meter

+
+

1.0

+
+

Smart Home Energy

+
+

en-US

+ +
+

Alexa.DoorbellEventSource

+
+

3

+
+

Smart Home Security

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.EndpointHealth

+
+

3.1

+
+

Smart Home

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.EqualizerController

+
+

3

+
+

Smart Home Entertainment

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.InputController

+
+

3

+
+

Smart Home Entertainment,
+Video, AVS

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.InventoryLevelSensor

+
+

3

+
+

Smart Home

+
+

de-DE, en-CA, en-GB, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, it-IT, ja-JP

+ +
+

Alexa.InventoryLevelUsageSensor

+
+

3

+
+

Smart Home

+
+

de-DE, en-CA, en-GB, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, it-IT, ja-JP

+ +
+

Alexa.InventoryUsageSensor

+
+

3

+
+

Smart Home

+
+

de-DE, en-CA, en-GB, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, it-IT, ja-JP

+ +
+

Alexa.KeypadController

+
+

3

+
+

Video

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.Launcher

+
+

3

+
+

Video

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.LockController

+
+

3

+
+

Smart Home Security

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.Media.Playback

+
+

1.0

+
+

Music, Radio, Podcast

+
+

en-US, es-US

+ +
+

Alexa.Media.PlayQueue

+
+

1.0

+
+

Music, Radio, Podcast

+
+

en-US, es-US

+ +
+

Alexa.Media.Search

+
+

1.0

+
+

Music, Radio, Podcast

+
+

en-US, es-US

+ +
+

Alexa.ModeController

+
+

3

+
+

Smart Home, AVS

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.MotionSensor

+
+

3

+
+

Smart Home Security

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.PercentageController

+
+

3

+
+

Smart Home

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.PlaybackController

+
+

3

+
+

Smart Home Entertainment,
+Video

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.PlaybackStateReporter

+
+

3

+
+

Smart Home Entertainment,
+Video

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.PowerController

+
+

3

+
+

Smart Home,
+Video, AVS

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.PowerLevelController

+
+

3

+
+

Smart Home

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, fr-CA, fr-FR, it-IT, ja-JP

+ +
+

Alexa.ProactiveNotificationSource

+
+

3.0

+
+

Smart Home

+
+

Notifications for device state: de-DE, en-CA, en-GB, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, it-IT
+Notifications for cooking: en-US

+ +
+

Alexa.RangeController

+
+

3

+
+

Smart Home, AVS

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.RecordController

+
+

3

+
+

Video

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.RemoteVideoPlayer

+
+

3.1

+
+

Video

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.RTCSessionController

+
+

3

+
+

Smart Home Security

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-US, es-ES, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.SceneController

+
+

3

+
+

Smart Home

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.SecurityPanelController

+
+

3

+
+

Smart Home Security

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, it-IT, ja-JP, pt-BR

+ +
+

Alexa.SecurityPanelController.Alert

+
+

1.1

+
+

Smart Home Security

+
+

de-DE, en-CA, en-GB, en-US, es-US, fr-CA, fr-FR

+ +
+

Alexa.SeekController

+
+

3

+
+

Video

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.SimpleEventSource

+
+

1.0

+
+

Smart Home

+
+

en-US

+ +
+

Alexa.SmartVision.ObjectDetectionSensor

+
+

1.0

+
+

Smart Home Security

+
+

en-US

+ +
+

Alexa.Speaker

+
+

3

+
+

Smart Home Entertainment,
+Video

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, it-IT, ja-JP

+ +
+

Alexa.StepSpeaker

+
+

3

+
+

Smart Home Entertainment,
+Video

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, it-IT

+ +
+

Alexa.TemperatureSensor

+
+

3

+
+

Smart Home

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.ThermostatController

+
+

3.1

+
+

Smart Home

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.ThermostatController.Configuration

+
+

3

+
+

Smart Home

+
+

en-US

+ +
+

Alexa.ThermostatController.HVAC.Components

+
+

1.0

+
+

Smart Home Energy

+
+

en-US

+ +
+

Alexa.ThermostatController.Schedule

+
+

3.2

+
+

Smart Home

+
+

en-US

+ +
+

Alexa.TimeHoldController

+
+

3

+
+

Smart Home Cooking

+
+

en-US

+ +
+

Alexa.ToggleController

+
+

3

+
+

Smart Home, AVS

+
+

de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.UIController

+
+

3.0

+
+

Video

+
+

en-US

+ +
+

Alexa.UserPreference

+
+

2.0

+
+

Music, Radio, Podcast

+
+

en-US, es-US

+ +
+

Alexa.VideoRecorder

+
+

3

+
+

Video

+
+

ar-SA, de-DE, en-AU, en-CA, en-GB, en-IN, en-US, es-ES, es-MX, es-US, fr-CA, fr-FR, hi-IN, it-IT, ja-JP, pt-BR

+ +
+

Alexa.WakeOnLANController

+
+

3

+
+

Smart Home Entertainment

+
+

de-DE, en-AU, en-IN, en-US, es-ES, it-IT, ja-JP

+ +
+ + + + diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index a4235d1ee2c..07228abcc2c 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -4804,3 +4804,18 @@ async def test_async_track_device_registry_updated_event_with_a_callback_that_th unsub2() assert event_data[0] == {"action": "create", "device_id": device_id} + + +async def test_track_state_change_deprecated( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test track_state_change is deprecated.""" + async_track_state_change( + hass, "light.Bowl", lambda entity_id, old_state, new_state: None, "on", "off" + ) + + assert ( + "Detected code that calls `async_track_state_change` instead " + "of `async_track_state_change_event` which is deprecated and " + "will be removed in Home Assistant 2025.5. Please report this issue." + ) in caplog.text diff --git a/tests/helpers/test_icon.py b/tests/helpers/test_icon.py index e986a07d7d5..5ad5071266b 100644 --- a/tests/helpers/test_icon.py +++ b/tests/helpers/test_icon.py @@ -106,8 +106,8 @@ async def test_get_icons(hass: HomeAssistant) -> None: # Ensure icons file for platform isn't loaded, as that isn't supported icons = await icon.async_get_icons(hass, "entity") assert icons == {} - icons = await icon.async_get_icons(hass, "entity", ["test.switch"]) - assert icons == {} + with pytest.raises(ValueError, match="test.switch"): + await icon.async_get_icons(hass, "entity", ["test.switch"]) # Load up an custom integration hass.config.components.add("test_package") diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index b5e71f4c9d8..e32768ee33e 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1852,3 +1852,139 @@ async def test_async_extract_config_entry_ids(hass: HomeAssistant) -> None: ) assert await service.async_extract_config_entry_ids(hass, call) == {"abc"} + + +async def test_reload_service_helper(hass: HomeAssistant) -> None: + """Test the reload service helper.""" + + active_reload_calls = 0 + reloaded = [] + + async def reload_service_handler(service_call: ServiceCall) -> None: + """Remove all automations and load new ones from config.""" + nonlocal active_reload_calls + # Assert the reload helper prevents parallel reloads + assert not active_reload_calls + active_reload_calls += 1 + if not (target := service_call.data.get("target")): + reloaded.append("all") + else: + reloaded.append(target) + await asyncio.sleep(0.01) + active_reload_calls -= 1 + + def reload_targets(service_call: ServiceCall) -> set[str | None]: + if target_id := service_call.data.get("target"): + return {target_id} + return {"target1", "target2", "target3", "target4"} + + # Test redundant reload of single targets + reloader = service.ReloadServiceHelper(reload_service_handler, reload_targets) + tasks = [ + # This reload task will start executing first, (target1) + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + # These reload tasks will be deduplicated to (target2, target3, target4, target1) + # while the first task is reloaded, note that target1 can't be deduplicated + # because it's already being reloaded. + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + ] + await asyncio.gather(*tasks) + assert reloaded == unordered( + ["target1", "target2", "target3", "target4", "target1"] + ) + + # Test redundant reload of multiple targets + single target + reloaded.clear() + tasks = [ + # This reload task will start executing first, (target1) + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + # These reload tasks will be deduplicated to (target2, target3, target4, all) + # while the first task is reloaded. + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service(ServiceCall("test", "test")), + ] + await asyncio.gather(*tasks) + assert reloaded == unordered(["target1", "target2", "target3", "target4", "all"]) + + # Test redundant reload of multiple targets + single target + reloaded.clear() + tasks = [ + # This reload task will start executing first, (all) + reloader.execute_service(ServiceCall("test", "test")), + # These reload tasks will be deduplicated to (target1, target2, target3, target4) + # while the first task is reloaded. + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + ] + await asyncio.gather(*tasks) + assert reloaded == unordered(["all", "target1", "target2", "target3", "target4"]) + + # Test redundant reload of single targets + reloaded.clear() + tasks = [ + # This reload task will start executing first, (target1) + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + # These reload tasks will be deduplicated to (target2, target3, target4, target1) + # while the first task is reloaded, note that target1 can't be deduplicated + # because it's already being reloaded. + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + ] + await asyncio.gather(*tasks) + assert reloaded == unordered( + ["target1", "target2", "target3", "target4", "target1"] + ) + + # Test redundant reload of multiple targets + single target + reloaded.clear() + tasks = [ + # This reload task will start executing first, (target1) + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + # These reload tasks will be deduplicated to (target2, target3, target4, all) + # while the first task is reloaded. + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service(ServiceCall("test", "test")), + reloader.execute_service(ServiceCall("test", "test")), + ] + await asyncio.gather(*tasks) + assert reloaded == unordered(["target1", "target2", "target3", "target4", "all"]) + + # Test redundant reload of multiple targets + single target + reloaded.clear() + tasks = [ + # This reload task will start executing first, (all) + reloader.execute_service(ServiceCall("test", "test")), + # These reload tasks will be deduplicated to (target1, target2, target3, target4) + # while the first task is reloaded. + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target1"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target2"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target3"})), + reloader.execute_service(ServiceCall("test", "test", {"target": "target4"})), + ] + await asyncio.gather(*tasks) + assert reloaded == unordered(["all", "target1", "target2", "target3", "target4"]) diff --git a/tests/non_packaged_scripts/__init__.py b/tests/non_packaged_scripts/__init__.py new file mode 100644 index 00000000000..852c52a8293 --- /dev/null +++ b/tests/non_packaged_scripts/__init__.py @@ -0,0 +1 @@ +"""Tests for the non-packaged scripts in the script directory.""" diff --git a/tests/non_packaged_scripts/snapshots/test_alexa_locales.ambr b/tests/non_packaged_scripts/snapshots/test_alexa_locales.ambr new file mode 100644 index 00000000000..bad47eedf53 --- /dev/null +++ b/tests/non_packaged_scripts/snapshots/test_alexa_locales.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_alexa_locales + ''' + Missing interfaces: + ['Alexa.ApplicationStateReporter', + 'Alexa.AuthorizationController', + 'Alexa.AutomationManagement', + 'Alexa.Commissionable', + 'Alexa.Cooking', + 'Alexa.DataController', + 'Alexa.InventoryLevelSensor', + 'Alexa.InventoryLevelUsageSensor', + 'Alexa.InventoryUsageSensor', + 'Alexa.KeypadController', + 'Alexa.Launcher', + 'Alexa.PercentageController', + 'Alexa.ProactiveNotificationSource', + 'Alexa.RecordController', + 'Alexa.RemoteVideoPlayer', + 'Alexa.RTCSessionController', + 'Alexa.SimpleEventSource', + 'Alexa.UIController', + 'Alexa.UserPreference', + 'Alexa.VideoRecorder', + 'Alexa.WakeOnLANController'] + + + Interfaces where upstream locales are not subsets of the core locales: + [] + + + Interfaces checked ok: + ['AlexaBrightnessController', + 'AlexaCameraStreamController', + 'AlexaChannelController', + 'AlexaColorController', + 'AlexaColorTemperatureController', + 'AlexaContactSensor', + 'AlexaDoorbellEventSource', + 'AlexaEndpointHealth', + 'AlexaEqualizerController', + 'AlexaInputController', + 'AlexaLockController', + 'AlexaModeController', + 'AlexaMotionSensor', + 'AlexaPlaybackController', + 'AlexaPlaybackStateReporter', + 'AlexaPowerController', + 'AlexaPowerLevelController', + 'AlexaRangeController', + 'AlexaSceneController', + 'AlexaSecurityPanelController', + 'AlexaSeekController', + 'AlexaSpeaker', + 'AlexaStepSpeaker', + 'AlexaTemperatureSensor', + 'AlexaThermostatController', + 'AlexaTimeHoldController', + 'AlexaToggleController'] + + ''' +# --- diff --git a/tests/non_packaged_scripts/test_alexa_locales.py b/tests/non_packaged_scripts/test_alexa_locales.py new file mode 100644 index 00000000000..ea139f7de8e --- /dev/null +++ b/tests/non_packaged_scripts/test_alexa_locales.py @@ -0,0 +1,29 @@ +"""Test the alexa_locales script.""" + +from pathlib import Path + +import pytest +import requests_mock +from syrupy import SnapshotAssertion + +from script.alexa_locales import SITE, run_script + + +def test_alexa_locales( + capsys: pytest.CaptureFixture[str], + requests_mock: requests_mock.Mocker, + snapshot: SnapshotAssertion, +) -> None: + """Test alexa_locales script.""" + fixture_file = ( + Path(__file__).parent.parent / "fixtures/non_packaged_scripts/alexa_locales.txt" + ) + requests_mock.get( + SITE, + text=fixture_file.read_text(encoding="utf-8"), + ) + + run_script() + + captured = capsys.readouterr() + assert captured.out == snapshot diff --git a/tests/test_core.py b/tests/test_core.py index 58738e3e52a..5d687d89833 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -55,6 +55,7 @@ from homeassistant.exceptions import ( InvalidStateError, MaxLengthExceeded, ServiceNotFound, + ServiceValidationError, ) from homeassistant.helpers.json import json_dumps from homeassistant.setup import async_setup_component @@ -1791,8 +1792,9 @@ async def test_services_call_return_response_requires_blocking( hass: HomeAssistant, ) -> None: """Test that non-blocking service calls cannot ask for response data.""" + await async_setup_component(hass, "homeassistant", {}) async_mock_service(hass, "test_domain", "test_service") - with pytest.raises(ValueError, match="when blocking=False"): + with pytest.raises(ServiceValidationError, match="blocking=False") as exc: await hass.services.async_call( "test_domain", "test_service", @@ -1800,6 +1802,10 @@ async def test_services_call_return_response_requires_blocking( blocking=False, return_response=True, ) + assert ( + str(exc.value) + == "A non blocking service call with argument blocking=False can't be used together with argument return_response=True" + ) @pytest.mark.parametrize( @@ -1816,6 +1822,7 @@ async def test_serviceregistry_return_response_invalid( hass: HomeAssistant, response_data: Any, expected_error: str ) -> None: """Test service call response data must be json serializable objects.""" + await async_setup_component(hass, "homeassistant", {}) def service_handler(call: ServiceCall) -> ServiceResponse: """Service handler coroutine.""" @@ -1842,8 +1849,8 @@ async def test_serviceregistry_return_response_invalid( @pytest.mark.parametrize( ("supports_response", "return_response", "expected_error"), [ - (SupportsResponse.NONE, True, "not support responses"), - (SupportsResponse.ONLY, False, "caller did not ask for responses"), + (SupportsResponse.NONE, True, "does not return responses"), + (SupportsResponse.ONLY, False, "call requires responses"), ], ) async def test_serviceregistry_return_response_arguments( @@ -1853,6 +1860,7 @@ async def test_serviceregistry_return_response_arguments( expected_error: str, ) -> None: """Test service call response data invalid arguments.""" + await async_setup_component(hass, "homeassistant", {}) hass.services.async_register( "test_domain", @@ -1861,7 +1869,7 @@ async def test_serviceregistry_return_response_arguments( supports_response=supports_response, ) - with pytest.raises(ValueError, match=expected_error): + with pytest.raises(ServiceValidationError, match=expected_error): await hass.services.async_call( "test_domain", "test_service", @@ -3411,3 +3419,20 @@ async def test_async_listen_with_run_immediately_deprecated( f"Detected code that calls `{method}` with run_immediately, which is " "deprecated and will be removed in Home Assistant 2025.5." ) in caplog.text + + +async def test_top_level_components(hass: HomeAssistant) -> None: + """Test top level components are updated when components change.""" + hass.config.components.add("homeassistant") + assert hass.config.components == {"homeassistant"} + assert hass.config.top_level_components == {"homeassistant"} + hass.config.components.add("homeassistant.scene") + assert hass.config.components == {"homeassistant", "homeassistant.scene"} + assert hass.config.top_level_components == {"homeassistant"} + hass.config.components.remove("homeassistant") + assert hass.config.components == {"homeassistant.scene"} + assert hass.config.top_level_components == set() + with pytest.raises(ValueError): + hass.config.components.remove("homeassistant.scene") + with pytest.raises(NotImplementedError): + hass.config.components.discard("homeassistant") diff --git a/tests/test_requirements.py b/tests/test_requirements.py index ed04ef8649b..73f3f54c3c4 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -591,7 +591,7 @@ async def test_discovery_requirements_mqtt(hass: HomeAssistant) -> None: ) as mock_process: await async_get_integration_with_requirements(hass, "mqtt_comp") - assert len(mock_process.mock_calls) == 2 # mqtt also depends on http + assert len(mock_process.mock_calls) == 1 assert mock_process.mock_calls[0][1][1] == mqtt.requirements @@ -608,13 +608,12 @@ async def test_discovery_requirements_ssdp(hass: HomeAssistant) -> None: ) as mock_process: await async_get_integration_with_requirements(hass, "ssdp_comp") - assert len(mock_process.mock_calls) == 4 + assert len(mock_process.mock_calls) == 3 assert mock_process.mock_calls[0][1][1] == ssdp.requirements assert { mock_process.mock_calls[1][1][0], mock_process.mock_calls[2][1][0], - mock_process.mock_calls[3][1][0], - } == {"http", "network", "recorder"} + } == {"network", "recorder"} @pytest.mark.parametrize( @@ -638,7 +637,7 @@ async def test_discovery_requirements_zeroconf( ) as mock_process: await async_get_integration_with_requirements(hass, "comp") - assert len(mock_process.mock_calls) == 4 # zeroconf also depends on http + assert len(mock_process.mock_calls) == 3 assert mock_process.mock_calls[0][1][1] == zeroconf.requirements diff --git a/tests/test_setup.py b/tests/test_setup.py index e3d9a322862..65472643adb 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -346,8 +346,9 @@ async def test_component_base_exception_setup(hass: HomeAssistant) -> None: mock_integration(hass, MockModule("comp", setup=exception_setup)) - with pytest.raises(BaseException): + with pytest.raises(BaseException) as exc_info: await setup.async_setup_component(hass, "comp", {}) + assert str(exc_info.value) == "fail!" assert "comp" not in hass.config.components