diff --git a/.coveragerc b/.coveragerc index 3b6faacd18c263..57e1229dc1b032 100644 --- a/.coveragerc +++ b/.coveragerc @@ -286,9 +286,6 @@ omit = homeassistant/components/edl21/__init__.py homeassistant/components/edl21/sensor.py homeassistant/components/egardia/* - homeassistant/components/eight_sleep/__init__.py - homeassistant/components/eight_sleep/binary_sensor.py - homeassistant/components/eight_sleep/sensor.py homeassistant/components/electric_kiwi/__init__.py homeassistant/components/electric_kiwi/api.py homeassistant/components/electric_kiwi/oauth2.py @@ -992,6 +989,7 @@ omit = homeassistant/components/pushsafer/notify.py homeassistant/components/pyload/sensor.py homeassistant/components/qbittorrent/__init__.py + homeassistant/components/qbittorrent/coordinator.py homeassistant/components/qbittorrent/sensor.py homeassistant/components/qnap/__init__.py homeassistant/components/qnap/coordinator.py @@ -1267,6 +1265,7 @@ omit = homeassistant/components/switchbot/sensor.py homeassistant/components/switchbot/switch.py homeassistant/components/switchbot/lock.py + homeassistant/components/switchbot_cloud/climate.py homeassistant/components/switchbot_cloud/coordinator.py homeassistant/components/switchbot_cloud/entity.py homeassistant/components/switchbot_cloud/switch.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7a5c3efd1cbc4e..29f0c9ee5d865d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ env: PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 5 BLACK_CACHE_VERSION: 1 - HA_SHORT_VERSION: "2023.11" + HA_SHORT_VERSION: "2023.12" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11', '3.12']" # 10.3 is the oldest supported version diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index da7021e9df38d5..ccd2d3c1678822 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v2.22.4 + uses: github/codeql-action/init@v2.22.5 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2.22.4 + uses: github/codeql-action/analyze@v2.22.5 with: category: "/language:python" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d9cca711131e63..77b16568eb48a0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,7 +39,7 @@ repos: hooks: - id: yamllint - repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.7.1 + rev: v3.0.3 hooks: - id: prettier - repo: https://github.com/cdce8p/python-typing-update diff --git a/.strict-typing b/.strict-typing index 97e3f5778494ec..1faf190a1de797 100644 --- a/.strict-typing +++ b/.strict-typing @@ -204,6 +204,7 @@ homeassistant.components.light.* homeassistant.components.litejet.* homeassistant.components.litterrobot.* homeassistant.components.local_ip.* +homeassistant.components.local_todo.* homeassistant.components.lock.* homeassistant.components.logbook.* homeassistant.components.logger.* diff --git a/CODEOWNERS b/CODEOWNERS index 498fa45969c20f..3d70ecc77b196b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -311,16 +311,14 @@ build.json @home-assistant/supervisor /tests/components/ecobee/ @marthoc @marcolivierarsenault /homeassistant/components/ecoforest/ @pjanuario /tests/components/ecoforest/ @pjanuario -/homeassistant/components/econet/ @vangorra @w1ll1am23 -/tests/components/econet/ @vangorra @w1ll1am23 +/homeassistant/components/econet/ @w1ll1am23 +/tests/components/econet/ @w1ll1am23 /homeassistant/components/ecovacs/ @OverloadUT @mib1185 /homeassistant/components/ecowitt/ @pvizeli /tests/components/ecowitt/ @pvizeli /homeassistant/components/efergy/ @tkdrob /tests/components/efergy/ @tkdrob /homeassistant/components/egardia/ @jeroenterheerdt -/homeassistant/components/eight_sleep/ @mezz64 @raman325 -/tests/components/eight_sleep/ @mezz64 @raman325 /homeassistant/components/electrasmart/ @jafar-atili /tests/components/electrasmart/ @jafar-atili /homeassistant/components/electric_kiwi/ @mikey0000 @@ -481,6 +479,8 @@ build.json @home-assistant/supervisor /tests/components/google_mail/ @tkdrob /homeassistant/components/google_sheets/ @tkdrob /tests/components/google_sheets/ @tkdrob +/homeassistant/components/google_tasks/ @allenporter +/tests/components/google_tasks/ @allenporter /homeassistant/components/google_travel_time/ @eifinger /tests/components/google_travel_time/ @eifinger /homeassistant/components/govee_ble/ @bdraco @PierreAronnax @@ -588,6 +588,8 @@ build.json @home-assistant/supervisor /tests/components/image_upload/ @home-assistant/core /homeassistant/components/imap/ @jbouwh /tests/components/imap/ @jbouwh +/homeassistant/components/improv_ble/ @emontnemery +/tests/components/improv_ble/ @emontnemery /homeassistant/components/incomfort/ @zxdavb /homeassistant/components/influxdb/ @mdegat01 /tests/components/influxdb/ @mdegat01 @@ -710,6 +712,8 @@ build.json @home-assistant/supervisor /tests/components/local_calendar/ @allenporter /homeassistant/components/local_ip/ @issacg /tests/components/local_ip/ @issacg +/homeassistant/components/local_todo/ @allenporter +/tests/components/local_todo/ @allenporter /homeassistant/components/lock/ @home-assistant/core /tests/components/lock/ @home-assistant/core /homeassistant/components/logbook/ @home-assistant/core @@ -1499,8 +1503,8 @@ build.json @home-assistant/supervisor /tests/components/zerproc/ @emlove /homeassistant/components/zeversolar/ @kvanzuijlen /tests/components/zeversolar/ @kvanzuijlen -/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly -/tests/components/zha/ @dmulcahey @adminiuga @puddly +/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES +/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES /homeassistant/components/zodiac/ @JulienTant /tests/components/zodiac/ @JulienTant /homeassistant/components/zone/ @home-assistant/core diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 9acf46dbac61ba..4ea324878ecf6d 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -185,7 +185,9 @@ def main() -> int: ensure_config_path(config_dir) # pylint: disable-next=import-outside-toplevel - from . import runner + from . import config, runner + + safe_mode = config.safe_mode_enabled(config_dir) runtime_conf = runner.RuntimeConfig( config_dir=config_dir, @@ -198,6 +200,7 @@ def main() -> int: recovery_mode=args.recovery_mode, debug=args.debug, open_ui=args.open_ui, + safe_mode=safe_mode, ) fault_file_name = os.path.join(config_dir, FAULT_LOG_FILENAME) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 89aa5c05d0d401..098f970d55f653 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -120,6 +120,7 @@ async def async_setup_hass( runtime_config.log_no_color, ) + hass.config.safe_mode = runtime_config.safe_mode hass.config.skip_pip = runtime_config.skip_pip hass.config.skip_pip_packages = runtime_config.skip_pip_packages if runtime_config.skip_pip or runtime_config.skip_pip_packages: @@ -197,6 +198,8 @@ async def async_setup_hass( {"recovery_mode": {}, "http": http_conf}, hass, ) + elif hass.config.safe_mode: + _LOGGER.info("Starting in safe mode") if runtime_config.open_ui: hass.add_job(open_hass_ui, hass) diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index ce71457a656d1b..7c6ebc044e9cf8 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -11,6 +11,7 @@ "google_maps", "google_pubsub", "google_sheets", + "google_tasks", "google_translate", "google_travel_time", "google_wifi", diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 3a834261af5eb7..5a5a1de2a01a94 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["accuweather"], "quality_scale": "platinum", - "requirements": ["accuweather==1.0.0"] + "requirements": ["accuweather==2.0.0"] } diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index a4e0a1033ba858..8244472f2b4ba4 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -122,6 +122,13 @@ def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: if self._ac.get(ADVANTAGE_AIR_AUTOFAN): self._attr_fan_modes += [FAN_AUTO] + @property + def current_temperature(self) -> float | None: + """Return the selected zones current temperature.""" + if self._myzone: + return self._myzone["measuredTemp"] + return None + @property def target_temperature(self) -> float | None: """Return the current target temperature.""" diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index 7940ff92f726ca..c3328fc1b5d666 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -12,6 +12,18 @@ AOD_COND_RAINY, AOD_COND_SNOWY, AOD_COND_SUNNY, + AOD_CONDITION, + AOD_FORECAST_DAILY, + AOD_FORECAST_HOURLY, + AOD_PRECIPITATION, + AOD_PRECIPITATION_PROBABILITY, + AOD_TEMP, + AOD_TEMP_MAX, + AOD_TEMP_MIN, + AOD_TIMESTAMP, + AOD_WIND_DIRECTION, + AOD_WIND_SPEED, + AOD_WIND_SPEED_MAX, ) from homeassistant.components.weather import ( @@ -25,6 +37,15 @@ ATTR_CONDITION_RAINY, ATTR_CONDITION_SNOWY, ATTR_CONDITION_SUNNY, + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, ) from homeassistant.const import Platform @@ -122,3 +143,30 @@ FORECAST_MODE_DAILY: ATTR_API_FORECAST_DAILY, FORECAST_MODE_HOURLY: ATTR_API_FORECAST_HOURLY, } + +FORECAST_MAP = { + AOD_FORECAST_DAILY: { + AOD_CONDITION: ATTR_FORECAST_CONDITION, + AOD_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, + AOD_TEMP_MAX: ATTR_FORECAST_NATIVE_TEMP, + AOD_TEMP_MIN: ATTR_FORECAST_NATIVE_TEMP_LOW, + AOD_TIMESTAMP: ATTR_FORECAST_TIME, + AOD_WIND_DIRECTION: ATTR_FORECAST_WIND_BEARING, + AOD_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, + }, + AOD_FORECAST_HOURLY: { + AOD_CONDITION: ATTR_FORECAST_CONDITION, + AOD_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, + AOD_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION, + AOD_TEMP: ATTR_FORECAST_NATIVE_TEMP, + AOD_TIMESTAMP: ATTR_FORECAST_TIME, + AOD_WIND_DIRECTION: ATTR_FORECAST_WIND_BEARING, + AOD_WIND_SPEED_MAX: ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + AOD_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, + }, +} + +WEATHER_FORECAST_MODES = { + AOD_FORECAST_DAILY: "daily", + AOD_FORECAST_HOURLY: "hourly", +} diff --git a/homeassistant/components/aemet/entity.py b/homeassistant/components/aemet/entity.py new file mode 100644 index 00000000000000..527ff046104bde --- /dev/null +++ b/homeassistant/components/aemet/entity.py @@ -0,0 +1,23 @@ +"""Entity classes for the AEMET OpenData integration.""" +from __future__ import annotations + +from typing import Any + +from aemet_opendata.helpers import dict_nested_value + +from homeassistant.components.weather import Forecast +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .weather_update_coordinator import WeatherUpdateCoordinator + + +class AemetEntity(CoordinatorEntity[WeatherUpdateCoordinator]): + """Define an AEMET entity.""" + + def get_aemet_forecast(self, forecast_mode: str) -> list[Forecast]: + """Return AEMET entity forecast by mode.""" + return self.coordinator.data["forecast"][forecast_mode] + + def get_aemet_value(self, keys: list[str]) -> Any: + """Return AEMET entity value by keys.""" + return dict_nested_value(self.coordinator.data["lib"], keys) diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index 03f91a74740f8a..b7b3c31ab5bf3c 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -1,16 +1,19 @@ """Support for the AEMET OpenData service.""" -from typing import cast + +from aemet_opendata.const import ( + AOD_CONDITION, + AOD_FORECAST_DAILY, + AOD_FORECAST_HOURLY, + AOD_HUMIDITY, + AOD_PRESSURE, + AOD_TEMP, + AOD_WEATHER, + AOD_WIND_DIRECTION, + AOD_WIND_SPEED, + AOD_WIND_SPEED_MAX, +) from homeassistant.components.weather import ( - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, - ATTR_FORECAST_NATIVE_WIND_SPEED, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, DOMAIN as WEATHER_DOMAIN, Forecast, SingleCoordinatorWeatherEntity, @@ -28,55 +31,16 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - ATTR_API_CONDITION, - ATTR_API_FORECAST_CONDITION, - ATTR_API_FORECAST_PRECIPITATION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_TEMP, - ATTR_API_FORECAST_TEMP_LOW, - ATTR_API_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_MAX_SPEED, - ATTR_API_FORECAST_WIND_SPEED, - ATTR_API_HUMIDITY, - ATTR_API_PRESSURE, - ATTR_API_TEMPERATURE, - ATTR_API_WIND_BEARING, - ATTR_API_WIND_MAX_SPEED, - ATTR_API_WIND_SPEED, ATTRIBUTION, + CONDITIONS_MAP, DOMAIN, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, - FORECAST_MODE_ATTR_API, - FORECAST_MODE_DAILY, - FORECAST_MODE_HOURLY, - FORECAST_MODES, + WEATHER_FORECAST_MODES, ) +from .entity import AemetEntity from .weather_update_coordinator import WeatherUpdateCoordinator -FORECAST_MAP = { - FORECAST_MODE_DAILY: { - ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_TEMP_LOW: ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP, - ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, - }, - FORECAST_MODE_HOURLY: { - ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION, - ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_API_FORECAST_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP, - ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME, - ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING, - ATTR_API_FORECAST_WIND_MAX_SPEED: ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, - ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, - }, -} - async def async_setup_entry( hass: HomeAssistant, @@ -95,11 +59,11 @@ async def async_setup_entry( if entity_registry.async_get_entity_id( WEATHER_DOMAIN, DOMAIN, - f"{config_entry.unique_id} {FORECAST_MODE_HOURLY}", + f"{config_entry.unique_id} {WEATHER_FORECAST_MODES[AOD_FORECAST_HOURLY]}", ): - for mode in FORECAST_MODES: - name = f"{domain_data[ENTRY_NAME]} {mode}" - unique_id = f"{config_entry.unique_id} {mode}" + for mode, mode_id in WEATHER_FORECAST_MODES.items(): + name = f"{domain_data[ENTRY_NAME]} {mode_id}" + unique_id = f"{config_entry.unique_id} {mode_id}" entities.append(AemetWeather(name, unique_id, weather_coordinator, mode)) else: entities.append( @@ -107,15 +71,18 @@ async def async_setup_entry( domain_data[ENTRY_NAME], config_entry.unique_id, weather_coordinator, - FORECAST_MODE_DAILY, + AOD_FORECAST_DAILY, ) ) async_add_entities(entities, False) -class AemetWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]): - """Implementation of an AEMET OpenData sensor.""" +class AemetWeather( + AemetEntity, + SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator], +): + """Implementation of an AEMET OpenData weather.""" _attr_attribution = ATTRIBUTION _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS @@ -137,7 +104,7 @@ def __init__( super().__init__(coordinator) self._forecast_mode = forecast_mode self._attr_entity_registry_enabled_default = ( - self._forecast_mode == FORECAST_MODE_DAILY + self._forecast_mode == AOD_FORECAST_DAILY ) self._attr_name = name self._attr_unique_id = unique_id @@ -145,61 +112,50 @@ def __init__( @property def condition(self): """Return the current condition.""" - return self.coordinator.data[ATTR_API_CONDITION] - - def _forecast(self, forecast_mode: str) -> list[Forecast]: - """Return the forecast array.""" - forecasts = self.coordinator.data[FORECAST_MODE_ATTR_API[forecast_mode]] - forecast_map = FORECAST_MAP[forecast_mode] - return cast( - list[Forecast], - [ - {ha_key: forecast[api_key] for api_key, ha_key in forecast_map.items()} - for forecast in forecasts - ], - ) + cond = self.get_aemet_value([AOD_WEATHER, AOD_CONDITION]) + return CONDITIONS_MAP.get(cond) @property def forecast(self) -> list[Forecast]: """Return the forecast array.""" - return self._forecast(self._forecast_mode) + return self.get_aemet_forecast(self._forecast_mode) @callback def _async_forecast_daily(self) -> list[Forecast]: """Return the daily forecast in native units.""" - return self._forecast(FORECAST_MODE_DAILY) + return self.get_aemet_forecast(AOD_FORECAST_DAILY) @callback def _async_forecast_hourly(self) -> list[Forecast]: """Return the hourly forecast in native units.""" - return self._forecast(FORECAST_MODE_HOURLY) + return self.get_aemet_forecast(AOD_FORECAST_HOURLY) @property def humidity(self): """Return the humidity.""" - return self.coordinator.data[ATTR_API_HUMIDITY] + return self.get_aemet_value([AOD_WEATHER, AOD_HUMIDITY]) @property def native_pressure(self): """Return the pressure.""" - return self.coordinator.data[ATTR_API_PRESSURE] + return self.get_aemet_value([AOD_WEATHER, AOD_PRESSURE]) @property def native_temperature(self): """Return the temperature.""" - return self.coordinator.data[ATTR_API_TEMPERATURE] + return self.get_aemet_value([AOD_WEATHER, AOD_TEMP]) @property def wind_bearing(self): """Return the wind bearing.""" - return self.coordinator.data[ATTR_API_WIND_BEARING] + return self.get_aemet_value([AOD_WEATHER, AOD_WIND_DIRECTION]) @property def native_wind_gust_speed(self): """Return the wind gust speed in native units.""" - return self.coordinator.data[ATTR_API_WIND_MAX_SPEED] + return self.get_aemet_value([AOD_WEATHER, AOD_WIND_SPEED_MAX]) @property def native_wind_speed(self): """Return the wind speed.""" - return self.coordinator.data[ATTR_API_WIND_SPEED] + return self.get_aemet_value([AOD_WEATHER, AOD_WIND_SPEED]) diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index 01c2502fb37aa0..cd95a8e0854e5b 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -4,7 +4,7 @@ from asyncio import timeout from datetime import timedelta import logging -from typing import Any, Final +from typing import Any, Final, cast from aemet_opendata.const import ( AEMET_ATTR_DATE, @@ -31,17 +31,24 @@ AEMET_ATTR_TEMPERATURE, AEMET_ATTR_WIND, AEMET_ATTR_WIND_GUST, + AOD_CONDITION, + AOD_FORECAST, + AOD_FORECAST_DAILY, + AOD_FORECAST_HOURLY, + AOD_TOWN, ATTR_DATA, ) from aemet_opendata.exceptions import AemetError from aemet_opendata.forecast import ForecastValue from aemet_opendata.helpers import ( + dict_nested_value, get_forecast_day_value, get_forecast_hour_value, get_forecast_interval_value, ) from aemet_opendata.interface import AEMET +from homeassistant.components.weather import Forecast from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -79,6 +86,7 @@ ATTR_API_WIND_SPEED, CONDITIONS_MAP, DOMAIN, + FORECAST_MAP, ) _LOGGER = logging.getLogger(__name__) @@ -239,6 +247,12 @@ def _convert_weather_response(self, weather_response): weather_response, now ) + data = self.aemet.data() + forecasts: list[dict[str, Forecast]] = { + AOD_FORECAST_DAILY: self.aemet_forecast(data, AOD_FORECAST_DAILY), + AOD_FORECAST_HOURLY: self.aemet_forecast(data, AOD_FORECAST_HOURLY), + } + return { ATTR_API_CONDITION: condition, ATTR_API_FORECAST_DAILY: forecast_daily, @@ -261,8 +275,29 @@ def _convert_weather_response(self, weather_response): ATTR_API_WIND_BEARING: wind_bearing, ATTR_API_WIND_MAX_SPEED: wind_max_speed, ATTR_API_WIND_SPEED: wind_speed, + "forecast": forecasts, + "lib": data, } + def aemet_forecast( + self, + data: dict[str, Any], + forecast_mode: str, + ) -> list[Forecast]: + """Return the forecast array.""" + forecasts = dict_nested_value(data, [AOD_TOWN, forecast_mode, AOD_FORECAST]) + forecast_map = FORECAST_MAP[forecast_mode] + forecast_list: list[dict[str, Any]] = [] + for forecast in forecasts: + cur_forecast: dict[str, Any] = {} + for api_key, ha_key in forecast_map.items(): + value = forecast[api_key] + if api_key == AOD_CONDITION: + value = CONDITIONS_MAP.get(value) + cur_forecast[ha_key] = value + forecast_list += [cur_forecast] + return cast(list[Forecast], forecast_list) + def _get_daily_forecast_from_weather_response(self, weather_response, now): if weather_response.daily: parse = False diff --git a/homeassistant/components/airzone/binary_sensor.py b/homeassistant/components/airzone/binary_sensor.py index a472a4991c6247..cee0bb19691dcb 100644 --- a/homeassistant/components/airzone/binary_sensor.py +++ b/homeassistant/components/airzone/binary_sensor.py @@ -9,7 +9,6 @@ AZD_BATTERY_LOW, AZD_ERRORS, AZD_FLOOR_DEMAND, - AZD_NAME, AZD_PROBLEMS, AZD_SYSTEMS, AZD_ZONES, @@ -45,7 +44,6 @@ class AirzoneBinarySensorEntityDescription(BinarySensorEntityDescription): device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, key=AZD_PROBLEMS, - name="Problem", ), ) @@ -53,17 +51,16 @@ class AirzoneBinarySensorEntityDescription(BinarySensorEntityDescription): AirzoneBinarySensorEntityDescription( device_class=BinarySensorDeviceClass.RUNNING, key=AZD_AIR_DEMAND, - name="Air Demand", + translation_key="air_demand", ), AirzoneBinarySensorEntityDescription( device_class=BinarySensorDeviceClass.BATTERY, key=AZD_BATTERY_LOW, - name="Battery Low", ), AirzoneBinarySensorEntityDescription( device_class=BinarySensorDeviceClass.RUNNING, key=AZD_FLOOR_DEMAND, - name="Floor Demand", + translation_key="floor_demand", ), AirzoneBinarySensorEntityDescription( attributes={ @@ -72,7 +69,6 @@ class AirzoneBinarySensorEntityDescription(BinarySensorEntityDescription): device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, key=AZD_PROBLEMS, - name="Problem", ), ) @@ -149,7 +145,6 @@ def __init__( ) -> None: """Initialize.""" super().__init__(coordinator, entry, system_data) - self._attr_name = f"System {system_id} {description.name}" self._attr_unique_id = f"{self._attr_unique_id}_{system_id}_{description.key}" self.entity_description = description self._async_update_attrs() @@ -169,7 +164,6 @@ def __init__( """Initialize.""" super().__init__(coordinator, entry, system_zone_id, zone_data) - self._attr_name = f"{zone_data[AZD_NAME]} {description.name}" self._attr_unique_id = ( f"{self._attr_unique_id}_{system_zone_id}_{description.key}" ) diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index c3ba74236bd046..b4cf3d9d522273 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -19,7 +19,6 @@ AZD_MASTER, AZD_MODE, AZD_MODES, - AZD_NAME, AZD_ON, AZD_SPEED, AZD_SPEEDS, @@ -114,6 +113,7 @@ async def async_setup_entry( class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): """Define an Airzone sensor.""" + _attr_name = None _speeds: dict[int, str] = {} _speeds_reverse: dict[str, int] = {} @@ -127,7 +127,6 @@ def __init__( """Initialize Airzone climate entity.""" super().__init__(coordinator, entry, system_zone_id, zone_data) - self._attr_name = f"{zone_data[AZD_NAME]}" self._attr_unique_id = f"{self._attr_unique_id}_{system_zone_id}" self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE self._attr_target_temperature_step = API_TEMPERATURE_STEP diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index 2310d5fb5a4cbf..b758acd4b75b2a 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -39,6 +39,8 @@ class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator]): """Define an Airzone entity.""" + _attr_has_entity_name = True + def get_airzone_value(self, key: str) -> Any: """Return Airzone entity value by key.""" raise NotImplementedError() @@ -62,7 +64,7 @@ def __init__( identifiers={(DOMAIN, f"{entry.entry_id}_{self.system_id}")}, manufacturer=MANUFACTURER, model=self.get_airzone_value(AZD_MODEL), - name=self.get_airzone_value(AZD_FULL_NAME), + name=f"System {self.system_id}", sw_version=self.get_airzone_value(AZD_FIRMWARE), via_device=(DOMAIN, f"{entry.entry_id}_ws"), ) @@ -172,7 +174,7 @@ def __init__( identifiers={(DOMAIN, f"{entry.entry_id}_{system_zone_id}")}, manufacturer=MANUFACTURER, model=self.get_airzone_value(AZD_THERMOSTAT_MODEL), - name=f"Airzone [{system_zone_id}] {zone_data[AZD_NAME]}", + name=zone_data[AZD_NAME], sw_version=self.get_airzone_value(AZD_THERMOSTAT_FW), via_device=(DOMAIN, f"{entry.entry_id}_{self.system_id}"), ) diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py index 1a0d577bb35f7a..78b4dee3b721a8 100644 --- a/homeassistant/components/airzone/select.py +++ b/homeassistant/components/airzone/select.py @@ -11,7 +11,6 @@ API_SLEEP, AZD_COLD_ANGLE, AZD_HEAT_ANGLE, - AZD_NAME, AZD_SLEEP, AZD_ZONES, ) @@ -60,7 +59,6 @@ class AirzoneSelectDescription(SelectEntityDescription, AirzoneSelectDescription api_param=API_COLD_ANGLE, entity_category=EntityCategory.CONFIG, key=AZD_COLD_ANGLE, - name="Cold Angle", options=list(GRILLE_ANGLE_DICT), options_dict=GRILLE_ANGLE_DICT, translation_key="grille_angles", @@ -69,16 +67,14 @@ class AirzoneSelectDescription(SelectEntityDescription, AirzoneSelectDescription api_param=API_HEAT_ANGLE, entity_category=EntityCategory.CONFIG, key=AZD_HEAT_ANGLE, - name="Heat Angle", options=list(GRILLE_ANGLE_DICT), options_dict=GRILLE_ANGLE_DICT, - translation_key="grille_angles", + translation_key="heat_angles", ), AirzoneSelectDescription( api_param=API_SLEEP, entity_category=EntityCategory.CONFIG, key=AZD_SLEEP, - name="Sleep", options=list(SLEEP_DICT), options_dict=SLEEP_DICT, translation_key="sleep_times", @@ -146,7 +142,6 @@ def __init__( """Initialize.""" super().__init__(coordinator, entry, system_zone_id, zone_data) - self._attr_name = f"{zone_data[AZD_NAME]} {description.name}" self._attr_unique_id = ( f"{self._attr_unique_id}_{system_zone_id}_{description.key}" ) diff --git a/homeassistant/components/airzone/sensor.py b/homeassistant/components/airzone/sensor.py index 1dd67294aff0e2..c14eaf48ff1f98 100644 --- a/homeassistant/components/airzone/sensor.py +++ b/homeassistant/components/airzone/sensor.py @@ -6,7 +6,6 @@ from aioairzone.const import ( AZD_HOT_WATER, AZD_HUMIDITY, - AZD_NAME, AZD_TEMP, AZD_TEMP_UNIT, AZD_WEBSERVER, @@ -54,7 +53,7 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, key=AZD_WIFI_RSSI, - name="RSSI", + translation_key="rssi", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, state_class=SensorStateClass.MEASUREMENT, ), @@ -64,14 +63,12 @@ SensorEntityDescription( device_class=SensorDeviceClass.TEMPERATURE, key=AZD_TEMP, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( device_class=SensorDeviceClass.HUMIDITY, key=AZD_HUMIDITY, - name="Humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), @@ -144,8 +141,6 @@ def _async_update_attrs(self) -> None: class AirzoneHotWaterSensor(AirzoneHotWaterEntity, AirzoneSensor): """Define an Airzone Hot Water sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -176,7 +171,6 @@ def __init__( ) -> None: """Initialize.""" super().__init__(coordinator, entry) - self._attr_name = f"WebServer {description.name}" self._attr_unique_id = f"{self._attr_unique_id}_ws_{description.key}" self.entity_description = description self._async_update_attrs() @@ -196,7 +190,6 @@ def __init__( """Initialize.""" super().__init__(coordinator, entry, system_zone_id, zone_data) - self._attr_name = f"{zone_data[AZD_NAME]} {description.name}" self._attr_unique_id = ( f"{self._attr_unique_id}_{system_zone_id}_{description.key}" ) diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json index 037ebe52d782bf..438304d7f417f0 100644 --- a/homeassistant/components/airzone/strings.json +++ b/homeassistant/components/airzone/strings.json @@ -25,8 +25,17 @@ } }, "entity": { + "binary_sensor": { + "air_demand": { + "name": "Air demand" + }, + "floor_demand": { + "name": "Floor demand" + } + }, "select": { "grille_angles": { + "name": "Cold angle", "state": { "90deg": "90°", "50deg": "50°", @@ -34,7 +43,17 @@ "40deg": "40°" } }, + "heat_angles": { + "name": "Heat angle", + "state": { + "90deg": "[%key:component::airzone::entity::select::grille_angles::state::90deg%]", + "50deg": "[%key:component::airzone::entity::select::grille_angles::state::50deg%]", + "45deg": "[%key:component::airzone::entity::select::grille_angles::state::45deg%]", + "40deg": "[%key:component::airzone::entity::select::grille_angles::state::40deg%]" + } + }, "sleep_times": { + "name": "Sleep", "state": { "off": "[%key:common::state::off%]", "30m": "30 minutes", @@ -42,6 +61,11 @@ "90m": "90 minutes" } } + }, + "sensor": { + "rssi": { + "name": "RSSI" + } } } } diff --git a/homeassistant/components/airzone/water_heater.py b/homeassistant/components/airzone/water_heater.py index b19aa36449c9b9..58164edf3e9ec6 100644 --- a/homeassistant/components/airzone/water_heater.py +++ b/homeassistant/components/airzone/water_heater.py @@ -9,7 +9,6 @@ API_ACS_POWER_MODE, API_ACS_SET_POINT, AZD_HOT_WATER, - AZD_NAME, AZD_OPERATION, AZD_OPERATIONS, AZD_TEMP, @@ -67,6 +66,7 @@ async def async_setup_entry( class AirzoneWaterHeater(AirzoneHotWaterEntity, WaterHeaterEntity): """Define an Airzone Water Heater.""" + _attr_name = None _attr_supported_features = ( WaterHeaterEntityFeature.TARGET_TEMPERATURE | WaterHeaterEntityFeature.ON_OFF @@ -81,7 +81,6 @@ def __init__( """Initialize Airzone water heater entity.""" super().__init__(coordinator, entry) - self._attr_name = self.get_airzone_value(AZD_NAME) self._attr_unique_id = f"{self._attr_unique_id}_dhw" self._attr_operation_list = [ OPERATION_LIB_TO_HASS[operation] diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py index a364ad0d7539ce..2a182b7b487a57 100644 --- a/homeassistant/components/airzone_cloud/binary_sensor.py +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -159,8 +159,6 @@ def _async_update_attrs(self) -> None: class AirzoneAidooBinarySensor(AirzoneAidooEntity, AirzoneBinarySensor): """Define an Airzone Cloud Aidoo binary sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -180,8 +178,6 @@ def __init__( class AirzoneSystemBinarySensor(AirzoneSystemEntity, AirzoneBinarySensor): """Define an Airzone Cloud System binary sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -201,8 +197,6 @@ def __init__( class AirzoneZoneBinarySensor(AirzoneZoneEntity, AirzoneBinarySensor): """Define an Airzone Cloud Zone binary sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index 1fe5e45ee44e35..53bc7e89a3ceee 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -142,7 +142,6 @@ async def async_setup_entry( class AirzoneClimate(AirzoneEntity, ClimateEntity): """Define an Airzone Cloud climate.""" - _attr_has_entity_name = True _attr_name = None _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index d5dd0cfcfb4752..297f85af359fac 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -34,6 +34,8 @@ class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC): """Define an Airzone Cloud entity.""" + _attr_has_entity_name = True + @property def available(self) -> bool: """Return Airzone Cloud entity availability.""" diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index a3c0f5e7dc091f..eb95934212262a 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.3.0"] + "requirements": ["aioairzone-cloud==0.3.1"] } diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py index c33838029b41f2..f45fd248cd5042 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -141,8 +141,6 @@ def _async_update_attrs(self) -> None: class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor): """Define an Airzone Cloud Aidoo sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -162,8 +160,6 @@ def __init__( class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): """Define an Airzone Cloud WebServer sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -183,8 +179,6 @@ def __init__( class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor): """Define an Airzone Cloud Zone sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index f3be69778917ff..bab3421c58d7d1 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -21,6 +21,15 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 +COMMAND_TO_ATTRIBUTE = { + "wakeup": ("power", "turn_on"), + "suspend": ("power", "turn_off"), + "turn_on": ("power", "turn_on"), + "turn_off": ("power", "turn_off"), + "volume_up": ("audio", "volume_up"), + "volume_down": ("audio", "volume_down"), + "home_hold": ("remote_control", "home"), +} async def async_setup_entry( @@ -61,7 +70,13 @@ async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> Non for _ in range(num_repeats): for single_command in command: - attr_value = getattr(self.atv.remote_control, single_command, None) + attr_value = None + if attributes := COMMAND_TO_ATTRIBUTE.get(single_command): + attr_value = self.atv + for attr_name in attributes: + attr_value = getattr(attr_value, attr_name, None) + if not attr_value: + attr_value = getattr(self.atv.remote_control, single_command, None) if not attr_value: raise ValueError("Command not found. Exiting sequence") diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 408d6e0be7e67d..c1eb21b682754d 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -25,13 +25,14 @@ ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import aiohttp_client, device_registry as dr, discovery_flow +from homeassistant.helpers import device_registry as dr, discovery_flow from .activity import ActivityStream from .const import CONF_BRAND, DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS from .exceptions import CannotConnect, InvalidAuth, RequireValidation from .gateway import AugustGateway from .subscriber import AugustSubscriberMixin +from .util import async_create_august_clientsession _LOGGER = logging.getLogger(__name__) @@ -46,10 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up August from a config entry.""" - # Create an aiohttp session instead of using the default one since the - # default one is likely to trigger august's WAF if another integration - # is also using Cloudflare - session = aiohttp_client.async_create_clientsession(hass) + session = async_create_august_clientsession(hass) august_gateway = AugustGateway(hass, session) try: diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index 670d16084210d1..0028db55415712 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -13,7 +13,6 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client from .const import ( CONF_ACCESS_TOKEN_CACHE_FILE, @@ -26,6 +25,7 @@ ) from .exceptions import CannotConnect, InvalidAuth, RequireValidation from .gateway import AugustGateway +from .util import async_create_august_clientsession _LOGGER = logging.getLogger(__name__) @@ -159,10 +159,7 @@ def _async_get_gateway(self) -> AugustGateway: """Set up the gateway.""" if self._august_gateway is not None: return self._august_gateway - # Create an aiohttp session instead of using the default one since the - # default one is likely to trigger august's WAF if another integration - # is also using Cloudflare - self._aiohttp_session = aiohttp_client.async_create_clientsession(self.hass) + self._aiohttp_session = async_create_august_clientsession(self.hass) self._august_gateway = AugustGateway(self.hass, self._aiohttp_session) return self._august_gateway diff --git a/homeassistant/components/august/util.py b/homeassistant/components/august/util.py new file mode 100644 index 00000000000000..9703fdc6fcd2a2 --- /dev/null +++ b/homeassistant/components/august/util.py @@ -0,0 +1,24 @@ +"""August util functions.""" + +import socket + +import aiohttp + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import aiohttp_client + + +@callback +def async_create_august_clientsession(hass: HomeAssistant) -> aiohttp.ClientSession: + """Create an aiohttp session for the august integration.""" + # Create an aiohttp session instead of using the default one since the + # default one is likely to trigger august's WAF if another integration + # is also using Cloudflare + # + # The family is set to AF_INET because IPv6 keeps coming up as an issue + # see https://github.com/home-assistant/core/issues/97146 + # + # When https://github.com/aio-libs/aiohttp/issues/4451 is implemented + # we can allow IPv6 again + # + return aiohttp_client.async_create_clientsession(hass, family=socket.AF_INET) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 89438c9c7c12dc..c6413dd4372d4f 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -86,8 +86,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: blink.auth = Auth(auth_data, no_prompt=True, session=session) blink.refresh_rate = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) coordinator = BlinkUpdateCoordinator(hass, blink) - await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator try: await blink.start() @@ -101,6 +99,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not blink.available: raise ConfigEntryNotReady + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index c789d7cdd6f8f1..d1fcb889fb858c 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -60,12 +60,12 @@ def __init__( self.api: Blink = coordinator.api self._coordinator = coordinator self.sync = sync - self._name: str = name self._attr_unique_id: str = sync.serial self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, sync.serial)}, name=f"{DOMAIN} {name}", manufacturer=DEFAULT_BRAND, + serial_number=sync.serial, ) self._update_attr() diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 65e454e44342c7..47b45e2f4eccbb 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -72,9 +72,11 @@ def __init__( super().__init__(coordinator) self.entity_description = description self._camera = coordinator.api.cameras[camera] - self._attr_unique_id = f"{self._camera.serial}-{description.key}" + serial = self._camera.serial + self._attr_unique_id = f"{serial}-{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._camera.serial)}, + identifiers={(DOMAIN, serial)}, + serial_number=serial, name=camera, manufacturer=DEFAULT_BRAND, model=self._camera.camera_type, diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 4ff0ba86db9481..c967ff59c8cdee 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -25,6 +25,7 @@ ATTR_VIDEO_CLIP = "video" ATTR_IMAGE = "image" +PARALLEL_UPDATES = 1 async def async_setup_entry( @@ -58,6 +59,7 @@ def __init__(self, coordinator: BlinkUpdateCoordinator, name, camera) -> None: self._attr_unique_id = f"{camera.serial}-camera" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, camera.serial)}, + serial_number=camera.serial, name=name, manufacturer=DEFAULT_BRAND, model=camera.camera_type, @@ -104,6 +106,7 @@ async def trigger_camera(self) -> None: """Trigger camera to take a snapshot.""" with contextlib.suppress(asyncio.TimeoutError): await self._camera.snap_picture() + await self._coordinator.api.refresh() self.async_write_ha_state() def camera_image( diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index 9453d3b6d6b513..064ad9d04f2210 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -74,14 +74,16 @@ def __init__( self.entity_description = description self._camera = coordinator.api.cameras[camera] - self._attr_unique_id = f"{self._camera.serial}-{description.key}" + serial = self._camera.serial + self._attr_unique_id = f"{serial}-{description.key}" self._sensor_key = ( "temperature_calibrated" if description.key == "temperature" else description.key ) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._camera.serial)}, + identifiers={(DOMAIN, serial)}, + serial_number=serial, name=f"{DOMAIN} {camera}", manufacturer=DEFAULT_BRAND, model=self._camera.camera_type, diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 240610e48680d4..8eacd3e291a31f 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -330,7 +330,7 @@ def _async_on_advertisement( prev_manufacturer_data = prev_advertisement.manufacturer_data prev_name = prev_device.name - if local_name and prev_name and len(prev_name) > len(local_name): + if prev_name and (not local_name or len(prev_name) > len(local_name)): local_name = prev_name if service_uuids and service_uuids != prev_service_uuids: diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 960a86637ae23a..06e7d34e68df43 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,7 +15,7 @@ "quality_scale": "internal", "requirements": [ "bleak==0.21.1", - "bleak-retry-connector==3.2.1", + "bleak-retry-connector==3.3.0", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.13.0", diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 8138587b9b5014..7dd39c140393f8 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -9,6 +9,7 @@ from homeassistant import config_entries from homeassistant.const import ( + ATTR_CONNECTIONS, ATTR_IDENTIFIERS, ATTR_NAME, CONF_ENTITY_CATEGORY, @@ -16,7 +17,7 @@ EntityCategory, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_platform import async_get_current_platform from homeassistant.helpers.event import async_track_time_interval @@ -644,6 +645,8 @@ def __init__( self._attr_unique_id = f"{address}-{key}" if ATTR_NAME not in self._attr_device_info: self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name + if device_id is None: + self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_BLUETOOTH, address)} self._attr_name = processor.entity_names.get(entity_key) @property diff --git a/homeassistant/components/bthome/__init__.py b/homeassistant/components/bthome/__init__.py index 751c8f74bf9f1d..566609b998b453 100644 --- a/homeassistant/components/bthome/__init__.py +++ b/homeassistant/components/bthome/__init__.py @@ -14,7 +14,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceRegistry, async_get +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceRegistry, + async_get, +) from .const import ( BTHOME_BLE_EVENT, @@ -55,6 +59,7 @@ def process_service_info( sensor_device_info = update.devices[device_key.device_id] device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, + connections={(CONNECTION_BLUETOOTH, address)}, identifiers={(BLUETOOTH_DOMAIN, address)}, manufacturer=sensor_device_info.manufacturer, model=sensor_device_info.model, diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 4fe5e38432ac70..14c9626c264a5d 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -1,10 +1,8 @@ """Support for WebDav Calendar.""" from __future__ import annotations -from datetime import date, datetime, time, timedelta -from functools import partial +from datetime import datetime import logging -import re import caldav import voluptuous as vol @@ -14,7 +12,6 @@ PLATFORM_SCHEMA, CalendarEntity, CalendarEvent, - extract_offset, is_offset_reached, ) from homeassistant.const import ( @@ -24,12 +21,14 @@ CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle, dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import CalDavUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -39,7 +38,6 @@ CONF_SEARCH = "search" CONF_DAYS = "days" -OFFSET = "!!" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -64,8 +62,6 @@ } ) -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) - def setup_platform( hass: HomeAssistant, @@ -103,16 +99,14 @@ def setup_platform( name = cust_calendar[CONF_NAME] device_id = f"{cust_calendar[CONF_CALENDAR]} {cust_calendar[CONF_NAME]}" entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) - calendar_devices.append( - WebDavCalendarEntity( - name=name, - calendar=calendar, - entity_id=entity_id, - days=days, - all_day=True, - search=cust_calendar[CONF_SEARCH], - ) + coordinator = CalDavUpdateCoordinator( + hass, + calendar=calendar, + days=days, + include_all_day=True, + search=cust_calendar[CONF_SEARCH], ) + calendar_devices.append(WebDavCalendarEntity(name, entity_id, coordinator)) # Create a default calendar if there was no custom one for all calendars # that support events. @@ -130,24 +124,24 @@ def setup_platform( name = calendar.name device_id = calendar.name entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) - calendar_devices.append( - WebDavCalendarEntity(name, calendar, entity_id, days) + coordinator = CalDavUpdateCoordinator( + hass, + calendar=calendar, + days=days, + include_all_day=False, + search=None, ) + calendar_devices.append(WebDavCalendarEntity(name, entity_id, coordinator)) add_entities(calendar_devices, True) -class WebDavCalendarEntity(CalendarEntity): +class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarEntity): """A device for getting the next Task from a WebDav Calendar.""" - def __init__(self, name, calendar, entity_id, days, all_day=False, search=None): + def __init__(self, name, entity_id, coordinator): """Create the WebDav Calendar Event Device.""" - self.data = WebDavCalendarData( - calendar=calendar, - days=days, - include_all_day=all_day, - search=search, - ) + super().__init__(coordinator) self.entity_id = entity_id self._event: CalendarEvent | None = None self._attr_name = name @@ -161,222 +155,22 @@ async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all events in a specific time frame.""" - return await self.data.async_get_events(hass, start_date, end_date) + return await self.coordinator.async_get_events(hass, start_date, end_date) - def update(self) -> None: + @callback + def _handle_coordinator_update(self) -> None: """Update event data.""" - self.data.update() - self._event = self.data.event + self._event = self.coordinator.data self._attr_extra_state_attributes = { "offset_reached": is_offset_reached( - self._event.start_datetime_local, self.data.offset + self._event.start_datetime_local, self.coordinator.offset ) if self._event else False } + super()._handle_coordinator_update() - -class WebDavCalendarData: - """Class to utilize the calendar dav client object to get next event.""" - - def __init__(self, calendar, days, include_all_day, search): - """Set up how we are going to search the WebDav calendar.""" - self.calendar = calendar - self.days = days - self.include_all_day = include_all_day - self.search = search - self.event = None - self.offset = None - - async def async_get_events( - self, hass: HomeAssistant, start_date: datetime, end_date: datetime - ) -> list[CalendarEvent]: - """Get all events in a specific time frame.""" - # Get event list from the current calendar - vevent_list = await hass.async_add_executor_job( - partial( - self.calendar.search, - start=start_date, - end=end_date, - event=True, - expand=True, - ) - ) - event_list = [] - for event in vevent_list: - if not hasattr(event.instance, "vevent"): - _LOGGER.warning("Skipped event with missing 'vevent' property") - continue - vevent = event.instance.vevent - if not self.is_matching(vevent, self.search): - continue - event_list.append( - CalendarEvent( - summary=self.get_attr_value(vevent, "summary") or "", - start=self.to_local(vevent.dtstart.value), - end=self.to_local(self.get_end_date(vevent)), - location=self.get_attr_value(vevent, "location"), - description=self.get_attr_value(vevent, "description"), - ) - ) - - return event_list - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data.""" - start_of_today = dt_util.start_of_local_day() - start_of_tomorrow = dt_util.start_of_local_day() + timedelta(days=self.days) - - # We have to retrieve the results for the whole day as the server - # won't return events that have already started - results = self.calendar.search( - start=start_of_today, - end=start_of_tomorrow, - event=True, - expand=True, - ) - - # Create new events for each recurrence of an event that happens today. - # For recurring events, some servers return the original event with recurrence rules - # and they would not be properly parsed using their original start/end dates. - new_events = [] - for event in results: - if not hasattr(event.instance, "vevent"): - _LOGGER.warning("Skipped event with missing 'vevent' property") - continue - vevent = event.instance.vevent - for start_dt in vevent.getrruleset() or []: - _start_of_today = start_of_today - _start_of_tomorrow = start_of_tomorrow - if self.is_all_day(vevent): - start_dt = start_dt.date() - _start_of_today = _start_of_today.date() - _start_of_tomorrow = _start_of_tomorrow.date() - if _start_of_today <= start_dt < _start_of_tomorrow: - new_event = event.copy() - new_vevent = new_event.instance.vevent - if hasattr(new_vevent, "dtend"): - dur = new_vevent.dtend.value - new_vevent.dtstart.value - new_vevent.dtend.value = start_dt + dur - new_vevent.dtstart.value = start_dt - new_events.append(new_event) - elif _start_of_tomorrow <= start_dt: - break - vevents = [ - event.instance.vevent - for event in results + new_events - if hasattr(event.instance, "vevent") - ] - - # dtstart can be a date or datetime depending if the event lasts a - # whole day. Convert everything to datetime to be able to sort it - vevents.sort(key=lambda x: self.to_datetime(x.dtstart.value)) - - vevent = next( - ( - vevent - for vevent in vevents - if ( - self.is_matching(vevent, self.search) - and (not self.is_all_day(vevent) or self.include_all_day) - and not self.is_over(vevent) - ) - ), - None, - ) - - # If no matching event could be found - if vevent is None: - _LOGGER.debug( - "No matching event found in the %d results for %s", - len(vevents), - self.calendar.name, - ) - self.event = None - return - - # Populate the entity attributes with the event values - (summary, offset) = extract_offset( - self.get_attr_value(vevent, "summary") or "", OFFSET - ) - self.event = CalendarEvent( - summary=summary, - start=self.to_local(vevent.dtstart.value), - end=self.to_local(self.get_end_date(vevent)), - location=self.get_attr_value(vevent, "location"), - description=self.get_attr_value(vevent, "description"), - ) - self.offset = offset - - @staticmethod - def is_matching(vevent, search): - """Return if the event matches the filter criteria.""" - if search is None: - return True - - pattern = re.compile(search) - return ( - hasattr(vevent, "summary") - and pattern.match(vevent.summary.value) - or hasattr(vevent, "location") - and pattern.match(vevent.location.value) - or hasattr(vevent, "description") - and pattern.match(vevent.description.value) - ) - - @staticmethod - def is_all_day(vevent): - """Return if the event last the whole day.""" - return not isinstance(vevent.dtstart.value, datetime) - - @staticmethod - def is_over(vevent): - """Return if the event is over.""" - return dt_util.now() >= WebDavCalendarData.to_datetime( - WebDavCalendarData.get_end_date(vevent) - ) - - @staticmethod - def to_datetime(obj): - """Return a datetime.""" - if isinstance(obj, datetime): - return WebDavCalendarData.to_local(obj) - return datetime.combine(obj, time.min).replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) - - @staticmethod - def to_local(obj: datetime | date) -> datetime | date: - """Return a datetime as a local datetime, leaving dates unchanged. - - This handles giving floating times a timezone for comparison - with all day events and dropping the custom timezone object - used by the caldav client and dateutil so the datetime can be copied. - """ - if isinstance(obj, datetime): - return dt_util.as_local(obj) - return obj - - @staticmethod - def get_attr_value(obj, attribute): - """Return the value of the attribute if defined.""" - if hasattr(obj, attribute): - return getattr(obj, attribute).value - return None - - @staticmethod - def get_end_date(obj): - """Return the end datetime as determined by dtend or duration.""" - if hasattr(obj, "dtend"): - enddate = obj.dtend.value - elif hasattr(obj, "duration"): - enddate = obj.dtstart.value + obj.duration.value - else: - enddate = obj.dtstart.value + timedelta(days=1) - - # End date for an all day event is exclusive. This fixes the case where - # an all day event has a start and end values are the same, or the event - # has a zero duration. - if not isinstance(enddate, datetime) and obj.dtstart.value == enddate: - enddate += timedelta(days=1) - - return enddate + async def async_added_to_hass(self) -> None: + """When entity is added to hass update state from existing coordinator data.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py new file mode 100644 index 00000000000000..ee34a56e23bc23 --- /dev/null +++ b/homeassistant/components/caldav/coordinator.py @@ -0,0 +1,234 @@ +"""Data update coordinator for caldav.""" + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta +from functools import partial +import logging +import re + +from homeassistant.components.calendar import CalendarEvent, extract_offset +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) +OFFSET = "!!" + + +class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): + """Class to utilize the calendar dav client object to get next event.""" + + def __init__(self, hass, calendar, days, include_all_day, search): + """Set up how we are going to search the WebDav calendar.""" + super().__init__( + hass, + _LOGGER, + name=f"CalDAV {calendar.name}", + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + self.calendar = calendar + self.days = days + self.include_all_day = include_all_day + self.search = search + self.offset = None + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + # Get event list from the current calendar + vevent_list = await hass.async_add_executor_job( + partial( + self.calendar.search, + start=start_date, + end=end_date, + event=True, + expand=True, + ) + ) + event_list = [] + for event in vevent_list: + if not hasattr(event.instance, "vevent"): + _LOGGER.warning("Skipped event with missing 'vevent' property") + continue + vevent = event.instance.vevent + if not self.is_matching(vevent, self.search): + continue + event_list.append( + CalendarEvent( + summary=self.get_attr_value(vevent, "summary") or "", + start=self.to_local(vevent.dtstart.value), + end=self.to_local(self.get_end_date(vevent)), + location=self.get_attr_value(vevent, "location"), + description=self.get_attr_value(vevent, "description"), + ) + ) + + return event_list + + async def _async_update_data(self) -> CalendarEvent | None: + """Get the latest data.""" + start_of_today = dt_util.start_of_local_day() + start_of_tomorrow = dt_util.start_of_local_day() + timedelta(days=self.days) + + # We have to retrieve the results for the whole day as the server + # won't return events that have already started + results = await self.hass.async_add_executor_job( + partial( + self.calendar.search, + start=start_of_today, + end=start_of_tomorrow, + event=True, + expand=True, + ), + ) + + # Create new events for each recurrence of an event that happens today. + # For recurring events, some servers return the original event with recurrence rules + # and they would not be properly parsed using their original start/end dates. + new_events = [] + for event in results: + if not hasattr(event.instance, "vevent"): + _LOGGER.warning("Skipped event with missing 'vevent' property") + continue + vevent = event.instance.vevent + for start_dt in vevent.getrruleset() or []: + _start_of_today: date | datetime + _start_of_tomorrow: datetime | date + if self.is_all_day(vevent): + start_dt = start_dt.date() + _start_of_today = start_of_today.date() + _start_of_tomorrow = start_of_tomorrow.date() + else: + _start_of_today = start_of_today + _start_of_tomorrow = start_of_tomorrow + if _start_of_today <= start_dt < _start_of_tomorrow: + new_event = event.copy() + new_vevent = new_event.instance.vevent + if hasattr(new_vevent, "dtend"): + dur = new_vevent.dtend.value - new_vevent.dtstart.value + new_vevent.dtend.value = start_dt + dur + new_vevent.dtstart.value = start_dt + new_events.append(new_event) + elif _start_of_tomorrow <= start_dt: + break + vevents = [ + event.instance.vevent + for event in results + new_events + if hasattr(event.instance, "vevent") + ] + + # dtstart can be a date or datetime depending if the event lasts a + # whole day. Convert everything to datetime to be able to sort it + vevents.sort(key=lambda x: self.to_datetime(x.dtstart.value)) + + vevent = next( + ( + vevent + for vevent in vevents + if ( + self.is_matching(vevent, self.search) + and (not self.is_all_day(vevent) or self.include_all_day) + and not self.is_over(vevent) + ) + ), + None, + ) + + # If no matching event could be found + if vevent is None: + _LOGGER.debug( + "No matching event found in the %d results for %s", + len(vevents), + self.calendar.name, + ) + self.offset = None + return None + + # Populate the entity attributes with the event values + (summary, offset) = extract_offset( + self.get_attr_value(vevent, "summary") or "", OFFSET + ) + self.offset = offset + return CalendarEvent( + summary=summary, + start=self.to_local(vevent.dtstart.value), + end=self.to_local(self.get_end_date(vevent)), + location=self.get_attr_value(vevent, "location"), + description=self.get_attr_value(vevent, "description"), + ) + + @staticmethod + def is_matching(vevent, search): + """Return if the event matches the filter criteria.""" + if search is None: + return True + + pattern = re.compile(search) + return ( + hasattr(vevent, "summary") + and pattern.match(vevent.summary.value) + or hasattr(vevent, "location") + and pattern.match(vevent.location.value) + or hasattr(vevent, "description") + and pattern.match(vevent.description.value) + ) + + @staticmethod + def is_all_day(vevent): + """Return if the event last the whole day.""" + return not isinstance(vevent.dtstart.value, datetime) + + @staticmethod + def is_over(vevent): + """Return if the event is over.""" + return dt_util.now() >= CalDavUpdateCoordinator.to_datetime( + CalDavUpdateCoordinator.get_end_date(vevent) + ) + + @staticmethod + def to_datetime(obj): + """Return a datetime.""" + if isinstance(obj, datetime): + return CalDavUpdateCoordinator.to_local(obj) + return datetime.combine(obj, time.min).replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) + + @staticmethod + def to_local(obj: datetime | date) -> datetime | date: + """Return a datetime as a local datetime, leaving dates unchanged. + + This handles giving floating times a timezone for comparison + with all day events and dropping the custom timezone object + used by the caldav client and dateutil so the datetime can be copied. + """ + if isinstance(obj, datetime): + return dt_util.as_local(obj) + return obj + + @staticmethod + def get_attr_value(obj, attribute): + """Return the value of the attribute if defined.""" + if hasattr(obj, attribute): + return getattr(obj, attribute).value + return None + + @staticmethod + def get_end_date(obj): + """Return the end datetime as determined by dtend or duration.""" + if hasattr(obj, "dtend"): + enddate = obj.dtend.value + elif hasattr(obj, "duration"): + enddate = obj.dtstart.value + obj.duration.value + else: + enddate = obj.dtstart.value + timedelta(days=1) + + # End date for an all day event is exclusive. This fixes the case where + # an all day event has a start and end values are the same, or the event + # has a zero duration. + if not isinstance(enddate, datetime) and obj.dtstart.value == enddate: + enddate += timedelta(days=1) + + return enddate diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 653ceafdaf089a..6d5c954361bb5d 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.73.0"] + "requirements": ["hass-nabucasa==0.74.0"] } diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 1fc4b0e66681c6..d3bc973429b9ac 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -1,11 +1,9 @@ """Support for Comelit.""" -import asyncio from datetime import timedelta from typing import Any -from aiocomelit import ComeliteSerialBridgeApi, ComelitSerialBridgeObject +from aiocomelit import ComeliteSerialBridgeApi, ComelitSerialBridgeObject, exceptions from aiocomelit.const import BRIDGE -import aiohttp from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -70,14 +68,13 @@ def platform_device_info(self, device: ComelitSerialBridgeObject) -> dr.DeviceIn async def _async_update_data(self) -> dict[str, Any]: """Update device data.""" _LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host) - logged = False try: - logged = await self.api.login() - except (asyncio.exceptions.TimeoutError, aiohttp.ClientConnectorError) as err: + await self.api.login() + except exceptions.CannotConnect as err: _LOGGER.warning("Connection error for %s", self._host) + await self.api.close() raise UpdateFailed(f"Error fetching data: {repr(err)}") from err - finally: - if not logged: - raise ConfigEntryAuthFailed + except exceptions.CannotAuthenticate: + raise ConfigEntryAuthFailed return await self.api.get_all_devices() diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 61b0cb39061c14..4a3c8eed63ceca 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -51,7 +51,8 @@ def __init__( self._api = coordinator.api self._device = device super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id because no serial number or mac is available + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device) # Device doesn't provide a status so we assume UNKNOWN at first startup diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 258dc2ce1e7a29..95906f7ec6e5c5 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -47,7 +47,8 @@ def __init__( self._api = coordinator.api self._device = device super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id because no serial number or mac is available + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device) diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index 5cbc708d63ec59..554433fa6ad392 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -66,7 +66,8 @@ def __init__( self._api = coordinator.api self._device = device super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id because no serial number or mac is available + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device) diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index 46cbb74fce7f09..379b936c3bb511 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -53,7 +53,8 @@ def __init__( self._api = coordinator.api self._device = device super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id because no serial number or mac is available + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.type}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device) if device.type == OTHER: diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 47ca1eda0d8ea6..dc2ed04b4ed8b3 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -38,7 +38,27 @@ from .gateway import DeconzGateway, get_gateway_from_config_entry DECONZ_GROUP = "is_deconz_group" -EFFECT_TO_DECONZ = {EFFECT_COLORLOOP: LightEffect.COLOR_LOOP, "None": LightEffect.NONE} +EFFECT_TO_DECONZ = { + EFFECT_COLORLOOP: LightEffect.COLOR_LOOP, + "None": LightEffect.NONE, + # Specific to Lidl christmas light + "carnival": LightEffect.CARNIVAL, + "collide": LightEffect.COLLIDE, + "fading": LightEffect.FADING, + "fireworks": LightEffect.FIREWORKS, + "flag": LightEffect.FLAG, + "glow": LightEffect.GLOW, + "rainbow": LightEffect.RAINBOW, + "snake": LightEffect.SNAKE, + "snow": LightEffect.SNOW, + "sparkles": LightEffect.SPARKLES, + "steady": LightEffect.STEADY, + "strobe": LightEffect.STROBE, + "twinkle": LightEffect.TWINKLE, + "updown": LightEffect.UPDOWN, + "vintage": LightEffect.VINTAGE, + "waves": LightEffect.WAVES, +} FLASH_TO_DECONZ = {FLASH_SHORT: LightAlert.SHORT, FLASH_LONG: LightAlert.LONG} DECONZ_TO_COLOR_MODE = { @@ -47,6 +67,25 @@ LightColorMode.XY: ColorMode.XY, } +TS0601_EFFECTS = [ + "carnival", + "collide", + "fading", + "fireworks", + "flag", + "glow", + "rainbow", + "snake", + "snow", + "sparkles", + "steady", + "strobe", + "twinkle", + "updown", + "vintage", + "waves", +] + _LightDeviceT = TypeVar("_LightDeviceT", bound=Group | Light) @@ -161,6 +200,8 @@ def __init__(self, device: _LightDeviceT, gateway: DeconzGateway) -> None: if device.effect is not None: self._attr_supported_features |= LightEntityFeature.EFFECT self._attr_effect_list = [EFFECT_COLORLOOP] + if device.model_id == "TS0601": + self._attr_effect_list += TS0601_EFFECTS @property def color_mode(self) -> str | None: diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index 83c599bc65d591..a00455293f6896 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -5,9 +5,9 @@ import voluptuous as vol -from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, Platform +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType from . import DeviceAutomationType, async_get_device_automation_platform @@ -55,31 +55,42 @@ async def async_validate_device_automation_config( platform = await async_get_device_automation_platform( hass, validated_config[CONF_DOMAIN], automation_type ) + + # Make sure the referenced device and optional entity exist + device_registry = dr.async_get(hass) + if not (device := device_registry.async_get(validated_config[CONF_DEVICE_ID])): + # The device referenced by the device automation does not exist + raise InvalidDeviceAutomationConfig( + f"Unknown device '{validated_config[CONF_DEVICE_ID]}'" + ) + if entity_id := validated_config.get(CONF_ENTITY_ID): + try: + er.async_validate_entity_id(er.async_get(hass), entity_id) + except vol.Invalid as err: + raise InvalidDeviceAutomationConfig( + f"Unknown entity '{entity_id}'" + ) from err + if not hasattr(platform, DYNAMIC_VALIDATOR[automation_type]): # Pass the unvalidated config to avoid mutating the raw config twice return cast( ConfigType, getattr(platform, STATIC_VALIDATOR[automation_type])(config) ) - # Bypass checks for entity platforms + # Devices are not linked to config entries from entity platform domains, skip + # the checks below which look for a config entry matching the device automation + # domain if ( automation_type == DeviceAutomationType.ACTION and validated_config[CONF_DOMAIN] in ENTITY_PLATFORMS ): + # Pass the unvalidated config to avoid mutating the raw config twice return cast( ConfigType, await getattr(platform, DYNAMIC_VALIDATOR[automation_type])(hass, config), ) - # Only call the dynamic validator if the referenced device exists and the relevant - # config entry is loaded - registry = dr.async_get(hass) - if not (device := registry.async_get(validated_config[CONF_DEVICE_ID])): - # The device referenced by the device automation does not exist - raise InvalidDeviceAutomationConfig( - f"Unknown device '{validated_config[CONF_DEVICE_ID]}'" - ) - + # Find a config entry with the same domain as the device automation device_config_entry = None for entry_id in device.config_entries: if ( @@ -91,7 +102,7 @@ async def async_validate_device_automation_config( break if not device_config_entry: - # The config entry referenced by the device automation does not exist + # There's no config entry with the same domain as the device automation raise InvalidDeviceAutomationConfig( f"Device '{validated_config[CONF_DEVICE_ID]}' has no config entry from " f"domain '{validated_config[CONF_DOMAIN]}'" diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 33bba375fd3f47..f12b2ad72bc70d 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -141,6 +141,7 @@ class DSMRReaderSensorEntityDescription(SensorEntityDescription): translation_key="gas_meter_usage", entity_registry_enabled_default=False, icon="mdi:fire", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -283,6 +284,7 @@ class DSMRReaderSensorEntityDescription(SensorEntityDescription): key="dsmr/day-consumption/gas", translation_key="daily_gas_usage", icon="mdi:counter", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( @@ -460,6 +462,7 @@ class DSMRReaderSensorEntityDescription(SensorEntityDescription): key="dsmr/current-month/gas", translation_key="current_month_gas_usage", icon="mdi:counter", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( @@ -538,6 +541,7 @@ class DSMRReaderSensorEntityDescription(SensorEntityDescription): key="dsmr/current-year/gas", translation_key="current_year_gas_usage", icon="mdi:counter", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index 71f5e04f75a7d4..ffb7fe8adfe45c 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -9,7 +9,7 @@ }, "iot_class": "cloud_polling", "loggers": ["pyecobee"], - "requirements": ["python-ecobee-api==0.2.14"], + "requirements": ["python-ecobee-api==0.2.17"], "zeroconf": [ { "type": "_ecobee._tcp.local." diff --git a/homeassistant/components/ecoforest/manifest.json b/homeassistant/components/ecoforest/manifest.json index 518f4d97a04eb4..2ef33b2054ba5e 100644 --- a/homeassistant/components/ecoforest/manifest.json +++ b/homeassistant/components/ecoforest/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecoforest", "iot_class": "local_polling", - "requirements": ["pyecoforest==0.3.0"] + "requirements": ["pyecoforest==0.4.0"] } diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py index 91f3138af37207..e595ddb65f7f55 100644 --- a/homeassistant/components/ecoforest/sensor.py +++ b/homeassistant/components/ecoforest/sensor.py @@ -13,7 +13,12 @@ SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTemperature +from homeassistant.const import ( + PERCENTAGE, + UnitOfPressure, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -88,6 +93,59 @@ class EcoforestSensorEntityDescription( icon="mdi:alert", value_fn=lambda data: data.alarm.value if data.alarm else "none", ), + EcoforestSensorEntityDescription( + key="depression", + translation_key="depression", + native_unit_of_measurement=UnitOfPressure.PA, + device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, + entity_registry_enabled_default=False, + value_fn=lambda data: data.depression, + ), + EcoforestSensorEntityDescription( + key="working_hours", + translation_key="working_hours", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + value_fn=lambda data: data.working_hours, + ), + EcoforestSensorEntityDescription( + key="ignitions", + translation_key="ignitions", + native_unit_of_measurement="ignitions", + entity_registry_enabled_default=False, + value_fn=lambda data: data.ignitions, + ), + EcoforestSensorEntityDescription( + key="live_pulse", + translation_key="live_pulse", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + value_fn=lambda data: data.live_pulse, + ), + EcoforestSensorEntityDescription( + key="pulse_offset", + translation_key="pulse_offset", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + value_fn=lambda data: data.pulse_offset, + ), + EcoforestSensorEntityDescription( + key="extractor", + translation_key="extractor", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + value_fn=lambda data: data.extractor, + ), + EcoforestSensorEntityDescription( + key="convecto_air_flow", + translation_key="convecto_air_flow", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + value_fn=lambda data: data.convecto_air_flow, + ), ) diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json index bd0605eab82abd..d1767be5cdaa45 100644 --- a/homeassistant/components/ecoforest/strings.json +++ b/homeassistant/components/ecoforest/strings.json @@ -50,6 +50,33 @@ "unkownn": "Unknown alarm", "none": "None" } + }, + "depression": { + "name": "Depression" + }, + "working_hours": { + "name": "Working time" + }, + "working_state": { + "name": "Working state" + }, + "working_level": { + "name": "Working level" + }, + "ignitions": { + "name": "Ignitions" + }, + "live_pulse": { + "name": "Live pulse" + }, + "pulse_offset": { + "name": "Pulse offset" + }, + "extractor": { + "name": "Extractor" + }, + "convecto_air_flow": { + "name": "Convecto air flow" } }, "number": { diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index 3472ca231e984e..c96867b489b048 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -1,10 +1,10 @@ { "domain": "econet", "name": "Rheem EcoNet Products", - "codeowners": ["@vangorra", "@w1ll1am23"], + "codeowners": ["@w1ll1am23"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/econet", "iot_class": "cloud_push", "loggers": ["paho_mqtt", "pyeconet"], - "requirements": ["pyeconet==0.1.20"] + "requirements": ["pyeconet==0.1.22"] } diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index b8066f2eb31899..ab5eff3b60f4ca 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -1,222 +1,37 @@ -"""Support for Eight smart mattress covers and mattresses.""" +"""The Eight Sleep integration.""" from __future__ import annotations -from dataclasses import dataclass -from datetime import timedelta -import logging - -from pyeight.eight import EightSleep -from pyeight.exceptions import RequestError -from pyeight.user import EightUser -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - ATTR_HW_VERSION, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SW_VERSION, - CONF_PASSWORD, - CONF_USERNAME, - Platform, -) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceInfo, async_get -from homeassistant.helpers.typing import UNDEFINED, ConfigType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) - -from .const import DOMAIN, NAME_MAP - -_LOGGER = logging.getLogger(__name__) - -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] - -HEAT_SCAN_INTERVAL = timedelta(seconds=60) -USER_SCAN_INTERVAL = timedelta(seconds=300) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ), - }, - extra=vol.ALLOW_EXTRA, -) - - -@dataclass -class EightSleepConfigEntryData: - """Data used for all entities for a given config entry.""" +from homeassistant.helpers import issue_registry as ir - api: EightSleep - heat_coordinator: DataUpdateCoordinator - user_coordinator: DataUpdateCoordinator +DOMAIN = "eight_sleep" -def _get_device_unique_id(eight: EightSleep, user_obj: EightUser | None = None) -> str: - """Get the device's unique ID.""" - unique_id = eight.device_id - assert unique_id - if user_obj: - unique_id = f"{unique_id}.{user_obj.user_id}.{user_obj.side}" - return unique_id - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Old set up method for the Eight Sleep component.""" - if DOMAIN in config: - _LOGGER.warning( - "Your Eight Sleep configuration has been imported into the UI; " - "please remove it from configuration.yaml as support for it " - "will be removed in a future release" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] - ) - ) - - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up the Eight Sleep config entry.""" - eight = EightSleep( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - hass.config.time_zone, - client_session=async_get_clientsession(hass), - ) - - # Authenticate, build sensors - try: - success = await eight.start() - except RequestError as err: - raise ConfigEntryNotReady from err - if not success: - # Authentication failed, cannot continue - return False - - heat_coordinator: DataUpdateCoordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=f"{DOMAIN}_heat", - update_interval=HEAT_SCAN_INTERVAL, - update_method=eight.update_device_data, - ) - user_coordinator: DataUpdateCoordinator = DataUpdateCoordinator( +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: + """Set up Eight Sleep from a config entry.""" + ir.async_create_issue( hass, - _LOGGER, - name=f"{DOMAIN}_user", - update_interval=USER_SCAN_INTERVAL, - update_method=eight.update_user_data, - ) - await heat_coordinator.async_config_entry_first_refresh() - await user_coordinator.async_config_entry_first_refresh() - - if not eight.users: - # No users, cannot continue - return False - - dev_reg = async_get(hass) - assert eight.device_data - device_data = { - ATTR_MANUFACTURER: "Eight Sleep", - ATTR_MODEL: eight.device_data.get("modelString", UNDEFINED), - ATTR_HW_VERSION: eight.device_data.get("sensorInfo", {}).get( - "hwRevision", UNDEFINED - ), - ATTR_SW_VERSION: eight.device_data.get("firmwareVersion", UNDEFINED), - } - dev_reg.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, _get_device_unique_id(eight))}, - name=f"{entry.data[CONF_USERNAME]}'s Eight Sleep", - **device_data, - ) - for user in eight.users.values(): - assert user.user_profile - dev_reg.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, _get_device_unique_id(eight, user))}, - name=f"{user.user_profile['firstName']}'s Eight Sleep Side", - via_device=(DOMAIN, _get_device_unique_id(eight)), - **device_data, - ) - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = EightSleepConfigEntryData( - eight, heat_coordinator, user_coordinator + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/eight_sleep" + }, ) - 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): - # stop the API before unloading everything - config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] - await config_entry_data.api.stop() - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - - return unload_ok - - -class EightSleepBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): - """The base Eight Sleep entity class.""" + if all( + config_entry.state is ConfigEntryState.NOT_LOADED + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) - def __init__( - self, - entry: ConfigEntry, - coordinator: DataUpdateCoordinator, - eight: EightSleep, - user_id: str | None, - sensor: str, - ) -> None: - """Initialize the data object.""" - super().__init__(coordinator) - self._config_entry = entry - self._eight = eight - self._user_id = user_id - self._sensor = sensor - self._user_obj: EightUser | None = None - if user_id: - self._user_obj = self._eight.users[user_id] - - mapped_name = NAME_MAP.get(sensor, sensor.replace("_", " ").title()) - if self._user_obj is not None: - assert self._user_obj.user_profile - name = f"{self._user_obj.user_profile['firstName']}'s {mapped_name}" - self._attr_name = name - else: - self._attr_name = f"Eight Sleep {mapped_name}" - unique_id = f"{_get_device_unique_id(eight, self._user_obj)}.{sensor}" - self._attr_unique_id = unique_id - identifiers = {(DOMAIN, _get_device_unique_id(eight, self._user_obj))} - self._attr_device_info = DeviceInfo(identifiers=identifiers) - - async def async_heat_set(self, target: int, duration: int) -> None: - """Handle eight sleep service calls.""" - if self._user_obj is None: - raise HomeAssistantError( - "This entity does not support the heat set service." - ) - - await self._user_obj.set_heating_level(target, duration) - config_entry_data: EightSleepConfigEntryData = self.hass.data[DOMAIN][ - self._config_entry.entry_id - ] - await config_entry_data.heat_coordinator.async_request_refresh() + return True diff --git a/homeassistant/components/eight_sleep/binary_sensor.py b/homeassistant/components/eight_sleep/binary_sensor.py deleted file mode 100644 index 7ad1b88200891e..00000000000000 --- a/homeassistant/components/eight_sleep/binary_sensor.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Support for Eight Sleep binary sensors.""" -from __future__ import annotations - -import logging - -from pyeight.eight import EightSleep - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from . import EightSleepBaseEntity, EightSleepConfigEntryData -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) -BINARY_SENSORS = ["bed_presence"] - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up the eight sleep binary sensor.""" - config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] - eight = config_entry_data.api - heat_coordinator = config_entry_data.heat_coordinator - async_add_entities( - EightHeatSensor(entry, heat_coordinator, eight, user.user_id, binary_sensor) - for user in eight.users.values() - for binary_sensor in BINARY_SENSORS - ) - - -class EightHeatSensor(EightSleepBaseEntity, BinarySensorEntity): - """Representation of a Eight Sleep heat-based sensor.""" - - _attr_device_class = BinarySensorDeviceClass.OCCUPANCY - - def __init__( - self, - entry: ConfigEntry, - coordinator: DataUpdateCoordinator, - eight: EightSleep, - user_id: str | None, - sensor: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(entry, coordinator, eight, user_id, sensor) - assert self._user_obj - _LOGGER.debug( - "Presence Sensor: %s, Side: %s, User: %s", - sensor, - self._user_obj.side, - user_id, - ) - - @property - def is_on(self) -> bool: - """Return true if the binary sensor is on.""" - assert self._user_obj - return bool(self._user_obj.bed_presence) diff --git a/homeassistant/components/eight_sleep/config_flow.py b/homeassistant/components/eight_sleep/config_flow.py index 504fbeb28176bc..8839cdf47197d8 100644 --- a/homeassistant/components/eight_sleep/config_flow.py +++ b/homeassistant/components/eight_sleep/config_flow.py @@ -1,90 +1,11 @@ -"""Config flow for Eight Sleep integration.""" -from __future__ import annotations +"""The Eight Sleep integration config flow.""" -import logging -from typing import Any +from homeassistant.config_entries import ConfigFlow -from pyeight.eight import EightSleep -from pyeight.exceptions import RequestError -import voluptuous as vol +from . import DOMAIN -from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.selector import ( - TextSelector, - TextSelectorConfig, - TextSelectorType, -) -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_USERNAME): TextSelector( - TextSelectorConfig(type=TextSelectorType.EMAIL) - ), - vol.Required(CONF_PASSWORD): TextSelector( - TextSelectorConfig(type=TextSelectorType.PASSWORD) - ), - } -) - - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class EightSleepConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Eight Sleep.""" VERSION = 1 - - async def _validate_data(self, config: dict[str, str]) -> str | None: - """Validate input data and return any error.""" - await self.async_set_unique_id(config[CONF_USERNAME].lower()) - self._abort_if_unique_id_configured() - - eight = EightSleep( - config[CONF_USERNAME], - config[CONF_PASSWORD], - self.hass.config.time_zone, - client_session=async_get_clientsession(self.hass), - ) - - try: - await eight.fetch_token() - except RequestError as err: - return str(err) - - return None - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) - - if (err := await self._validate_data(user_input)) is not None: - return self.async_show_form( - step_id="user", - data_schema=STEP_USER_DATA_SCHEMA, - errors={"base": "cannot_connect"}, - description_placeholders={"error": err}, - ) - - return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) - - async def async_step_import(self, import_config: dict) -> FlowResult: - """Handle import.""" - if (err := await self._validate_data(import_config)) is not None: - _LOGGER.error("Unable to import configuration.yaml configuration: %s", err) - return self.async_abort( - reason="cannot_connect", description_placeholders={"error": err} - ) - - return self.async_create_entry( - title=import_config[CONF_USERNAME], data=import_config - ) diff --git a/homeassistant/components/eight_sleep/const.py b/homeassistant/components/eight_sleep/const.py deleted file mode 100644 index 236890666658a3..00000000000000 --- a/homeassistant/components/eight_sleep/const.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Eight Sleep constants.""" -DOMAIN = "eight_sleep" - -HEAT_ENTITY = "heat" -USER_ENTITY = "user" - -NAME_MAP = { - "current_sleep": "Sleep Session", - "current_sleep_fitness": "Sleep Fitness", - "last_sleep": "Previous Sleep Session", -} - -SERVICE_HEAT_SET = "heat_set" - -ATTR_TARGET = "target" -ATTR_DURATION = "duration" diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json index 71e01f75d46754..a4f7482c920c9e 100644 --- a/homeassistant/components/eight_sleep/manifest.json +++ b/homeassistant/components/eight_sleep/manifest.json @@ -1,10 +1,9 @@ { "domain": "eight_sleep", "name": "Eight Sleep", - "codeowners": ["@mezz64", "@raman325"], - "config_flow": true, + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/eight_sleep", + "integration_type": "system", "iot_class": "cloud_polling", - "loggers": ["pyeight"], - "requirements": ["pyEight==0.3.2"] + "requirements": [] } diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py deleted file mode 100644 index e546318a4ddddc..00000000000000 --- a/homeassistant/components/eight_sleep/sensor.py +++ /dev/null @@ -1,301 +0,0 @@ -"""Support for Eight Sleep sensors.""" -from __future__ import annotations - -import logging -from typing import Any - -from pyeight.eight import EightSleep -import voluptuous as vol - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorStateClass, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfTemperature -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, - async_get_current_platform, -) -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from . import EightSleepBaseEntity, EightSleepConfigEntryData -from .const import ATTR_DURATION, ATTR_TARGET, DOMAIN, SERVICE_HEAT_SET - -ATTR_ROOM_TEMP = "Room Temperature" -ATTR_AVG_ROOM_TEMP = "Average Room Temperature" -ATTR_BED_TEMP = "Bed Temperature" -ATTR_AVG_BED_TEMP = "Average Bed Temperature" -ATTR_RESP_RATE = "Respiratory Rate" -ATTR_AVG_RESP_RATE = "Average Respiratory Rate" -ATTR_HEART_RATE = "Heart Rate" -ATTR_AVG_HEART_RATE = "Average Heart Rate" -ATTR_SLEEP_DUR = "Time Slept" -ATTR_LIGHT_PERC = f"Light Sleep {PERCENTAGE}" -ATTR_DEEP_PERC = f"Deep Sleep {PERCENTAGE}" -ATTR_REM_PERC = f"REM Sleep {PERCENTAGE}" -ATTR_TNT = "Tosses & Turns" -ATTR_SLEEP_STAGE = "Sleep Stage" -ATTR_TARGET_HEAT = "Target Heating Level" -ATTR_ACTIVE_HEAT = "Heating Active" -ATTR_DURATION_HEAT = "Heating Time Remaining" -ATTR_PROCESSING = "Processing" -ATTR_SESSION_START = "Session Start" -ATTR_FIT_DATE = "Fitness Date" -ATTR_FIT_DURATION_SCORE = "Fitness Duration Score" -ATTR_FIT_ASLEEP_SCORE = "Fitness Asleep Score" -ATTR_FIT_OUT_SCORE = "Fitness Out-of-Bed Score" -ATTR_FIT_WAKEUP_SCORE = "Fitness Wakeup Score" - -_LOGGER = logging.getLogger(__name__) - -EIGHT_USER_SENSORS = [ - "current_sleep", - "current_sleep_fitness", - "last_sleep", - "bed_temperature", - "sleep_stage", -] -EIGHT_HEAT_SENSORS = ["bed_state"] -EIGHT_ROOM_SENSORS = ["room_temperature"] - -VALID_TARGET_HEAT = vol.All(vol.Coerce(int), vol.Clamp(min=-100, max=100)) -VALID_DURATION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=28800)) - -SERVICE_EIGHT_SCHEMA = { - ATTR_TARGET: VALID_TARGET_HEAT, - ATTR_DURATION: VALID_DURATION, -} - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up the eight sleep sensors.""" - config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] - eight = config_entry_data.api - heat_coordinator = config_entry_data.heat_coordinator - user_coordinator = config_entry_data.user_coordinator - - all_sensors: list[SensorEntity] = [] - - for obj in eight.users.values(): - all_sensors.extend( - EightUserSensor(entry, user_coordinator, eight, obj.user_id, sensor) - for sensor in EIGHT_USER_SENSORS - ) - all_sensors.extend( - EightHeatSensor(entry, heat_coordinator, eight, obj.user_id, sensor) - for sensor in EIGHT_HEAT_SENSORS - ) - - all_sensors.extend( - EightRoomSensor(entry, user_coordinator, eight, sensor) - for sensor in EIGHT_ROOM_SENSORS - ) - - async_add_entities(all_sensors) - - platform = async_get_current_platform() - platform.async_register_entity_service( - SERVICE_HEAT_SET, - SERVICE_EIGHT_SCHEMA, - "async_heat_set", - ) - - -class EightHeatSensor(EightSleepBaseEntity, SensorEntity): - """Representation of an eight sleep heat-based sensor.""" - - _attr_native_unit_of_measurement = PERCENTAGE - - def __init__( - self, - entry: ConfigEntry, - coordinator: DataUpdateCoordinator, - eight: EightSleep, - user_id: str, - sensor: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(entry, coordinator, eight, user_id, sensor) - assert self._user_obj - - _LOGGER.debug( - "Heat Sensor: %s, Side: %s, User: %s", - self._sensor, - self._user_obj.side, - self._user_id, - ) - - @property - def native_value(self) -> int | None: - """Return the state of the sensor.""" - assert self._user_obj - return self._user_obj.heating_level - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return device state attributes.""" - assert self._user_obj - return { - ATTR_TARGET_HEAT: self._user_obj.target_heating_level, - ATTR_ACTIVE_HEAT: self._user_obj.now_heating, - ATTR_DURATION_HEAT: self._user_obj.heating_remaining, - } - - -def _get_breakdown_percent( - attr: dict[str, Any], key: str, denominator: int | float -) -> int | float: - """Get a breakdown percent.""" - try: - return round((attr["breakdown"][key] / denominator) * 100, 2) - except (ZeroDivisionError, KeyError): - return 0 - - -def _get_rounded_value(attr: dict[str, Any], key: str) -> int | float | None: - """Get rounded value for given key.""" - if (val := attr.get(key)) is None: - return None - return round(val, 2) - - -class EightUserSensor(EightSleepBaseEntity, SensorEntity): - """Representation of an eight sleep user-based sensor.""" - - def __init__( - self, - entry: ConfigEntry, - coordinator: DataUpdateCoordinator, - eight: EightSleep, - user_id: str, - sensor: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(entry, coordinator, eight, user_id, sensor) - assert self._user_obj - - if self._sensor == "bed_temperature": - self._attr_icon = "mdi:thermometer" - self._attr_device_class = SensorDeviceClass.TEMPERATURE - self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - elif self._sensor in ("current_sleep", "last_sleep", "current_sleep_fitness"): - self._attr_native_unit_of_measurement = "Score" - - if self._sensor != "sleep_stage": - self._attr_state_class = SensorStateClass.MEASUREMENT - - _LOGGER.debug( - "User Sensor: %s, Side: %s, User: %s", - self._sensor, - self._user_obj.side, - self._user_id, - ) - - @property - def native_value(self) -> str | int | float | None: - """Return the state of the sensor.""" - if not self._user_obj: - return None - - if "current" in self._sensor: - if "fitness" in self._sensor: - return self._user_obj.current_sleep_fitness_score - return self._user_obj.current_sleep_score - - if "last" in self._sensor: - return self._user_obj.last_sleep_score - - if self._sensor == "bed_temperature": - return self._user_obj.current_values["bed_temp"] - - if self._sensor == "sleep_stage": - return self._user_obj.current_values["stage"] - - return None - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return device state attributes.""" - attr = None - if "current" in self._sensor and self._user_obj: - if "fitness" in self._sensor: - attr = self._user_obj.current_fitness_values - else: - attr = self._user_obj.current_values - elif "last" in self._sensor and self._user_obj: - attr = self._user_obj.last_values - - if attr is None: - # Skip attributes if sensor type doesn't support - return None - - if "fitness" in self._sensor: - state_attr = { - ATTR_FIT_DATE: attr["date"], - ATTR_FIT_DURATION_SCORE: attr["duration"], - ATTR_FIT_ASLEEP_SCORE: attr["asleep"], - ATTR_FIT_OUT_SCORE: attr["out"], - ATTR_FIT_WAKEUP_SCORE: attr["wakeup"], - } - return state_attr - - state_attr = {ATTR_SESSION_START: attr["date"]} - state_attr[ATTR_TNT] = attr["tnt"] - state_attr[ATTR_PROCESSING] = attr["processing"] - - if attr.get("breakdown") is not None: - sleep_time = sum(attr["breakdown"].values()) - attr["breakdown"]["awake"] - state_attr[ATTR_SLEEP_DUR] = sleep_time - state_attr[ATTR_LIGHT_PERC] = _get_breakdown_percent( - attr, "light", sleep_time - ) - state_attr[ATTR_DEEP_PERC] = _get_breakdown_percent( - attr, "deep", sleep_time - ) - state_attr[ATTR_REM_PERC] = _get_breakdown_percent(attr, "rem", sleep_time) - - room_temp = _get_rounded_value(attr, "room_temp") - bed_temp = _get_rounded_value(attr, "bed_temp") - - if "current" in self._sensor: - state_attr[ATTR_RESP_RATE] = _get_rounded_value(attr, "resp_rate") - state_attr[ATTR_HEART_RATE] = _get_rounded_value(attr, "heart_rate") - state_attr[ATTR_SLEEP_STAGE] = attr["stage"] - state_attr[ATTR_ROOM_TEMP] = room_temp - state_attr[ATTR_BED_TEMP] = bed_temp - elif "last" in self._sensor: - state_attr[ATTR_AVG_RESP_RATE] = _get_rounded_value(attr, "resp_rate") - state_attr[ATTR_AVG_HEART_RATE] = _get_rounded_value(attr, "heart_rate") - state_attr[ATTR_AVG_ROOM_TEMP] = room_temp - state_attr[ATTR_AVG_BED_TEMP] = bed_temp - - return state_attr - - -class EightRoomSensor(EightSleepBaseEntity, SensorEntity): - """Representation of an eight sleep room sensor.""" - - _attr_icon = "mdi:thermometer" - _attr_device_class = SensorDeviceClass.TEMPERATURE - _attr_state_class = SensorStateClass.MEASUREMENT - _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - - def __init__( - self, - entry, - coordinator: DataUpdateCoordinator, - eight: EightSleep, - sensor: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(entry, coordinator, eight, None, sensor) - - @property - def native_value(self) -> int | float | None: - """Return the state of the sensor.""" - return self._eight.room_temperature diff --git a/homeassistant/components/eight_sleep/services.yaml b/homeassistant/components/eight_sleep/services.yaml deleted file mode 100644 index b191187bb0ac6e..00000000000000 --- a/homeassistant/components/eight_sleep/services.yaml +++ /dev/null @@ -1,20 +0,0 @@ -heat_set: - target: - entity: - integration: eight_sleep - domain: sensor - fields: - duration: - required: true - selector: - number: - min: 0 - max: 28800 - unit_of_measurement: seconds - target: - required: true - selector: - number: - min: -100 - max: 100 - unit_of_measurement: "°" diff --git a/homeassistant/components/eight_sleep/strings.json b/homeassistant/components/eight_sleep/strings.json index b2fb73cc020867..15773084462cbc 100644 --- a/homeassistant/components/eight_sleep/strings.json +++ b/homeassistant/components/eight_sleep/strings.json @@ -1,35 +1,8 @@ { - "config": { - "step": { - "user": { - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "cannot_connect": "Cannot connect to Eight Sleep cloud: {error}" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:component::eight_sleep::config::error::cannot_connect%]" - } - }, - "services": { - "heat_set": { - "name": "Heat set", - "description": "Sets heating/cooling level for eight sleep.", - "fields": { - "duration": { - "name": "Duration", - "description": "Duration to heat/cool at the target level in seconds." - }, - "target": { - "name": "Target", - "description": "Target cooling/heating level from -100 to 100." - } - } + "issues": { + "integration_removed": { + "title": "The Eight Sleep integration has been removed", + "description": "The Eight Sleep integration has been removed from Home Assistant.\n\nThe Eight Sleep API has changed and now requires a unique secret which is inaccessible outside of their apps.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Eight Sleep integration entries]({entries})." } } } diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 21a8141647dae7..e53200c2e90ba9 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -29,6 +29,7 @@ SensorInfo, SensorState, SwitchInfo, + TextInfo, TextSensorInfo, UserService, build_unique_id, @@ -68,6 +69,7 @@ SelectInfo: Platform.SELECT, SensorInfo: Platform.SENSOR, SwitchInfo: Platform.SWITCH, + TextInfo: Platform.TEXT, TextSensorInfo: Platform.SENSOR, } diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 812cf430d09c0b..d2eca7d39f9240 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -596,6 +596,10 @@ def _async_setup_device_registry( model = project_name[1] hw_version = device_info.project_version + suggested_area = None + if device_info.suggested_area: + suggested_area = device_info.suggested_area + device_registry = dr.async_get(hass) device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -606,6 +610,7 @@ def _async_setup_device_registry( model=model, sw_version=sw_version, hw_version=hw_version, + suggested_area=suggested_area, ) return device_entry.id diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 4cade9078999ae..8968fa7da4f04a 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.0.11", + "aioesphomeapi==18.2.0", "bluetooth-data-tools==1.13.0", "esphome-dashboard-api==1.2.3" ], diff --git a/homeassistant/components/esphome/text.py b/homeassistant/components/esphome/text.py new file mode 100644 index 00000000000000..49049eecfd4ae3 --- /dev/null +++ b/homeassistant/components/esphome/text.py @@ -0,0 +1,63 @@ +"""Support for esphome texts.""" +from __future__ import annotations + +from aioesphomeapi import EntityInfo, TextInfo, TextMode as EsphomeTextMode, TextState + +from homeassistant.components.text import TextEntity, TextMode +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up esphome texts based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + info_type=TextInfo, + entity_type=EsphomeText, + state_type=TextState, + ) + + +TEXT_MODES: EsphomeEnumMapper[EsphomeTextMode, TextMode] = EsphomeEnumMapper( + { + EsphomeTextMode.TEXT: TextMode.TEXT, + EsphomeTextMode.PASSWORD: TextMode.PASSWORD, + } +) + + +class EsphomeText(EsphomeEntity[TextInfo, TextState], TextEntity): + """A text 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 + self._attr_native_min = static_info.min_length + self._attr_native_max = static_info.max_length + self._attr_pattern = static_info.pattern + self._attr_mode = TEXT_MODES.from_esphome(static_info.mode) or TextMode.TEXT + + @property + @esphome_state_property + def native_value(self) -> str | None: + """Return the state of the entity.""" + state = self._state + if state.missing_state: + return None + return state.state + + async def async_set_value(self, value: str) -> None: + """Update the current value.""" + await self._client.text_command(self._key, value) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 2aa0cd42fe1aad..4b79ef3df1bd4c 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -487,6 +487,18 @@ def get_session_id(client_v1) -> str | None: ) self.temps = None # these are now stale, will fall back to v2 temps + except KeyError as err: + _LOGGER.warning( + ( + "Unable to obtain high-precision temperatures. " + "It appears the JSON schema is not as expected, " + "so the high-precision feature will be disabled until next restart." + "Message is: %s" + ), + err, + ) + self.client_v1 = self.temps = None + else: if ( str(self.client_v1.location_id) @@ -495,7 +507,7 @@ def get_session_id(client_v1) -> str | None: _LOGGER.warning( "The v2 API's configured location doesn't match " "the v1 API's default location (there is more than one location), " - "so the high-precision feature will be disabled" + "so the high-precision feature will be disabled until next restart" ) self.client_v1 = self.temps = None else: diff --git a/homeassistant/components/fan/device_action.py b/homeassistant/components/fan/device_action.py index 55bd862349b4ca..fc7f1ddce1f060 100644 --- a/homeassistant/components/fan/device_action.py +++ b/homeassistant/components/fan/device_action.py @@ -3,14 +3,24 @@ import voluptuous as vol -from homeassistant.components.device_automation import toggle_entity +from homeassistant.components.device_automation import ( + async_validate_entity_schema, + toggle_entity, +) from homeassistant.const import CONF_DOMAIN from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN -ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) +_ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) + + +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) async def async_get_actions( diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 45b8ea21b0ea10..d0d939ce67e3fa 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -8,6 +8,8 @@ import os from typing import Any, Final, cast +from fitbit import Fitbit +from oauthlib.oauth2.rfc6749.errors import OAuth2Error import voluptuous as vol from homeassistant.components.application_credentials import ( @@ -567,34 +569,54 @@ async def async_setup_platform( if config_file is not None: _LOGGER.debug("Importing existing fitbit.conf application credentials") - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential( - config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET] - ), - ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - "auth_implementation": DOMAIN, - CONF_TOKEN: { - ATTR_ACCESS_TOKEN: config_file[ATTR_ACCESS_TOKEN], - ATTR_REFRESH_TOKEN: config_file[ATTR_REFRESH_TOKEN], - "expires_at": config_file[ATTR_LAST_SAVED_AT], - }, - CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT], - CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM], - CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES], - }, + + # Refresh the token before importing to ensure it is working and not + # expired on first initialization. + authd_client = Fitbit( + config_file[CONF_CLIENT_ID], + config_file[CONF_CLIENT_SECRET], + access_token=config_file[ATTR_ACCESS_TOKEN], + refresh_token=config_file[ATTR_REFRESH_TOKEN], + expires_at=config_file[ATTR_LAST_SAVED_AT], + refresh_cb=lambda x: None, ) - translation_key = "deprecated_yaml_import" - if ( - result.get("type") == FlowResultType.ABORT - and result.get("reason") == "cannot_connect" - ): + try: + updated_token = await hass.async_add_executor_job( + authd_client.client.refresh_token + ) + except OAuth2Error as err: + _LOGGER.debug("Unable to import fitbit OAuth2 credentials: %s", err) translation_key = "deprecated_yaml_import_issue_cannot_connect" + else: + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET] + ), + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + "auth_implementation": DOMAIN, + CONF_TOKEN: { + ATTR_ACCESS_TOKEN: updated_token[ATTR_ACCESS_TOKEN], + ATTR_REFRESH_TOKEN: updated_token[ATTR_REFRESH_TOKEN], + "expires_at": updated_token["expires_at"], + "scope": " ".join(updated_token.get("scope", [])), + }, + CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT], + CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM], + CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES], + }, + ) + translation_key = "deprecated_yaml_import" + if ( + result.get("type") == FlowResultType.ABORT + and result.get("reason") == "cannot_connect" + ): + translation_key = "deprecated_yaml_import_issue_cannot_connect" else: translation_key = "deprecated_yaml_no_import" diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py index 294f50c50e29d2..9a96233e6a9995 100644 --- a/homeassistant/components/flume/__init__.py +++ b/homeassistant/components/flume/__init__.py @@ -2,6 +2,7 @@ from pyflume import FlumeAuth, FlumeDeviceList from requests import Session from requests.exceptions import RequestException +import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -10,8 +11,14 @@ CONF_PASSWORD, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.selector import ConfigEntrySelector from .const import ( BASE_TOKEN_FILENAME, @@ -19,8 +26,18 @@ FLUME_AUTH, FLUME_DEVICES, FLUME_HTTP_SESSION, + FLUME_NOTIFICATIONS_COORDINATOR, PLATFORMS, ) +from .coordinator import FlumeNotificationDataUpdateCoordinator + +SERVICE_LIST_NOTIFICATIONS = "list_notifications" +CONF_CONFIG_ENTRY = "config_entry" +LIST_NOTIFICATIONS_SERVICE_SCHEMA = vol.All( + { + vol.Required(CONF_CONFIG_ENTRY): ConfigEntrySelector(), + }, +) def _setup_entry(hass: HomeAssistant, entry: ConfigEntry): @@ -59,14 +76,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: flume_auth, flume_devices, http_session = await hass.async_add_executor_job( _setup_entry, hass, entry ) + notification_coordinator = FlumeNotificationDataUpdateCoordinator( + hass=hass, auth=flume_auth + ) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { FLUME_DEVICES: flume_devices, FLUME_AUTH: flume_auth, FLUME_HTTP_SESSION: http_session, + FLUME_NOTIFICATIONS_COORDINATOR: notification_coordinator, } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await async_setup_service(hass) return True @@ -81,3 +103,29 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def async_setup_service(hass: HomeAssistant) -> None: + """Add the services for the flume integration.""" + + async def list_notifications(call: ServiceCall) -> ServiceResponse: + """Return the user notifications.""" + entry_id: str = call.data[CONF_CONFIG_ENTRY] + entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) + if not entry: + raise ValueError(f"Invalid config entry: {entry_id}") + if not (flume_domain_data := hass.data[DOMAIN].get(entry_id)): + raise ValueError(f"Config entry not loaded: {entry_id}") + return { + "notifications": flume_domain_data[ + FLUME_NOTIFICATIONS_COORDINATOR + ].notifications + } + + hass.services.async_register( + DOMAIN, + SERVICE_LIST_NOTIFICATIONS, + list_notifications, + schema=LIST_NOTIFICATIONS_SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/flume/binary_sensor.py b/homeassistant/components/flume/binary_sensor.py index c912c3419d7b41..2305cd9f23eb49 100644 --- a/homeassistant/components/flume/binary_sensor.py +++ b/homeassistant/components/flume/binary_sensor.py @@ -15,8 +15,8 @@ from .const import ( DOMAIN, - FLUME_AUTH, FLUME_DEVICES, + FLUME_NOTIFICATIONS_COORDINATOR, FLUME_TYPE_BRIDGE, FLUME_TYPE_SENSOR, KEY_DEVICE_ID, @@ -84,7 +84,6 @@ async def async_setup_entry( ) -> None: """Set up a Flume binary sensor..""" flume_domain_data = hass.data[DOMAIN][config_entry.entry_id] - flume_auth = flume_domain_data[FLUME_AUTH] flume_devices = flume_domain_data[FLUME_DEVICES] flume_entity_list: list[ @@ -94,9 +93,7 @@ async def async_setup_entry( connection_coordinator = FlumeDeviceConnectionUpdateCoordinator( hass=hass, flume_devices=flume_devices ) - notification_coordinator = FlumeNotificationDataUpdateCoordinator( - hass=hass, auth=flume_auth - ) + notification_coordinator = flume_domain_data[FLUME_NOTIFICATIONS_COORDINATOR] flume_devices = get_valid_flume_devices(flume_devices) for device in flume_devices: device_id = device[KEY_DEVICE_ID] diff --git a/homeassistant/components/flume/const.py b/homeassistant/components/flume/const.py index 9e932cce4ddfc9..a4e7dba444e8f8 100644 --- a/homeassistant/components/flume/const.py +++ b/homeassistant/components/flume/const.py @@ -29,7 +29,7 @@ FLUME_AUTH = "flume_auth" FLUME_HTTP_SESSION = "http_session" FLUME_DEVICES = "devices" - +FLUME_NOTIFICATIONS_COORDINATOR = "notifications_coordinator" CONF_TOKEN_FILE = "token_filename" BASE_TOKEN_FILENAME = "FLUME_TOKEN_FILE" diff --git a/homeassistant/components/flume/services.yaml b/homeassistant/components/flume/services.yaml new file mode 100644 index 00000000000000..e6f3d908a093d8 --- /dev/null +++ b/homeassistant/components/flume/services.yaml @@ -0,0 +1,7 @@ +list_notifications: + fields: + config_entry: + required: true + selector: + config_entry: + integration: flume diff --git a/homeassistant/components/flume/strings.json b/homeassistant/components/flume/strings.json index 2c1a900c091842..5f3021960b5d8c 100644 --- a/homeassistant/components/flume/strings.json +++ b/homeassistant/components/flume/strings.json @@ -61,5 +61,17 @@ "name": "30 days" } } + }, + "services": { + "list_notifications": { + "name": "List notifications", + "description": "Return user notifications.", + "fields": { + "config_entry": { + "name": "Flume", + "description": "The flume config entry for which to return notifications." + } + } + } } } diff --git a/homeassistant/components/freebox/alarm_control_panel.py b/homeassistant/components/freebox/alarm_control_panel.py new file mode 100644 index 00000000000000..52b7109045cf78 --- /dev/null +++ b/homeassistant/components/freebox/alarm_control_panel.py @@ -0,0 +1,138 @@ +"""Support for Freebox alarms.""" +import logging +from typing import Any + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMING, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, FreeboxHomeCategory +from .home_base import FreeboxHomeEntity +from .router import FreeboxRouter + +FREEBOX_TO_STATUS = { + "alarm1_arming": STATE_ALARM_ARMING, + "alarm2_arming": STATE_ALARM_ARMING, + "alarm1_armed": STATE_ALARM_ARMED_AWAY, + "alarm2_armed": STATE_ALARM_ARMED_NIGHT, + "alarm1_alert_timer": STATE_ALARM_TRIGGERED, + "alarm2_alert_timer": STATE_ALARM_TRIGGERED, + "alert": STATE_ALARM_TRIGGERED, +} + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up alarm panel.""" + router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + + alarm_entities: list[AlarmControlPanelEntity] = [] + + for node in router.home_devices.values(): + if node["category"] == FreeboxHomeCategory.ALARM: + alarm_entities.append(FreeboxAlarm(hass, router, node)) + + if alarm_entities: + async_add_entities(alarm_entities, True) + + +class FreeboxAlarm(FreeboxHomeEntity, AlarmControlPanelEntity): + """Representation of a Freebox alarm.""" + + def __init__( + self, hass: HomeAssistant, router: FreeboxRouter, node: dict[str, Any] + ) -> None: + """Initialize an alarm.""" + super().__init__(hass, router, node) + + # Commands + self._command_trigger = self.get_command_id( + node["type"]["endpoints"], "slot", "trigger" + ) + self._command_arm_away = self.get_command_id( + node["type"]["endpoints"], "slot", "alarm1" + ) + self._command_arm_home = self.get_command_id( + node["type"]["endpoints"], "slot", "alarm2" + ) + self._command_disarm = self.get_command_id( + node["type"]["endpoints"], "slot", "off" + ) + self._command_state = self.get_command_id( + node["type"]["endpoints"], "signal", "state" + ) + self._set_features(self._router.home_devices[self._id]) + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + if await self.set_home_endpoint_value(self._command_disarm): + self._set_state(STATE_ALARM_DISARMED) + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + if await self.set_home_endpoint_value(self._command_arm_away): + self._set_state(STATE_ALARM_ARMING) + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + if await self.set_home_endpoint_value(self._command_arm_home): + self._set_state(STATE_ALARM_ARMING) + + async def async_alarm_trigger(self, code: str | None = None) -> None: + """Send alarm trigger command.""" + if await self.set_home_endpoint_value(self._command_trigger): + self._set_state(STATE_ALARM_TRIGGERED) + + async def async_update_signal(self): + """Update signal.""" + state = await self.get_home_endpoint_value(self._command_state) + if state: + self._set_state(state) + + def _set_features(self, node: dict[str, Any]) -> None: + """Add alarm features.""" + # Search if the arm home feature is present => has an "alarm2" endpoint + can_arm_home = False + for nodeid, local_node in self._router.home_devices.items(): + if nodeid == local_node["id"]: + alarm2 = next( + filter( + lambda x: (x["name"] == "alarm2" and x["ep_type"] == "signal"), + local_node["show_endpoints"], + ), + None, + ) + if alarm2: + can_arm_home = alarm2["value"] + break + + if can_arm_home: + self._attr_supported_features = ( + AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_HOME + ) + + else: + self._attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY + + def _set_state(self, state: str) -> None: + """Update state.""" + self._attr_state = FREEBOX_TO_STATUS.get(state) + if not self._attr_state: + self._attr_state = STATE_ALARM_DISARMED + self.async_write_ha_state() diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 0c3450d13b628d..f74f6f49ebf1e7 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -18,6 +18,7 @@ API_VERSION = "v6" PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, @@ -84,6 +85,7 @@ class FreeboxHomeCategory(enum.StrEnum): } HOME_COMPATIBLE_CATEGORIES = [ + FreeboxHomeCategory.ALARM, FreeboxHomeCategory.CAMERA, FreeboxHomeCategory.DWS, FreeboxHomeCategory.IOHOME, diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 6d5e43a94eed33..dfc76ae14157c8 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -99,6 +99,9 @@ class FroniusSensorEntityDescription(SensorEntityDescription): """Describes Fronius sensor entity.""" default_value: StateType | None = None + # Gen24 devices may report 0 for total energy while doing firmware updates. + # Handling such values shall mitigate spikes in delta calculations. + invalid_when_falsy: bool = False INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ @@ -119,6 +122,7 @@ class FroniusSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="frequency_ac", @@ -253,6 +257,7 @@ class FroniusSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:lightning-bolt-outline", entity_registry_enabled_default=False, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_reactive_ac_produced", @@ -260,6 +265,7 @@ class FroniusSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:lightning-bolt-outline", entity_registry_enabled_default=False, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_real_ac_minus", @@ -267,6 +273,7 @@ class FroniusSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_real_ac_plus", @@ -274,18 +281,21 @@ class FroniusSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_real_consumed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_real_produced", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="frequency_phase_average", @@ -461,6 +471,7 @@ class FroniusSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="power_real_ac", @@ -508,6 +519,7 @@ class FroniusSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, entity_registry_enabled_default=False, ), FroniusSensorEntityDescription( @@ -648,6 +660,8 @@ def _get_entity_value(self) -> Any: ]["value"] if new_value is None: return self.entity_description.default_value + if self.entity_description.invalid_when_falsy and not new_value: + raise ValueError(f"Ignoring zero value for {self.entity_id}.") if isinstance(new_value, float): return round(new_value, 4) return new_value @@ -657,8 +671,10 @@ def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" try: self._attr_native_value = self._get_entity_value() - except KeyError: + except (KeyError, ValueError): # sets state to `None` if no default_value is defined in entity description + # KeyError: raised when omitted in response - eg. at night when no production + # ValueError: raised when invalid zero value received self._attr_native_value = self.entity_description.default_value self.async_write_ha_state() diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index e8a71d23adf287..2ec991750f0f1a 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -388,6 +388,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Can be removed in 2023 hass.http.register_redirect("/config/server_control", "/developer-tools/yaml") + # Shopping list panel was replaced by todo panel in 2023.11 + hass.http.register_redirect("/shopping-list", "/todo") + hass.http.app.router.register_resource(IndexView(repo_path, hass)) async_register_built_in_panel(hass, "profile") @@ -593,7 +596,7 @@ def get_template(self) -> jinja2.Template: async def get(self, request: web.Request) -> web.Response: """Serve the index page for panel pages.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] if not onboarding.async_is_onboarded(hass): return web.Response(status=302, headers={"location": "/onboarding.html"}) @@ -602,12 +605,20 @@ async def get(self, request: web.Request) -> web.Response: self.get_template ) + extra_modules: frozenset[str] + extra_js_es5: frozenset[str] + if hass.config.safe_mode: + extra_modules = frozenset() + extra_js_es5 = frozenset() + else: + extra_modules = hass.data[DATA_EXTRA_MODULE_URL].urls + extra_js_es5 = hass.data[DATA_EXTRA_JS_URL_ES5].urls return web.Response( text=_async_render_index_cached( template, theme_color=MANIFEST_JSON["theme_color"], - extra_modules=hass.data[DATA_EXTRA_MODULE_URL].urls, - extra_js_es5=hass.data[DATA_EXTRA_JS_URL_ES5].urls, + extra_modules=extra_modules, + extra_js_es5=extra_js_es5, ), content_type="text/html", ) @@ -658,18 +669,13 @@ def websocket_get_themes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get themes command.""" - if hass.config.recovery_mode: + if hass.config.recovery_mode or hass.config.safe_mode: connection.send_message( websocket_api.result_message( msg["id"], { - "themes": { - "recovery_mode": { - "primary-color": "#db4437", - "accent-color": "#ffca28", - } - }, - "default_theme": "recovery_mode", + "themes": {}, + "default_theme": "default", }, ) ) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 0d1c16594717f4..a47ef38264eec0 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20231005.0"] + "requirements": ["home-assistant-frontend==20231027.0"] } diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 641a267e987661..223abe26e555ea 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -126,7 +126,8 @@ async def async_update(self) -> None: if not self._attr_source_list: self.__modes_by_label = { - mode.label: mode.key for mode in await afsapi.get_modes() + (mode.label if mode.label else mode.id): mode.key + for mode in await afsapi.get_modes() } self._attr_source_list = list(self.__modes_by_label) diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 41954645f5c4c3..fece0b09f60fdc 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], "quality_scale": "platinum", - "requirements": ["gios==3.1.0"] + "requirements": ["gios==3.2.0"] } diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index f40d2253614c20..03575f9f4e2e48 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/goodwe", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.2.31"] + "requirements": ["goodwe==0.2.32"] } diff --git a/homeassistant/components/google_tasks/__init__.py b/homeassistant/components/google_tasks/__init__.py new file mode 100644 index 00000000000000..da6fc85b2877c0 --- /dev/null +++ b/homeassistant/components/google_tasks/__init__.py @@ -0,0 +1,46 @@ +"""The Google Tasks integration.""" +from __future__ import annotations + +from aiohttp import ClientError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_entry_oauth2_flow + +from . import api +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.TODO] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Google Tasks from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + auth = api.AsyncConfigEntryAuth(hass, session) + try: + await auth.async_get_access_token() + except ClientError as err: + raise ConfigEntryNotReady from err + + hass.data[DOMAIN][entry.entry_id] = auth + + 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/google_tasks/api.py b/homeassistant/components/google_tasks/api.py new file mode 100644 index 00000000000000..d42926c3bf6b01 --- /dev/null +++ b/homeassistant/components/google_tasks/api.py @@ -0,0 +1,81 @@ +"""API for Google Tasks bound to Home Assistant OAuth.""" + +from typing import Any + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import Resource, build +from googleapiclient.http import HttpRequest + +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + +MAX_TASK_RESULTS = 100 + + +class AsyncConfigEntryAuth: + """Provide Google Tasks authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: HomeAssistant, + oauth2_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Google Tasks Auth.""" + self._hass = hass + self._oauth_session = oauth2_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + return self._oauth_session.token[CONF_ACCESS_TOKEN] + + async def _get_service(self) -> Resource: + """Get current resource.""" + token = await self.async_get_access_token() + return build("tasks", "v1", credentials=Credentials(token=token)) + + async def list_task_lists(self) -> list[dict[str, Any]]: + """Get all TaskList resources.""" + service = await self._get_service() + cmd: HttpRequest = service.tasklists().list() + result = await self._hass.async_add_executor_job(cmd.execute) + return result["items"] + + async def list_tasks(self, task_list_id: str) -> list[dict[str, Any]]: + """Get all Task resources for the task list.""" + service = await self._get_service() + cmd: HttpRequest = service.tasks().list( + tasklist=task_list_id, maxResults=MAX_TASK_RESULTS + ) + result = await self._hass.async_add_executor_job(cmd.execute) + return result["items"] + + async def insert( + self, + task_list_id: str, + task: dict[str, Any], + ) -> None: + """Create a new Task resource on the task list.""" + service = await self._get_service() + cmd: HttpRequest = service.tasks().insert( + tasklist=task_list_id, + body=task, + ) + await self._hass.async_add_executor_job(cmd.execute) + + async def patch( + self, + task_list_id: str, + task_id: str, + task: dict[str, Any], + ) -> None: + """Update a task resource.""" + service = await self._get_service() + cmd: HttpRequest = service.tasks().patch( + tasklist=task_list_id, + task=task_id, + body=task, + ) + await self._hass.async_add_executor_job(cmd.execute) diff --git a/homeassistant/components/google_tasks/application_credentials.py b/homeassistant/components/google_tasks/application_credentials.py new file mode 100644 index 00000000000000..223e723f258527 --- /dev/null +++ b/homeassistant/components/google_tasks/application_credentials.py @@ -0,0 +1,23 @@ +"""Application credentials platform for the Google Tasks integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_tasks/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + } diff --git a/homeassistant/components/google_tasks/config_flow.py b/homeassistant/components/google_tasks/config_flow.py new file mode 100644 index 00000000000000..77570f0377f7df --- /dev/null +++ b/homeassistant/components/google_tasks/config_flow.py @@ -0,0 +1,30 @@ +"""Config flow for Google Tasks.""" +import logging +from typing import Any + +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN, OAUTH2_SCOPES + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Google Tasks OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": " ".join(OAUTH2_SCOPES), + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } diff --git a/homeassistant/components/google_tasks/const.py b/homeassistant/components/google_tasks/const.py new file mode 100644 index 00000000000000..872534861272e5 --- /dev/null +++ b/homeassistant/components/google_tasks/const.py @@ -0,0 +1,16 @@ +"""Constants for the Google Tasks integration.""" + +from enum import StrEnum + +DOMAIN = "google_tasks" + +OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth" +OAUTH2_TOKEN = "https://oauth2.googleapis.com/token" +OAUTH2_SCOPES = ["https://www.googleapis.com/auth/tasks"] + + +class TaskStatus(StrEnum): + """Status of a Google Task.""" + + NEEDS_ACTION = "needsAction" + COMPLETED = "completed" diff --git a/homeassistant/components/google_tasks/coordinator.py b/homeassistant/components/google_tasks/coordinator.py new file mode 100644 index 00000000000000..5377e2be567625 --- /dev/null +++ b/homeassistant/components/google_tasks/coordinator.py @@ -0,0 +1,38 @@ +"""Coordinator for fetching data from Google Tasks API.""" + +import asyncio +import datetime +import logging +from typing import Any, Final + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .api import AsyncConfigEntryAuth + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL: Final = datetime.timedelta(minutes=30) +TIMEOUT = 10 + + +class TaskUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): + """Coordinator for fetching Google Tasks for a Task List form the API.""" + + def __init__( + self, hass: HomeAssistant, api: AsyncConfigEntryAuth, task_list_id: str + ) -> None: + """Initialize TaskUpdateCoordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"Google Tasks {task_list_id}", + update_interval=UPDATE_INTERVAL, + ) + self.api = api + self._task_list_id = task_list_id + + async def _async_update_data(self) -> list[dict[str, Any]]: + """Fetch tasks from API endpoint.""" + async with asyncio.timeout(TIMEOUT): + return await self.api.list_tasks(self._task_list_id) diff --git a/homeassistant/components/google_tasks/manifest.json b/homeassistant/components/google_tasks/manifest.json new file mode 100644 index 00000000000000..08f2a54d0517ed --- /dev/null +++ b/homeassistant/components/google_tasks/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "google_tasks", + "name": "Google Tasks", + "codeowners": ["@allenporter"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/google_tasks", + "iot_class": "cloud_polling", + "requirements": ["google-api-python-client==2.71.0"] +} diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json new file mode 100644 index 00000000000000..e7dbbc2b625164 --- /dev/null +++ b/homeassistant/components/google_tasks/strings.json @@ -0,0 +1,24 @@ +{ + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Tasks. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + }, + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py new file mode 100644 index 00000000000000..5d2da33da71561 --- /dev/null +++ b/homeassistant/components/google_tasks/todo.py @@ -0,0 +1,116 @@ +"""Google Tasks todo platform.""" +from __future__ import annotations + +from datetime import timedelta +from typing import cast + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .api import AsyncConfigEntryAuth +from .const import DOMAIN +from .coordinator import TaskUpdateCoordinator + +SCAN_INTERVAL = timedelta(minutes=15) + +TODO_STATUS_MAP = { + "needsAction": TodoItemStatus.NEEDS_ACTION, + "completed": TodoItemStatus.COMPLETED, +} +TODO_STATUS_MAP_INV = {v: k for k, v in TODO_STATUS_MAP.items()} + + +def _convert_todo_item(item: TodoItem) -> dict[str, str]: + """Convert TodoItem dataclass items to dictionary of attributes the tasks API.""" + result: dict[str, str] = {} + if item.summary is not None: + result["title"] = item.summary + if item.status is not None: + result["status"] = TODO_STATUS_MAP_INV[item.status] + return result + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Google Tasks todo platform.""" + api: AsyncConfigEntryAuth = hass.data[DOMAIN][entry.entry_id] + task_lists = await api.list_task_lists() + async_add_entities( + ( + GoogleTaskTodoListEntity( + TaskUpdateCoordinator(hass, api, task_list["id"]), + task_list["title"], + entry.entry_id, + task_list["id"], + ) + for task_list in task_lists + ), + True, + ) + + +class GoogleTaskTodoListEntity( + CoordinatorEntity[TaskUpdateCoordinator], TodoListEntity +): + """A To-do List representation of the Shopping List.""" + + _attr_has_entity_name = True + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM + ) + + def __init__( + self, + coordinator: TaskUpdateCoordinator, + name: str, + config_entry_id: str, + task_list_id: str, + ) -> None: + """Initialize LocalTodoListEntity.""" + super().__init__(coordinator) + self._attr_name = name.capitalize() + self._attr_unique_id = f"{config_entry_id}-{task_list_id}" + self._task_list_id = task_list_id + + @property + def todo_items(self) -> list[TodoItem] | None: + """Get the current set of To-do items.""" + if self.coordinator.data is None: + return None + return [ + TodoItem( + summary=item["title"], + uid=item["id"], + status=TODO_STATUS_MAP.get( + item.get("status"), TodoItemStatus.NEEDS_ACTION # type: ignore[arg-type] + ), + ) + for item in self.coordinator.data + ] + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + await self.coordinator.api.insert( + self._task_list_id, + task=_convert_todo_item(item), + ) + await self.coordinator.async_refresh() + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update a To-do item.""" + uid: str = cast(str, item.uid) + await self.coordinator.api.patch( + self._task_list_id, + uid, + task=_convert_todo_item(item), + ) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 82c2651e76429d..1092bc5834b054 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -294,7 +294,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def reload_service_handler(service: ServiceCall) -> None: """Remove all user-defined groups and load new ones from config.""" - auto = [e for e in component.entities if not e.user_defined] + auto = [e for e in component.entities if e.created_by_service] if (conf := await component.async_prepare_reload()) is None: return @@ -329,20 +329,15 @@ async def groups_service_handler(service: ServiceCall) -> None: or None ) - extra_arg = { - attr: service.data[attr] - for attr in (ATTR_ICON,) - if service.data.get(attr) is not None - } - await Group.async_create_group( hass, service.data.get(ATTR_NAME, object_id), - object_id=object_id, + created_by_service=True, entity_ids=entity_ids, - user_defined=False, + icon=service.data.get(ATTR_ICON), mode=service.data.get(ATTR_ALL), - **extra_arg, + object_id=object_id, + order=None, ) return @@ -449,7 +444,8 @@ async def _async_process_config(hass: HomeAssistant, config: ConfigType) -> None Group.async_create_group_entity( hass, name, - entity_ids, + created_by_service=False, + entity_ids=entity_ids, icon=icon, object_id=object_id, mode=mode, @@ -570,11 +566,12 @@ def __init__( self, hass: HomeAssistant, name: str, - order: int | None = None, - icon: str | None = None, - user_defined: bool = True, - entity_ids: Collection[str] | None = None, - mode: bool | None = None, + *, + created_by_service: bool, + entity_ids: Collection[str] | None, + icon: str | None, + mode: bool | None, + order: int | None, ) -> None: """Initialize a group. @@ -588,7 +585,7 @@ def __init__( self._on_off: dict[str, bool] = {} self._assumed: dict[str, bool] = {} self._on_states: set[str] = set() - self.user_defined = user_defined + self.created_by_service = created_by_service self.mode = any if mode: self.mode = all @@ -596,36 +593,18 @@ def __init__( self._assumed_state = False self._async_unsub_state_changed: CALLBACK_TYPE | None = None - @staticmethod - def create_group( - hass: HomeAssistant, - name: str, - entity_ids: Collection[str] | None = None, - user_defined: bool = True, - icon: str | None = None, - object_id: str | None = None, - mode: bool | None = None, - order: int | None = None, - ) -> Group: - """Initialize a group.""" - return asyncio.run_coroutine_threadsafe( - Group.async_create_group( - hass, name, entity_ids, user_defined, icon, object_id, mode, order - ), - hass.loop, - ).result() - @staticmethod @callback def async_create_group_entity( hass: HomeAssistant, name: str, - entity_ids: Collection[str] | None = None, - user_defined: bool = True, - icon: str | None = None, - object_id: str | None = None, - mode: bool | None = None, - order: int | None = None, + *, + created_by_service: bool, + entity_ids: Collection[str] | None, + icon: str | None, + mode: bool | None, + object_id: str | None, + order: int | None, ) -> Group: """Create a group entity.""" if order is None: @@ -639,11 +618,11 @@ def async_create_group_entity( group = Group( hass, name, - order=order, - icon=icon, - user_defined=user_defined, + created_by_service=created_by_service, entity_ids=entity_ids, + icon=icon, mode=mode, + order=order, ) group.entity_id = async_generate_entity_id( @@ -656,19 +635,27 @@ def async_create_group_entity( async def async_create_group( hass: HomeAssistant, name: str, - entity_ids: Collection[str] | None = None, - user_defined: bool = True, - icon: str | None = None, - object_id: str | None = None, - mode: bool | None = None, - order: int | None = None, + *, + created_by_service: bool, + entity_ids: Collection[str] | None, + icon: str | None, + mode: bool | None, + object_id: str | None, + order: int | None, ) -> Group: """Initialize a group. This method must be run in the event loop. """ group = Group.async_create_group_entity( - hass, name, entity_ids, user_defined, icon, object_id, mode, order + hass, + name, + created_by_service=created_by_service, + entity_ids=entity_ids, + icon=icon, + mode=mode, + object_id=object_id, + order=order, ) # If called before the platform async_setup is called (test cases) @@ -704,7 +691,7 @@ def icon(self, value: str | None) -> None: def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes for the group.""" data = {ATTR_ENTITY_ID: self.tracking, ATTR_ORDER: self._order} - if not self.user_defined: + if self.created_by_service: data[ATTR_AUTO] = True return data diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 78e9c40cebd557..e7ab7aac3c85d9 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -34,6 +34,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.event import async_call_later from homeassistant.helpers.storage import Store @@ -74,6 +75,7 @@ DATA_KEY_SUPERVISOR, DATA_KEY_SUPERVISOR_ISSUES, DOMAIN, + REQUEST_REFRESH_DELAY, SUPERVISOR_CONTAINER, SupervisorEntityModel, ) @@ -334,7 +336,7 @@ def get_addons_stats(hass): Async friendly. """ - return hass.data.get(DATA_ADDONS_STATS) + return hass.data.get(DATA_ADDONS_STATS) or {} @callback @@ -344,7 +346,7 @@ def get_core_stats(hass): Async friendly. """ - return hass.data.get(DATA_CORE_STATS) + return hass.data.get(DATA_CORE_STATS) or {} @callback @@ -354,7 +356,7 @@ def get_supervisor_stats(hass): Async friendly. """ - return hass.data.get(DATA_SUPERVISOR_STATS) + return hass.data.get(DATA_SUPERVISOR_STATS) or {} @callback @@ -754,6 +756,12 @@ def __init__( _LOGGER, name=DOMAIN, update_interval=HASSIO_UPDATE_INTERVAL, + # We don't want an immediate refresh since we want to avoid + # fetching the container stats right away and avoid hammering + # the Supervisor API on startup + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), ) self.hassio: HassIO = hass.data[DOMAIN] self.data = {} @@ -875,9 +883,9 @@ async def force_data_refresh(self, first_update: bool) -> None: DATA_SUPERVISOR_INFO: hassio.get_supervisor_info(), DATA_OS_INFO: hassio.get_os_info(), } - if first_update or CONTAINER_STATS in container_updates[CORE_CONTAINER]: + if CONTAINER_STATS in container_updates[CORE_CONTAINER]: updates[DATA_CORE_STATS] = hassio.get_core_stats() - if first_update or CONTAINER_STATS in container_updates[SUPERVISOR_CONTAINER]: + if CONTAINER_STATS in container_updates[SUPERVISOR_CONTAINER]: updates[DATA_SUPERVISOR_STATS] = hassio.get_supervisor_stats() results = await asyncio.gather(*updates.values()) @@ -903,20 +911,28 @@ async def force_data_refresh(self, first_update: bool) -> None: # API calls since otherwise we would fetch stats for all containers # and throw them away. # - for data_key, update_func, enabled_key, wanted_addons in ( + for data_key, update_func, enabled_key, wanted_addons, needs_first_update in ( ( DATA_ADDONS_STATS, self._update_addon_stats, CONTAINER_STATS, started_addons, + False, ), ( DATA_ADDONS_CHANGELOGS, self._update_addon_changelog, CONTAINER_CHANGELOG, all_addons, + True, + ), + ( + DATA_ADDONS_INFO, + self._update_addon_info, + CONTAINER_INFO, + all_addons, + True, ), - (DATA_ADDONS_INFO, self._update_addon_info, CONTAINER_INFO, all_addons), ): container_data: dict[str, Any] = data.setdefault(data_key, {}) container_data.update( @@ -925,7 +941,8 @@ async def force_data_refresh(self, first_update: bool) -> None: *[ update_func(slug) for slug in wanted_addons - if first_update or enabled_key in container_updates[slug] + if (first_update and needs_first_update) + or enabled_key in container_updates[slug] ] ) ) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 193d4762c5a950..b495745e87ddfa 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -101,6 +101,8 @@ ATTR_STATE: {CONTAINER_INFO}, } +REQUEST_REFRESH_DELAY = 10 + class SupervisorEntityModel(StrEnum): """Supervisor entity model.""" diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index 16e418d91d5650..63e0314dd0512c 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -10,6 +10,7 @@ from . import DOMAIN, HassioDataUpdateCoordinator from .const import ( ATTR_SLUG, + CONTAINER_STATS, CORE_CONTAINER, DATA_KEY_ADDONS, DATA_KEY_CORE, @@ -58,6 +59,8 @@ async def async_added_to_hass(self) -> None: self._addon_slug, self.entity_id, update_types ) ) + if CONTAINER_STATS in update_types: + await self.coordinator.async_request_refresh() class HassioOSEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): @@ -147,6 +150,8 @@ async def async_added_to_hass(self) -> None: SUPERVISOR_CONTAINER, self.entity_id, update_types ) ) + if CONTAINER_STATS in update_types: + await self.coordinator.async_request_refresh() class HassioCoreEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): @@ -183,3 +188,5 @@ async def async_added_to_hass(self) -> None: CORE_CONTAINER, self.entity_id, update_types ) ) + if CONTAINER_STATS in update_types: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 5b26cb29dedf34..c978a7d4320615 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -44,6 +44,7 @@ from .exposed_entities import ExposedEntities ATTR_ENTRY_ID = "entry_id" +ATTR_SAFE_MODE = "safe_mode" _LOGGER = logging.getLogger(__name__) SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" @@ -63,7 +64,7 @@ ), cv.has_at_least_one_key(ATTR_ENTRY_ID, *cv.ENTITY_SERVICE_FIELDS), ) - +SCHEMA_RESTART = vol.Schema({vol.Optional(ATTR_SAFE_MODE, default=False): bool}) SHUTDOWN_SERVICES = (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART) @@ -193,6 +194,8 @@ async def async_handle_core_service(call: ha.ServiceCall) -> None: ) if call.service == SERVICE_HOMEASSISTANT_RESTART: + if call.data[ATTR_SAFE_MODE]: + await conf_util.async_enable_safe_mode(hass) stop_handler = hass.data[DATA_STOP_HANDLER] await stop_handler(hass, True) @@ -228,7 +231,11 @@ async def async_handle_update_service(call: ha.ServiceCall) -> None: hass, ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service ) async_register_admin_service( - hass, ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service + hass, + ha.DOMAIN, + SERVICE_HOMEASSISTANT_RESTART, + async_handle_core_service, + SCHEMA_RESTART, ) async_register_admin_service( hass, ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index a3435a8d1f5f2c..26871522819937 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -68,7 +68,13 @@ }, "restart": { "name": "[%key:common::action::restart%]", - "description": "Restarts Home Assistant." + "description": "Restarts Home Assistant.", + "fields": { + "safe_mode": { + "name": "Safe mode", + "description": "Disable custom integrations and custom cards." + } + } }, "set_location": { "name": "Set location", diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 4f6cc24edc8214..17d1237e579ef7 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["pyhap"], "requirements": [ - "HAP-python==4.9.0", + "HAP-python==4.9.1", "fnv-hash-fast==0.5.0", "PyQRCode==1.2.1", "base36==0.1.1" diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index c3d14b7d38314a..d75ca02b66f4f1 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["homematicip"], "quality_scale": "silver", - "requirements": ["homematicip==1.0.15"] + "requirements": ["homematicip==1.0.16"] } diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 01705d66f50b2a..036f6c077daffc 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -1,6 +1,5 @@ """The Homewizard integration.""" from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -10,7 +9,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Homewizard from a config entry.""" - coordinator = Coordinator(hass, entry, entry.data[CONF_IP_ADDRESS]) + coordinator = Coordinator(hass) try: await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py index 96fe1b157f86c0..8a6936ee1c8ee6 100644 --- a/homeassistant/components/homewizard/button.py +++ b/homeassistant/components/homewizard/button.py @@ -18,23 +18,19 @@ async def async_setup_entry( """Set up the Identify button.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] if coordinator.supports_identify(): - async_add_entities([HomeWizardIdentifyButton(coordinator, entry)]) + async_add_entities([HomeWizardIdentifyButton(coordinator)]) class HomeWizardIdentifyButton(HomeWizardEntity, ButtonEntity): """Representation of a identify button.""" - _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_category = EntityCategory.CONFIG _attr_device_class = ButtonDeviceClass.IDENTIFY - def __init__( - self, - coordinator: HWEnergyDeviceUpdateCoordinator, - entry: ConfigEntry, - ) -> None: + def __init__(self, coordinator: HWEnergyDeviceUpdateCoordinator) -> None: """Initialize button.""" super().__init__(coordinator) - self._attr_unique_id = f"{entry.unique_id}_identify" + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_identify" @homewizard_exception_handler async def async_press(self) -> None: diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index 82c808a0f13b03..b24b49da96598c 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -62,7 +62,7 @@ async def async_step_user( ) self._abort_if_unique_id_configured(updates=user_input) return self.async_create_entry( - title=f"{device_info.product_name} ({device_info.serial})", + title=f"{device_info.product_name}", data=user_input, ) @@ -121,14 +121,18 @@ async def async_step_discovery_confirm( errors = {"base": ex.error_code} else: return self.async_create_entry( - title=f"{self.discovery.product_name} ({self.discovery.serial})", + title=self.discovery.product_name, data={CONF_IP_ADDRESS: self.discovery.ip}, ) self._set_confirm_only() - self.context["title_placeholders"] = { - "name": f"{self.discovery.product_name} ({self.discovery.serial})" - } + + # We won't be adding mac/serial to the title for devices + # that users generally don't have multiple of. + name = self.discovery.product_name + if self.discovery.product_type not in ["HWE-P1", "HWE-WTR"]: + name = f"{name} ({self.discovery.serial})" + self.context["title_placeholders"] = {"name": name} return self.async_show_form( step_id="discovery_confirm", diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index fb89989b2a5805..e38b1d54471cc2 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -9,6 +9,7 @@ from homewizard_energy.models import Device from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -26,16 +27,18 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] _unsupported_error: bool = False + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, - host: str, ) -> None: """Initialize update coordinator.""" super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) - self.entry = entry - self.api = HomeWizardEnergy(host, clientsession=async_get_clientsession(hass)) + self.api = HomeWizardEnergy( + self.config_entry.data[CONF_IP_ADDRESS], + clientsession=async_get_clientsession(hass), + ) async def _async_update_data(self) -> DeviceResponseEntry: """Fetch all device and sensor data from api.""" @@ -58,7 +61,7 @@ async def _async_update_data(self) -> DeviceResponseEntry: self._unsupported_error = True _LOGGER.warning( "%s is running an outdated firmware version (%s). Contact HomeWizard support to update your device", - self.entry.title, + self.config_entry.title, ex, ) @@ -71,7 +74,9 @@ async def _async_update_data(self) -> DeviceResponseEntry: # Do not reload when performing first refresh if self.data is not None: - await self.hass.config_entries.async_reload(self.entry.entry_id) + await self.hass.config_entries.async_reload( + self.config_entry.entry_id + ) raise UpdateFailed(ex) from ex diff --git a/homeassistant/components/homewizard/diagnostics.py b/homeassistant/components/homewizard/diagnostics.py index a8f89b67ce9e62..b8103f7a4cb6eb 100644 --- a/homeassistant/components/homewizard/diagnostics.py +++ b/homeassistant/components/homewizard/diagnostics.py @@ -28,18 +28,23 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - meter_data = { - "device": asdict(coordinator.data.device), - "data": asdict(coordinator.data.data), - "state": asdict(coordinator.data.state) - if coordinator.data.state is not None - else None, - "system": asdict(coordinator.data.system) - if coordinator.data.system is not None - else None, - } - - return { - "entry": async_redact_data(entry.data, TO_REDACT), - "data": async_redact_data(meter_data, TO_REDACT), - } + state: dict[str, Any] | None = None + if coordinator.data.state: + state = asdict(coordinator.data.state) + + system: dict[str, Any] | None = None + if coordinator.data.system: + system = asdict(coordinator.data.system) + + return async_redact_data( + { + "entry": async_redact_data(entry.data, TO_REDACT), + "data": { + "device": asdict(coordinator.data.device), + "data": asdict(coordinator.data.data), + "state": state, + "system": system, + }, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/homewizard/entity.py b/homeassistant/components/homewizard/entity.py index 51dbe9fcad32bc..2090cc363ba1ad 100644 --- a/homeassistant/components/homewizard/entity.py +++ b/homeassistant/components/homewizard/entity.py @@ -16,19 +16,15 @@ class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]): def __init__(self, coordinator: HWEnergyDeviceUpdateCoordinator) -> None: """Initialize the HomeWizard entity.""" - super().__init__(coordinator=coordinator) + super().__init__(coordinator) self._attr_device_info = DeviceInfo( - name=coordinator.entry.title, manufacturer="HomeWizard", sw_version=coordinator.data.device.firmware_version, model=coordinator.data.device.product_type, ) - if coordinator.data.device.serial is not None: + if (serial_number := coordinator.data.device.serial) is not None: self._attr_device_info[ATTR_CONNECTIONS] = { - (CONNECTION_NETWORK_MAC, coordinator.data.device.serial) - } - - self._attr_device_info[ATTR_IDENTIFIERS] = { - (DOMAIN, coordinator.data.device.serial) + (CONNECTION_NETWORK_MAC, serial_number) } + self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, serial_number)} diff --git a/homeassistant/components/homewizard/helpers.py b/homeassistant/components/homewizard/helpers.py index d2d1b7c0119916..3f7fc0649313d4 100644 --- a/homeassistant/components/homewizard/helpers.py +++ b/homeassistant/components/homewizard/helpers.py @@ -32,7 +32,9 @@ async def handler( except RequestError as ex: raise HomeAssistantError from ex except DisabledError as ex: - await self.hass.config_entries.async_reload(self.coordinator.entry.entry_id) + await self.hass.config_entries.async_reload( + self.coordinator.config_entry.entry_id + ) raise HomeAssistantError from ex return handler diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index d51d180edb1826..58e0b02a06c25f 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -21,7 +21,7 @@ async def async_setup_entry( """Set up numbers for device.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] if coordinator.supports_state(): - async_add_entities([HWEnergyNumberEntity(coordinator, entry)]) + async_add_entities([HWEnergyNumberEntity(coordinator)]) class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): @@ -35,11 +35,12 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): def __init__( self, coordinator: HWEnergyDeviceUpdateCoordinator, - entry: ConfigEntry, ) -> None: """Initialize the control number.""" super().__init__(coordinator) - self._attr_unique_id = f"{entry.unique_id}_status_light_brightness" + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}_status_light_brightness" + ) @homewizard_exception_handler async def async_set_native_value(self, value: float) -> None: @@ -47,13 +48,17 @@ async def async_set_native_value(self, value: float) -> None: await self.coordinator.api.state_set(brightness=int(value * (255 / 100))) await self.coordinator.async_refresh() + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.data.state is not None + @property def native_value(self) -> float | None: """Return the current value.""" if ( - self.coordinator.data.state is None - or self.coordinator.data.state.brightness is None + not self.coordinator.data.state + or (brightness := self.coordinator.data.state.brightness) is None ): return None - brightness: float = self.coordinator.data.state.brightness return round(brightness * (100 / 255)) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index d8cc72ce45eeae..a342e11bea0c71 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -26,6 +26,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import DOMAIN from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -39,7 +40,7 @@ class HomeWizardEntityDescriptionMixin: """Mixin values for HomeWizard entities.""" has_fn: Callable[[Data], bool] - value_fn: Callable[[Data], float | int | str | None] + value_fn: Callable[[Data], StateType] @dataclass @@ -433,7 +434,7 @@ async def async_setup_entry( coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - HomeWizardSensorEntity(coordinator, entry, description) + HomeWizardSensorEntity(coordinator, description) for description in SENSORS if description.has_fn(coordinator.data.data) ) @@ -447,18 +448,17 @@ class HomeWizardSensorEntity(HomeWizardEntity, SensorEntity): def __init__( self, coordinator: HWEnergyDeviceUpdateCoordinator, - entry: ConfigEntry, description: HomeWizardSensorEntityDescription, ) -> None: """Initialize Sensor Domain.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{entry.unique_id}_{description.key}" + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" if not description.enabled_fn(self.coordinator.data.data): self._attr_entity_registry_enabled_default = False @property - def native_value(self) -> float | int | str | None: + def native_value(self) -> StateType: """Return the sensor value.""" return self.entity_description.value_fn(self.coordinator.data.data) diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index cddcabc841ec2b..ed59963aa41074 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -86,11 +86,7 @@ async def async_setup_entry( coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - HomeWizardSwitchEntity( - coordinator=coordinator, - description=description, - entry=entry, - ) + HomeWizardSwitchEntity(coordinator, description) for description in SWITCHES if description.create_fn(coordinator) ) @@ -105,12 +101,11 @@ def __init__( self, coordinator: HWEnergyDeviceUpdateCoordinator, description: HomeWizardSwitchEntityDescription, - entry: ConfigEntry, ) -> None: """Initialize the switch.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{entry.unique_id}_{description.key}" + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" @property def icon(self) -> str | None: diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 7d63df131d883d..8ce6d287551b91 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -96,6 +96,8 @@ def __init__( self.api: HueBridgeV2 = bridge.api self._attr_supported_features |= LightEntityFeature.FLASH self._attr_supported_features |= LightEntityFeature.TRANSITION + self._restore_brightness: float | None = None + self._brightness_pct: float = 0 # we create a virtual service/device for Hue zones/rooms # so we have a parent for grouped lights and scenes self._attr_device_info = DeviceInfo( @@ -153,6 +155,18 @@ async def async_turn_on(self, **kwargs: Any) -> None: brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) flash = kwargs.get(ATTR_FLASH) + if self._restore_brightness and brightness is None: + # The Hue bridge sets the brightness to 1% when turning on a bulb + # when a transition was used to turn off the bulb. + # This issue has been reported on the Hue forum several times: + # https://developers.meethue.com/forum/t/brightness-turns-down-to-1-automatically-shortly-after-sending-off-signal-hue-bug/5692 + # https://developers.meethue.com/forum/t/lights-turn-on-with-lowest-brightness-via-siri-if-turned-off-via-api/6700 + # https://developers.meethue.com/forum/t/using-transitiontime-with-on-false-resets-bri-to-1/4585 + # https://developers.meethue.com/forum/t/bri-value-changing-in-switching-lights-on-off/6323 + # https://developers.meethue.com/forum/t/fade-in-fade-out/6673 + brightness = self._restore_brightness + self._restore_brightness = None + if flash is not None: await self.async_set_flash(flash) return @@ -170,6 +184,8 @@ async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) + if transition is not None: + self._restore_brightness = self._brightness_pct flash = kwargs.get(ATTR_FLASH) if flash is not None: @@ -244,6 +260,7 @@ def _update_values(self) -> None: if len(supported_color_modes) == 0: # only add color mode brightness if no color variants supported_color_modes.add(ColorMode.BRIGHTNESS) + self._brightness_pct = total_brightness / lights_with_dimming_support self._attr_brightness = round( ((total_brightness / lights_with_dimming_support) / 100) * 255 ) diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index ed5d0151b03a78..348d60d8de2393 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -94,6 +94,7 @@ def __init__( self._supported_color_modes.add(ColorMode.BRIGHTNESS) # support transition if brightness control self._attr_supported_features |= LightEntityFeature.TRANSITION + self._last_brightness: float | None = None self._color_temp_active: bool = False # get list of supported effects (combine effects and timed_effects) self._attr_effect_list = [] @@ -209,6 +210,17 @@ async def async_turn_on(self, **kwargs: Any) -> None: xy_color = kwargs.get(ATTR_XY_COLOR) color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP)) brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) + if self._last_brightness and brightness is None: + # The Hue bridge sets the brightness to 1% when turning on a bulb + # when a transition was used to turn off the bulb. + # This issue has been reported on the Hue forum several times: + # https://developers.meethue.com/forum/t/brightness-turns-down-to-1-automatically-shortly-after-sending-off-signal-hue-bug/5692 + # https://developers.meethue.com/forum/t/lights-turn-on-with-lowest-brightness-via-siri-if-turned-off-via-api/6700 + # https://developers.meethue.com/forum/t/using-transitiontime-with-on-false-resets-bri-to-1/4585 + # https://developers.meethue.com/forum/t/bri-value-changing-in-switching-lights-on-off/6323 + # https://developers.meethue.com/forum/t/fade-in-fade-out/6673 + brightness = self._last_brightness + self._last_brightness = None self._color_temp_active = color_temp is not None flash = kwargs.get(ATTR_FLASH) effect = effect_str = kwargs.get(ATTR_EFFECT) @@ -245,6 +257,8 @@ async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) + if transition is not None and self.resource.dimming: + self._last_brightness = self.resource.dimming.brightness flash = kwargs.get(ATTR_FLASH) if flash is not None: diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index 6e3f5eaee333b4..b82b2b34a4bcdf 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -146,7 +146,7 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): translation_key="energy_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_DAY, precision=1, @@ -156,7 +156,7 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): translation_key="energy_week", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_WEEK, precision=1, @@ -166,7 +166,7 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): translation_key="energy_month", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_MONTH, precision=1, @@ -176,7 +176,7 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): translation_key="energy_year", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_YEAR, precision=1, @@ -197,7 +197,7 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_DAY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), @@ -207,7 +207,7 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_WEEK, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), @@ -217,7 +217,7 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_MONTH, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), @@ -227,7 +227,7 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_YEAR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), diff --git a/homeassistant/components/idasen_desk/config_flow.py b/homeassistant/components/idasen_desk/config_flow.py index caa8d866fc3e0d..80282ce0271053 100644 --- a/homeassistant/components/idasen_desk/config_flow.py +++ b/homeassistant/components/idasen_desk/config_flow.py @@ -65,14 +65,12 @@ async def async_step_user( desk = Desk(None, monitor_height=False) try: await desk.connect(discovery_info.device, auto_reconnect=False) - except AuthFailedError as err: - _LOGGER.exception("AuthFailedError", exc_info=err) + except AuthFailedError: errors["base"] = "auth_failed" - except TimeoutError as err: - _LOGGER.exception("TimeoutError", exc_info=err) + except TimeoutError: errors["base"] = "cannot_connect" - except BleakError as err: - _LOGGER.exception("BleakError", exc_info=err) + except BleakError: + _LOGGER.exception("Unexpected Bluetooth error") errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected error") diff --git a/homeassistant/components/improv_ble/__init__.py b/homeassistant/components/improv_ble/__init__.py new file mode 100644 index 00000000000000..985684cb5b8882 --- /dev/null +++ b/homeassistant/components/improv_ble/__init__.py @@ -0,0 +1 @@ +"""The Improv BLE integration.""" diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py new file mode 100644 index 00000000000000..bfc86ac01629f6 --- /dev/null +++ b/homeassistant/components/improv_ble/config_flow.py @@ -0,0 +1,415 @@ +"""Config flow for Improv via BLE integration.""" +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any, TypeVar + +from bleak import BleakError +from improv_ble_client import ( + SERVICE_DATA_UUID, + Error, + ImprovBLEClient, + ImprovServiceData, + State, + device_filter, + errors as improv_ble_errors, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import bluetooth +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow, FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +_T = TypeVar("_T") + +STEP_PROVISION_SCHEMA = vol.Schema( + { + vol.Required("ssid"): str, + vol.Optional("password"): str, + } +) + + +@dataclass +class Credentials: + """Container for WiFi credentials.""" + + password: str + ssid: str + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Improv via BLE.""" + + VERSION = 1 + + _authorize_task: asyncio.Task | None = None + _can_identify: bool | None = None + _credentials: Credentials | None = None + _provision_result: FlowResult | None = None + _provision_task: asyncio.Task | None = None + _reauth_entry: config_entries.ConfigEntry | None = None + _remove_bluetooth_callback: Callable[[], None] | None = None + _unsub: Callable[[], None] | None = None + + def __init__(self) -> None: + """Initialize the config flow.""" + self._device: ImprovBLEClient | None = None + # Populated by user step + self._discovered_devices: dict[str, bluetooth.BluetoothServiceInfoBleak] = {} + # Populated by bluetooth, reauth_confirm and user steps + self._discovery_info: bluetooth.BluetoothServiceInfoBleak | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + errors: dict[str, str] = {} + + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + # Guard against the user selecting a device which has been configured by + # another flow. + self._abort_if_unique_id_configured() + self._discovery_info = self._discovered_devices[address] + return await self.async_step_start_improv() + + current_addresses = self._async_current_ids() + for discovery in bluetooth.async_discovered_service_info(self.hass): + if ( + discovery.address in current_addresses + or discovery.address in self._discovered_devices + or not device_filter(discovery.advertisement) + ): + continue + self._discovered_devices[discovery.address] = discovery + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + data_schema = vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In( + { + service_info.address: ( + f"{service_info.name} ({service_info.address})" + ) + for service_info in self._discovered_devices.values() + } + ), + } + ) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) + + def _abort_if_provisioned(self) -> None: + """Check improv state and abort flow if needed.""" + # mypy is not aware that we can't get here without having these set already + assert self._discovery_info is not None + + service_data = self._discovery_info.service_data + improv_service_data = ImprovServiceData.from_bytes( + service_data[SERVICE_DATA_UUID] + ) + if improv_service_data.state in (State.PROVISIONING, State.PROVISIONED): + _LOGGER.debug( + "Aborting improv flow, device is already provisioned: %s", + improv_service_data.state, + ) + raise AbortFlow("already_provisioned") + + @callback + def _async_update_ble( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Update from a ble callback.""" + _LOGGER.debug( + "Got updated BLE data: %s", + service_info.service_data[SERVICE_DATA_UUID].hex(), + ) + + self._discovery_info = service_info + try: + self._abort_if_provisioned() + except AbortFlow: + self.hass.config_entries.flow.async_abort(self.flow_id) + + def _unregister_bluetooth_callback(self) -> None: + """Unregister bluetooth callbacks.""" + if not self._remove_bluetooth_callback: + return + self._remove_bluetooth_callback() + self._remove_bluetooth_callback = None + + async def async_step_bluetooth( + self, discovery_info: bluetooth.BluetoothServiceInfoBleak + ) -> FlowResult: + """Handle the Bluetooth discovery step.""" + self._discovery_info = discovery_info + + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + self._abort_if_provisioned() + + self._remove_bluetooth_callback = bluetooth.async_register_callback( + self.hass, + self._async_update_ble, + bluetooth.BluetoothCallbackMatcher( + {bluetooth.match.ADDRESS: discovery_info.address} + ), + bluetooth.BluetoothScanningMode.PASSIVE, + ) + + name = self._discovery_info.name or self._discovery_info.address + self.context["title_placeholders"] = {"name": name} + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle bluetooth confirm step.""" + # mypy is not aware that we can't get here without having these set already + assert self._discovery_info is not None + + if user_input is None: + name = self._discovery_info.name or self._discovery_info.address + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders={"name": name}, + ) + + self._unregister_bluetooth_callback() + return await self.async_step_start_improv() + + async def async_step_start_improv( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Start improv flow. + + If the device supports identification, show a menu, if it does not, + ask for WiFi credentials. + """ + # mypy is not aware that we can't get here without having these set already + assert self._discovery_info is not None + + if not self._device: + self._device = ImprovBLEClient(self._discovery_info.device) + device = self._device + + if self._can_identify is None: + try: + self._can_identify = await self._try_call(device.can_identify()) + except AbortFlow as err: + return self.async_abort(reason=err.reason) + if self._can_identify: + return await self.async_step_main_menu() + return await self.async_step_provision() + + async def async_step_main_menu(self, _: None = None) -> FlowResult: + """Show the main menu.""" + return self.async_show_menu( + step_id="main_menu", + menu_options=[ + "identify", + "provision", + ], + ) + + async def async_step_identify( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle identify step.""" + # mypy is not aware that we can't get here without having these set already + assert self._device is not None + + if user_input is None: + try: + await self._try_call(self._device.identify()) + except AbortFlow as err: + return self.async_abort(reason=err.reason) + return self.async_show_form(step_id="identify") + return await self.async_step_start_improv() + + async def async_step_provision( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle provision step.""" + # mypy is not aware that we can't get here without having these set already + assert self._device is not None + + if user_input is None and self._credentials is None: + return self.async_show_form( + step_id="provision", data_schema=STEP_PROVISION_SCHEMA + ) + if user_input is not None: + self._credentials = Credentials( + user_input.get("password", ""), user_input["ssid"] + ) + + try: + need_authorization = await self._try_call(self._device.need_authorization()) + except AbortFlow as err: + return self.async_abort(reason=err.reason) + _LOGGER.debug("Need authorization: %s", need_authorization) + if need_authorization: + return await self.async_step_authorize() + return await self.async_step_do_provision() + + async def async_step_do_provision( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Execute provisioning.""" + + async def _do_provision() -> None: + # mypy is not aware that we can't get here without having these set already + assert self._credentials is not None + assert self._device is not None + + errors = {} + try: + redirect_url = await self._try_call( + self._device.provision( + self._credentials.ssid, self._credentials.password, None + ) + ) + except AbortFlow as err: + self._provision_result = self.async_abort(reason=err.reason) + return + except improv_ble_errors.ProvisioningFailed as err: + if err.error == Error.NOT_AUTHORIZED: + _LOGGER.debug("Need authorization when calling provision") + self._provision_result = await self.async_step_authorize() + return + if err.error == Error.UNABLE_TO_CONNECT: + self._credentials = None + errors["base"] = "unable_to_connect" + else: + self._provision_result = self.async_abort(reason="unknown") + return + else: + _LOGGER.debug("Provision successful, redirect URL: %s", redirect_url) + # Abort all flows in progress with same unique ID + for flow in self._async_in_progress(include_uninitialized=True): + flow_unique_id = flow["context"].get("unique_id") + if ( + flow["flow_id"] != self.flow_id + and self.unique_id == flow_unique_id + ): + self.hass.config_entries.flow.async_abort(flow["flow_id"]) + if redirect_url: + self._provision_result = self.async_abort( + reason="provision_successful_url", + description_placeholders={"url": redirect_url}, + ) + return + self._provision_result = self.async_abort(reason="provision_successful") + return + self._provision_result = self.async_show_form( + step_id="provision", data_schema=STEP_PROVISION_SCHEMA, errors=errors + ) + return + + if not self._provision_task: + self._provision_task = self.hass.async_create_task( + self._resume_flow_when_done(_do_provision()) + ) + return self.async_show_progress( + step_id="do_provision", progress_action="provisioning" + ) + + await self._provision_task + self._provision_task = None + return self.async_show_progress_done(next_step_id="provision_done") + + async def async_step_provision_done( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Show the result of the provision step.""" + # mypy is not aware that we can't get here without having these set already + assert self._provision_result is not None + + result = self._provision_result + self._provision_result = None + return result + + async def _resume_flow_when_done(self, awaitable: Awaitable) -> None: + try: + await awaitable + finally: + self.hass.async_create_task( + self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) + ) + + async def async_step_authorize( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle authorize step.""" + # mypy is not aware that we can't get here without having these set already + assert self._device is not None + + _LOGGER.debug("Wait for authorization") + if not self._authorize_task: + authorized_event = asyncio.Event() + + def on_state_update(state: State) -> None: + _LOGGER.debug("State update: %s", state.name) + if state != State.AUTHORIZATION_REQUIRED: + authorized_event.set() + + try: + self._unsub = await self._try_call( + self._device.subscribe_state_updates(on_state_update) + ) + except AbortFlow as err: + return self.async_abort(reason=err.reason) + + self._authorize_task = self.hass.async_create_task( + self._resume_flow_when_done(authorized_event.wait()) + ) + return self.async_show_progress( + step_id="authorize", progress_action="authorize" + ) + + await self._authorize_task + self._authorize_task = None + if self._unsub: + self._unsub() + self._unsub = None + return self.async_show_progress_done(next_step_id="provision") + + @staticmethod + async def _try_call(func: Coroutine[Any, Any, _T]) -> _T: + """Call the library and abort flow on common errors.""" + try: + return await func + except BleakError as err: + _LOGGER.warning("BleakError", exc_info=err) + raise AbortFlow("cannot_connect") from err + except improv_ble_errors.CharacteristicMissingError as err: + _LOGGER.warning("CharacteristicMissing", exc_info=err) + raise AbortFlow("characteristic_missing") from err + except improv_ble_errors.CommandFailed: + raise + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + raise AbortFlow("unknown") from err + + @callback + def async_remove(self) -> None: + """Notification that the flow has been removed.""" + self._unregister_bluetooth_callback() diff --git a/homeassistant/components/improv_ble/const.py b/homeassistant/components/improv_ble/const.py new file mode 100644 index 00000000000000..0641773a0557c8 --- /dev/null +++ b/homeassistant/components/improv_ble/const.py @@ -0,0 +1,3 @@ +"""Constants for the Improv BLE integration.""" + +DOMAIN = "improv_ble" diff --git a/homeassistant/components/improv_ble/manifest.json b/homeassistant/components/improv_ble/manifest.json new file mode 100644 index 00000000000000..30af6e111a08b2 --- /dev/null +++ b/homeassistant/components/improv_ble/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "improv_ble", + "name": "Improv via BLE", + "bluetooth": [ + { + "service_uuid": "00467768-6228-2272-4663-277478268000", + "service_data_uuid": "00004677-0000-1000-8000-00805f9b34fb" + } + ], + "codeowners": ["@emontnemery"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/improv_ble", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["py-improv-ble-client==1.0.3"] +} diff --git a/homeassistant/components/improv_ble/strings.json b/homeassistant/components/improv_ble/strings.json new file mode 100644 index 00000000000000..b57139101342ef --- /dev/null +++ b/homeassistant/components/improv_ble/strings.json @@ -0,0 +1,49 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + }, + "identify": { + "description": "The device is now identifying itself, for example by blinking or beeping." + }, + "main_menu": { + "description": "Choose next step.", + "menu_options": { + "identify": "Identify device", + "provision": "Connect device to a Wi-Fi network" + } + }, + "provision": { + "description": "Enter Wi-Fi credentials to connect the device to your network.", + "data": { + "password": "Password", + "ssid": "SSID" + } + } + }, + "progress": { + "authorize": "The device requires authorization, please press its authorization button or consult the device's manual for how to proceed.", + "provisioning": "The device is connecting to the Wi-Fi network." + }, + "error": { + "unable_to_connect": "The device could not connect to the Wi-Fi network. Check that the SSID and password are correct and try again." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "characteristic_missing": "The device is either already connected to Wi-Fi, or no longer able to connect to Wi-Fi. If you want to connect it to another network, try factory resetting it first.", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "provision_successful": "The device has successfully connected to the Wi-Fi network.", + "provision_successful_url": "The device has successfully connected to the Wi-Fi network.\n\nPlease visit {url} to finish setup.", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index f25c3410edbb19..2e9e6bb71f74f4 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -3,11 +3,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input -from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, LOGGER, PLATFORMS +from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, PLATFORMS from .coordinator import JellyfinDataUpdateCoordinator, SessionsDataUpdateCoordinator from .models import JellyfinData @@ -30,9 +30,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: user_id, connect_result = await validate_input(hass, dict(entry.data), client) except CannotConnect as ex: raise ConfigEntryNotReady("Cannot connect to Jellyfin server") from ex - except InvalidAuth: - LOGGER.error("Failed to login to Jellyfin server") - return False + except InvalidAuth as ex: + raise ConfigEntryAuthFailed(ex) from ex server_info: dict[str, Any] = connect_result["Servers"][0] diff --git a/homeassistant/components/jellyfin/config_flow.py b/homeassistant/components/jellyfin/config_flow.py index 84b78d51926fcc..84360ed053e3dd 100644 --- a/homeassistant/components/jellyfin/config_flow.py +++ b/homeassistant/components/jellyfin/config_flow.py @@ -1,6 +1,7 @@ """Config flow for the Jellyfin integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -24,6 +25,12 @@ } ) +REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_PASSWORD, default=""): str, + } +) + def _generate_client_device_id() -> str: """Generate a random UUID4 string to identify ourselves.""" @@ -38,6 +45,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the Jellyfin config flow.""" self.client_device_id: str | None = None + self.entry: config_entries.ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -83,3 +91,41 @@ async def async_step_user( return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + + if user_input is not None: + assert self.entry is not None + new_input = self.entry.data | user_input + + if self.client_device_id is None: + self.client_device_id = _generate_client_device_id() + + client = create_client(device_id=self.client_device_id) + try: + await validate_input(self.hass, new_input, client) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception as ex: # pylint: disable=broad-except + errors["base"] = "unknown" + _LOGGER.exception(ex) + else: + self.hass.config_entries.async_update_entry(self.entry, data=new_input) + + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", data_schema=REAUTH_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/jellyfin/strings.json b/homeassistant/components/jellyfin/strings.json index 8d74d416a94d83..3e8965da785612 100644 --- a/homeassistant/components/jellyfin/strings.json +++ b/homeassistant/components/jellyfin/strings.json @@ -1,6 +1,13 @@ { "config": { "step": { + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Jellyfin integration needs to re-authenticate your account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + }, "user": { "data": { "url": "[%key:common::config_flow::data::url%]", @@ -16,7 +23,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index b5c98c7203ab65..a233ca387053f4 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "quality_scale": "platinum", "requirements": [ "xknx==2.11.2", - "xknxproject==3.3.0", + "xknxproject==3.4.0", "knx-frontend==2023.6.23.191712" ] } diff --git a/homeassistant/components/kodi/notify.py b/homeassistant/components/kodi/notify.py index 0d62e6cfa10e86..f3459e891b707b 100644 --- a/homeassistant/components/kodi/notify.py +++ b/homeassistant/components/kodi/notify.py @@ -64,7 +64,7 @@ async def async_get_service( _LOGGER.warning( "Kodi host name should no longer contain http:// See updated " "definitions here: " - "https://www.home-assistant.io/integrations/media_player.kodi/" + "https://www.home-assistant.io/integrations/kodi/" ) http_protocol = "https" if encryption else "http" diff --git a/homeassistant/components/local_todo/__init__.py b/homeassistant/components/local_todo/__init__.py new file mode 100644 index 00000000000000..f8403251ba0d56 --- /dev/null +++ b/homeassistant/components/local_todo/__init__.py @@ -0,0 +1,55 @@ +"""The Local To-do integration.""" +from __future__ import annotations + +from pathlib import Path + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.util import slugify + +from .const import CONF_STORAGE_KEY, CONF_TODO_LIST_NAME, DOMAIN +from .store import LocalTodoListStore + +PLATFORMS: list[Platform] = [Platform.TODO] + +STORAGE_PATH = ".storage/local_todo.{key}.ics" + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Local To-do from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + + path = Path(hass.config.path(STORAGE_PATH.format(key=entry.data[CONF_STORAGE_KEY]))) + store = LocalTodoListStore(hass, path) + try: + await store.async_load() + except OSError as err: + raise ConfigEntryNotReady("Failed to load file {path}: {err}") from err + + hass.data[DOMAIN][entry.entry_id] = store + + 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 + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle removal of an entry.""" + key = slugify(entry.data[CONF_TODO_LIST_NAME]) + path = Path(hass.config.path(STORAGE_PATH.format(key=key))) + + def unlink(path: Path) -> None: + path.unlink(missing_ok=True) + + await hass.async_add_executor_job(unlink, path) diff --git a/homeassistant/components/local_todo/config_flow.py b/homeassistant/components/local_todo/config_flow.py new file mode 100644 index 00000000000000..73328358a3cbca --- /dev/null +++ b/homeassistant/components/local_todo/config_flow.py @@ -0,0 +1,44 @@ +"""Config flow for Local To-do integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.util import slugify + +from .const import CONF_STORAGE_KEY, CONF_TODO_LIST_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_TODO_LIST_NAME): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Local To-do.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + key = slugify(user_input[CONF_TODO_LIST_NAME]) + self._async_abort_entries_match({CONF_STORAGE_KEY: key}) + user_input[CONF_STORAGE_KEY] = key + return self.async_create_entry( + title=user_input[CONF_TODO_LIST_NAME], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/local_todo/const.py b/homeassistant/components/local_todo/const.py new file mode 100644 index 00000000000000..4677ed42178d8b --- /dev/null +++ b/homeassistant/components/local_todo/const.py @@ -0,0 +1,6 @@ +"""Constants for the Local To-do integration.""" + +DOMAIN = "local_todo" + +CONF_TODO_LIST_NAME = "todo_list_name" +CONF_STORAGE_KEY = "storage_key" diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json new file mode 100644 index 00000000000000..049a1824495959 --- /dev/null +++ b/homeassistant/components/local_todo/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "local_todo", + "name": "Local To-do", + "codeowners": ["@allenporter"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/local_todo", + "iot_class": "local_polling", + "requirements": ["ical==5.1.0"] +} diff --git a/homeassistant/components/local_todo/store.py b/homeassistant/components/local_todo/store.py new file mode 100644 index 00000000000000..79d5adb217f9b0 --- /dev/null +++ b/homeassistant/components/local_todo/store.py @@ -0,0 +1,36 @@ +"""Local storage for the Local To-do integration.""" + +import asyncio +from pathlib import Path + +from homeassistant.core import HomeAssistant + + +class LocalTodoListStore: + """Local storage for a single To-do list.""" + + def __init__(self, hass: HomeAssistant, path: Path) -> None: + """Initialize LocalTodoListStore.""" + self._hass = hass + self._path = path + self._lock = asyncio.Lock() + + async def async_load(self) -> str: + """Load the calendar from disk.""" + async with self._lock: + return await self._hass.async_add_executor_job(self._load) + + def _load(self) -> str: + """Load the calendar from disk.""" + if not self._path.exists(): + return "" + return self._path.read_text() + + async def async_store(self, ics_content: str) -> None: + """Persist the calendar to storage.""" + async with self._lock: + await self._hass.async_add_executor_job(self._store, ics_content) + + def _store(self, ics_content: str) -> None: + """Persist the calendar to storage.""" + self._path.write_text(ics_content) diff --git a/homeassistant/components/local_todo/strings.json b/homeassistant/components/local_todo/strings.json new file mode 100644 index 00000000000000..2403fae60a5e3d --- /dev/null +++ b/homeassistant/components/local_todo/strings.json @@ -0,0 +1,16 @@ +{ + "title": "Local To-do", + "config": { + "step": { + "user": { + "description": "Please choose a name for your new To-do list", + "data": { + "todo_list_name": "To-do list name" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py new file mode 100644 index 00000000000000..7e23d01ee4655b --- /dev/null +++ b/homeassistant/components/local_todo/todo.py @@ -0,0 +1,170 @@ +"""A Local To-do todo platform.""" + +from collections.abc import Iterable +import dataclasses +import logging +from typing import Any + +from ical.calendar import Calendar +from ical.calendar_stream import IcsCalendarStream +from ical.store import TodoStore +from ical.todo import Todo, TodoStatus +from pydantic import ValidationError + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_TODO_LIST_NAME, DOMAIN +from .store import LocalTodoListStore + +_LOGGER = logging.getLogger(__name__) + + +PRODID = "-//homeassistant.io//local_todo 1.0//EN" + +ICS_TODO_STATUS_MAP = { + TodoStatus.IN_PROCESS: TodoItemStatus.NEEDS_ACTION, + TodoStatus.NEEDS_ACTION: TodoItemStatus.NEEDS_ACTION, + TodoStatus.COMPLETED: TodoItemStatus.COMPLETED, + TodoStatus.CANCELLED: TodoItemStatus.COMPLETED, +} +ICS_TODO_STATUS_MAP_INV = { + TodoItemStatus.COMPLETED: TodoStatus.COMPLETED, + TodoItemStatus.NEEDS_ACTION: TodoStatus.NEEDS_ACTION, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the local_todo todo platform.""" + + store = hass.data[DOMAIN][config_entry.entry_id] + ics = await store.async_load() + calendar = IcsCalendarStream.calendar_from_ics(ics) + calendar.prodid = PRODID + + name = config_entry.data[CONF_TODO_LIST_NAME] + entity = LocalTodoListEntity(store, calendar, name, unique_id=config_entry.entry_id) + async_add_entities([entity], True) + + +def _todo_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: + """Convert TodoItem dataclass items to dictionary of attributes for ical consumption.""" + result: dict[str, str] = {} + for name, value in obj: + if name == "status": + result[name] = ICS_TODO_STATUS_MAP_INV[value] + elif value is not None: + result[name] = value + return result + + +def _convert_item(item: TodoItem) -> Todo: + """Convert a HomeAssistant TodoItem to an ical Todo.""" + try: + return Todo(**dataclasses.asdict(item, dict_factory=_todo_dict_factory)) + except ValidationError as err: + _LOGGER.debug("Error parsing todo input fields: %s (%s)", item, err) + raise HomeAssistantError("Error parsing todo input fields") from err + + +class LocalTodoListEntity(TodoListEntity): + """A To-do List representation of the Shopping List.""" + + _attr_has_entity_name = True + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.MOVE_TODO_ITEM + ) + _attr_should_poll = False + + def __init__( + self, + store: LocalTodoListStore, + calendar: Calendar, + name: str, + unique_id: str, + ) -> None: + """Initialize LocalTodoListEntity.""" + self._store = store + self._calendar = calendar + self._attr_name = name.capitalize() + self._attr_unique_id = unique_id + + async def async_update(self) -> None: + """Update entity state based on the local To-do items.""" + self._attr_todo_items = [ + TodoItem( + uid=item.uid, + summary=item.summary or "", + status=ICS_TODO_STATUS_MAP.get( + item.status or TodoStatus.NEEDS_ACTION, TodoItemStatus.NEEDS_ACTION + ), + ) + for item in self._calendar.todos + ] + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + todo = _convert_item(item) + TodoStore(self._calendar).add(todo) + await self._async_save() + await self.async_update_ha_state(force_refresh=True) + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update an item to the To-do list.""" + todo = _convert_item(item) + TodoStore(self._calendar).edit(todo.uid, todo) + await self._async_save() + await self.async_update_ha_state(force_refresh=True) + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Add an item to the To-do list.""" + store = TodoStore(self._calendar) + for uid in uids: + store.delete(uid) + await self._async_save() + await self.async_update_ha_state(force_refresh=True) + + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: + """Re-order an item to the To-do list.""" + if uid == previous_uid: + return + todos = self._calendar.todos + item_idx: dict[str, int] = {itm.uid: idx for idx, itm in enumerate(todos)} + if uid not in item_idx: + raise HomeAssistantError( + "Item '{uid}' not found in todo list {self.entity_id}" + ) + if previous_uid and previous_uid not in item_idx: + raise HomeAssistantError( + "Item '{previous_uid}' not found in todo list {self.entity_id}" + ) + dst_idx = item_idx[previous_uid] + 1 if previous_uid else 0 + src_idx = item_idx[uid] + src_item = todos.pop(src_idx) + if dst_idx > src_idx: + dst_idx -= 1 + todos.insert(dst_idx, src_item) + await self._async_save() + await self.async_update_ha_state(force_refresh=True) + + async def _async_save(self) -> None: + """Persist the todo list to disk.""" + content = IcsCalendarStream.calendar_to_ics(self._calendar) + await self._store.async_store(content) diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index 423ba3117eaea9..c9b7cb103866e8 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -60,6 +60,9 @@ async def websocket_lovelace_resources( """Send Lovelace UI resources over WebSocket configuration.""" resources = hass.data[DOMAIN]["resources"] + if hass.config.safe_mode: + connection.send_result(msg["id"], []) + if not resources.loaded: await resources.async_load() resources.loaded = True diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 44e5d30fec4b4a..a22f9174d2aaae 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -44,7 +44,7 @@ } SystemModeEnum = clusters.Thermostat.Enums.ThermostatSystemMode ControlSequenceEnum = clusters.Thermostat.Enums.ThermostatControlSequence -ThermostatFeature = clusters.Thermostat.Bitmaps.ThermostatFeature +ThermostatFeature = clusters.Thermostat.Bitmaps.Feature class ThermostatRunningState(IntEnum): @@ -268,7 +268,7 @@ def _get_temperature_in_degrees( @staticmethod def _create_optional_setpoint_command( - mode: clusters.Thermostat.Enums.SetpointAdjustMode, + mode: clusters.Thermostat.Enums.SetpointAdjustMode | int, target_temp: float, current_target_temp: float, ) -> clusters.Thermostat.Commands.SetpointRaiseLower | None: diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index 84049301296b10..3361c3fa146fa5 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -21,7 +21,7 @@ from .helpers import get_matter from .models import MatterDiscoverySchema -SwitchFeature = clusters.Switch.Bitmaps.SwitchFeature +SwitchFeature = clusters.Switch.Bitmaps.Feature EVENT_TYPES_MAP = { # mapping from raw event id's to translation keys diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 2237f0ade98121..6f494153a97c4b 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==3.7.0"] + "requirements": ["python-matter-server==4.0.0"] } diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index e9bb3af51f280d..9265b72d0d065f 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -36,45 +36,49 @@ _LOGGER = logging.getLogger(__name__) -def setup_decrypt(key_encoder) -> tuple[int, Callable]: +def setup_decrypt( + key_encoder: type[RawEncoder] | type[HexEncoder], +) -> Callable[[bytes, bytes], bytes]: """Return decryption function and length of key. Async friendly. """ - def decrypt(ciphertext, key): + def decrypt(ciphertext: bytes, key: bytes) -> bytes: """Decrypt ciphertext using key.""" return SecretBox(key, encoder=key_encoder).decrypt( ciphertext, encoder=Base64Encoder ) - return (SecretBox.KEY_SIZE, decrypt) + return decrypt -def setup_encrypt(key_encoder) -> tuple[int, Callable]: +def setup_encrypt( + key_encoder: type[RawEncoder] | type[HexEncoder], +) -> Callable[[bytes, bytes], bytes]: """Return encryption function and length of key. Async friendly. """ - def encrypt(ciphertext, key): + def encrypt(ciphertext: bytes, key: bytes) -> bytes: """Encrypt ciphertext using key.""" return SecretBox(key, encoder=key_encoder).encrypt( ciphertext, encoder=Base64Encoder ) - return (SecretBox.KEY_SIZE, encrypt) + return encrypt def _decrypt_payload_helper( - key: str | None, - ciphertext: str, - get_key_bytes: Callable[[str, int], str | bytes], - key_encoder, + key: str | bytes, + ciphertext: bytes, + key_bytes: bytes, + key_encoder: type[RawEncoder] | type[HexEncoder], ) -> JsonValueType | None: """Decrypt encrypted payload.""" try: - keylen, decrypt = setup_decrypt(key_encoder) + decrypt = setup_decrypt(key_encoder) except OSError: _LOGGER.warning("Ignoring encrypted payload because libsodium not installed") return None @@ -83,33 +87,31 @@ def _decrypt_payload_helper( _LOGGER.warning("Ignoring encrypted payload because no decryption key known") return None - key_bytes = get_key_bytes(key, keylen) - msg_bytes = decrypt(ciphertext, key_bytes) message = json_loads(msg_bytes) _LOGGER.debug("Successfully decrypted mobile_app payload") return message -def decrypt_payload(key: str | None, ciphertext: str) -> JsonValueType | None: +def decrypt_payload(key: str, ciphertext: bytes) -> JsonValueType | None: """Decrypt encrypted payload.""" + return _decrypt_payload_helper(key, ciphertext, key.encode("utf-8"), HexEncoder) - def get_key_bytes(key: str, keylen: int) -> str: - return key - return _decrypt_payload_helper(key, ciphertext, get_key_bytes, HexEncoder) +def _convert_legacy_encryption_key(key: str) -> bytes: + """Convert legacy encryption key.""" + keylen = SecretBox.KEY_SIZE + key_bytes = key.encode("utf-8") + key_bytes = key_bytes[:keylen] + key_bytes = key_bytes.ljust(keylen, b"\0") + return key_bytes -def decrypt_payload_legacy(key: str | None, ciphertext: str) -> JsonValueType | None: +def decrypt_payload_legacy(key: str, ciphertext: bytes) -> JsonValueType | None: """Decrypt encrypted payload.""" - - def get_key_bytes(key: str, keylen: int) -> bytes: - key_bytes = key.encode("utf-8") - key_bytes = key_bytes[:keylen] - key_bytes = key_bytes.ljust(keylen, b"\0") - return key_bytes - - return _decrypt_payload_helper(key, ciphertext, get_key_bytes, RawEncoder) + return _decrypt_payload_helper( + key, ciphertext, _convert_legacy_encryption_key(key), RawEncoder + ) def registration_context(registration: Mapping[str, Any]) -> Context: @@ -184,16 +186,14 @@ def webhook_response( json_data = json_bytes(data) if registration[ATTR_SUPPORTS_ENCRYPTION]: - keylen, encrypt = setup_encrypt( + encrypt = setup_encrypt( HexEncoder if ATTR_NO_LEGACY_ENCRYPTION in registration else RawEncoder ) if ATTR_NO_LEGACY_ENCRYPTION in registration: key: bytes = registration[CONF_SECRET] else: - key = registration[CONF_SECRET].encode("utf-8") - key = key[:keylen] - key = key.ljust(keylen, b"\0") + key = _convert_legacy_encryption_key(registration[CONF_SECRET]) enc_data = encrypt(json_data, key).decode("utf-8") json_data = json_bytes({"encrypted": True, "encrypted_data": enc_data}) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 1f8e5bbf2e7e55..ac229cb677fb9d 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -47,6 +47,7 @@ publish, subscribe, ) +from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA # noqa: F401 from .config_integration import CONFIG_SCHEMA_BASE from .const import ( # noqa: F401 ATTR_PAYLOAD, diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 4eb3a3f5171794..cb0e840604edcf 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -47,7 +47,7 @@ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, } -).extend(mqtt.config.MQTT_RO_SCHEMA.schema) +).extend(mqtt.MQTT_RO_SCHEMA.schema) @lru_cache(maxsize=256) diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index 32f7329a0a2fa4..be571460b4a627 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], "quality_scale": "platinum", - "requirements": ["nettigo-air-monitor==2.1.0"], + "requirements": ["nettigo-air-monitor==2.2.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index bf24fc4a4e9e8f..892446422073cf 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.2"] + "requirements": ["google-nest-sdm==3.0.3"] } diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index 2f13632dc46e1a..ddd2e400dabd22 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["nextdns"], "quality_scale": "platinum", - "requirements": ["nextdns==1.4.0"] + "requirements": ["nextdns==2.0.0"] } diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 88e038425043bc..a27d6f6f6808db 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.0.37"] + "requirements": ["opower==0.0.38"] } diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 32146c2753ff6a..a33cef0e3a7713 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -66,13 +66,6 @@ def __init__( self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._attr_preset_modes = presets - # Determine hvac modes and current hvac mode - self._attr_hvac_modes = [HVACMode.HEAT] - if self.coordinator.data.gateway["cooling_present"]: - self._attr_hvac_modes = [HVACMode.HEAT_COOL] - if self.device["available_schedules"] != ["None"]: - self._attr_hvac_modes.append(HVACMode.AUTO) - self._attr_min_temp = self.device["thermostat"]["lower_bound"] self._attr_max_temp = self.device["thermostat"]["upper_bound"] # Ensure we don't drop below 0.1 @@ -117,6 +110,18 @@ def hvac_mode(self) -> HVACMode: return HVACMode.HEAT return HVACMode(mode) + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of available HVACModes.""" + hvac_modes = [HVACMode.HEAT] + if self.coordinator.data.gateway["cooling_present"]: + hvac_modes = [HVACMode.HEAT_COOL] + + if self.device["available_schedules"] != ["None"]: + hvac_modes.append(HVACMode.AUTO) + + return hvac_modes + @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" @@ -164,9 +169,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: raise HomeAssistantError("Unsupported hvac_mode") await self.coordinator.api.set_schedule_state( - self.device["location"], - self.device["last_used"], - "on" if hvac_mode == HVACMode.AUTO else "off", + self.device["location"], "on" if hvac_mode == HVACMode.AUTO else "off" ) @plugwise_command diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index b4cc418cc7eca7..1155aaffdf860c 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.33.1"], + "requirements": ["plugwise==0.33.2"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index a45204351619db..23a8fc3bf649ab 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -70,25 +70,25 @@ def async_setup_proximity_component( ignored_zones: list[str] = config[CONF_IGNORED_ZONES] proximity_devices: list[str] = config[CONF_DEVICES] tolerance: int = config[CONF_TOLERANCE] - proximity_zone = name + proximity_zone = config[CONF_ZONE] unit_of_measurement: str = config.get( CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit ) - zone_id = f"zone.{config[CONF_ZONE]}" + zone_friendly_name = name proximity = Proximity( hass, - proximity_zone, + zone_friendly_name, DEFAULT_DIST_TO_ZONE, DEFAULT_DIR_OF_TRAVEL, DEFAULT_NEAREST, ignored_zones, proximity_devices, tolerance, - zone_id, + proximity_zone, unit_of_measurement, ) - proximity.entity_id = f"{DOMAIN}.{proximity_zone}" + proximity.entity_id = f"{DOMAIN}.{zone_friendly_name}" proximity.async_write_ha_state() @@ -171,7 +171,7 @@ def async_check_proximity_state_change( devices_to_calculate = False devices_in_zone = "" - zone_state = self.hass.states.get(self.proximity_zone) + zone_state = self.hass.states.get(f"zone.{self.proximity_zone}") proximity_latitude = ( zone_state.attributes.get(ATTR_LATITUDE) if zone_state else None ) @@ -189,7 +189,7 @@ def async_check_proximity_state_change( devices_to_calculate = True # Check the location of all devices. - if (device_state.state).lower() == (self.friendly_name).lower(): + if (device_state.state).lower() == (self.proximity_zone).lower(): device_friendly = device_state.name if devices_in_zone != "": devices_in_zone = f"{devices_in_zone}, " diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index 53e8d4b96607c4..fd9577f5c73185 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -16,6 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN +from .coordinator import QBittorrentDataCoordinator from .helpers import setup_client PLATFORMS = [Platform.SENSOR] @@ -27,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up qBittorrent from a config entry.""" hass.data.setdefault(DOMAIN, {}) try: - hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( + client = await hass.async_add_executor_job( setup_client, entry.data[CONF_URL], entry.data[CONF_USERNAME], @@ -38,7 +39,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady("Invalid credentials") from err except RequestException as err: raise ConfigEntryNotReady("Failed to connect") from err + coordinator = QBittorrentDataCoordinator(hass, client) + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/qbittorrent/coordinator.py b/homeassistant/components/qbittorrent/coordinator.py new file mode 100644 index 00000000000000..8363a764d0a718 --- /dev/null +++ b/homeassistant/components/qbittorrent/coordinator.py @@ -0,0 +1,38 @@ +"""The QBittorrent coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from qbittorrent import Client +from qbittorrent.client import LoginRequired + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """QBittorrent update coordinator.""" + + def __init__(self, hass: HomeAssistant, client: Client) -> None: + """Initialize coordinator.""" + self.client = client + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + + async def _async_update_data(self) -> dict[str, Any]: + try: + return await self.hass.async_add_executor_job(self.client.sync_main_data) + except LoginRequired as exc: + raise ConfigEntryError("Invalid authentication") from exc diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 5cca77ecc34736..e2feee1e60c795 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -1,10 +1,10 @@ """Support for monitoring the qBittorrent API.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass import logging - -from qbittorrent.client import Client, LoginRequired -from requests.exceptions import RequestException +from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, @@ -16,8 +16,11 @@ from homeassistant.const import STATE_IDLE, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import QBittorrentDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -25,26 +28,61 @@ SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed" SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( + +@dataclass +class QBittorrentMixin: + """Mixin for required keys.""" + + value_fn: Callable[[dict[str, Any]], StateType] + + +@dataclass +class QBittorrentSensorEntityDescription(SensorEntityDescription, QBittorrentMixin): + """Describes QBittorrent sensor entity.""" + + +def _get_qbittorrent_state(data: dict[str, Any]) -> str: + download = data["server_state"]["dl_info_speed"] + upload = data["server_state"]["up_info_speed"] + + if upload > 0 and download > 0: + return "up_down" + if upload > 0 and download == 0: + return "seeding" + if upload == 0 and download > 0: + return "downloading" + return STATE_IDLE + + +def format_speed(speed): + """Return a bytes/s measurement as a human readable string.""" + kb_spd = float(speed) / 1024 + return round(kb_spd, 2 if kb_spd < 0.1 else 1) + + +SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( + QBittorrentSensorEntityDescription( key=SENSOR_TYPE_CURRENT_STATUS, name="Status", + value_fn=_get_qbittorrent_state, ), - SensorEntityDescription( + QBittorrentSensorEntityDescription( key=SENSOR_TYPE_DOWNLOAD_SPEED, name="Down Speed", icon="mdi:cloud-download", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: format_speed(data["server_state"]["dl_info_speed"]), ), - SensorEntityDescription( + QBittorrentSensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, name="Up Speed", icon="mdi:cloud-upload", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: format_speed(data["server_state"]["up_info_speed"]), ), ) @@ -55,68 +93,33 @@ async def async_setup_entry( async_add_entites: AddEntitiesCallback, ) -> None: """Set up qBittorrent sensor entries.""" - client: Client = hass.data[DOMAIN][config_entry.entry_id] + coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] entities = [ - QBittorrentSensor(description, client, config_entry) + QBittorrentSensor(description, coordinator, config_entry) for description in SENSOR_TYPES ] - async_add_entites(entities, True) + async_add_entites(entities) -def format_speed(speed): - """Return a bytes/s measurement as a human readable string.""" - kb_spd = float(speed) / 1024 - return round(kb_spd, 2 if kb_spd < 0.1 else 1) +class QBittorrentSensor(CoordinatorEntity[QBittorrentDataCoordinator], SensorEntity): + """Representation of a qBittorrent sensor.""" - -class QBittorrentSensor(SensorEntity): - """Representation of an qBittorrent sensor.""" + entity_description: QBittorrentSensorEntityDescription def __init__( self, - description: SensorEntityDescription, - qbittorrent_client: Client, + description: QBittorrentSensorEntityDescription, + coordinator: QBittorrentDataCoordinator, config_entry: ConfigEntry, ) -> None: """Initialize the qBittorrent sensor.""" + super().__init__(coordinator) self.entity_description = description - self.client = qbittorrent_client - self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" self._attr_name = f"{config_entry.title} {description.name}" self._attr_available = False - def update(self) -> None: - """Get the latest data from qBittorrent and updates the state.""" - try: - data = self.client.sync_main_data() - self._attr_available = True - except RequestException: - _LOGGER.error("Connection lost") - self._attr_available = False - return - except LoginRequired: - _LOGGER.error("Invalid authentication") - return - - if data is None: - return - - download = data["server_state"]["dl_info_speed"] - upload = data["server_state"]["up_info_speed"] - - sensor_type = self.entity_description.key - if sensor_type == SENSOR_TYPE_CURRENT_STATUS: - if upload > 0 and download > 0: - self._attr_native_value = "up_down" - elif upload > 0 and download == 0: - self._attr_native_value = "seeding" - elif upload == 0 and download > 0: - self._attr_native_value = "downloading" - else: - self._attr_native_value = STATE_IDLE - - elif sensor_type == SENSOR_TYPE_DOWNLOAD_SPEED: - self._attr_native_value = format_speed(download) - elif sensor_type == SENSOR_TYPE_UPLOAD_SPEED: - self._attr_native_value = format_speed(upload) + @property + def native_value(self) -> StateType: + """Return value of sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/random/__init__.py b/homeassistant/components/random/__init__.py index 01bde80b0c3784..89a772529bd30b 100644 --- a/homeassistant/components/random/__init__.py +++ b/homeassistant/components/random/__init__.py @@ -1 +1,24 @@ """The random component.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + await hass.config_entries.async_forward_entry_setups( + entry, (entry.options["entity_type"],) + ) + entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) + return True + + +async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener, called when the config entry options are changed.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms( + entry, (entry.options["entity_type"],) + ) diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index 5e688162124755..9ada2ecd6212a7 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -1,7 +1,9 @@ """Support for showing random states.""" from __future__ import annotations +from collections.abc import Mapping from random import getrandbits +from typing import Any import voluptuous as vol @@ -10,13 +12,14 @@ PLATFORM_SCHEMA, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, 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, DiscoveryInfoType -DEFAULT_NAME = "Random Binary Sensor" +DEFAULT_NAME = "Random binary sensor" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -33,20 +36,32 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Random binary sensor.""" - name = config.get(CONF_NAME) - device_class = config.get(CONF_DEVICE_CLASS) - async_add_entities([RandomSensor(name, device_class)], True) + async_add_entities([RandomBinarySensor(config)], True) -class RandomSensor(BinarySensorEntity): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize config entry.""" + async_add_entities( + [RandomBinarySensor(config_entry.options, config_entry.entry_id)], True + ) + + +class RandomBinarySensor(BinarySensorEntity): """Representation of a Random binary sensor.""" - def __init__(self, name, device_class): + _state: bool | None = None + + def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random binary sensor.""" - self._name = name - self._device_class = device_class - self._state = None + self._name = config.get(CONF_NAME) + self._device_class = config.get(CONF_DEVICE_CLASS) + if entry_id: + self._attr_unique_id = entry_id @property def name(self): diff --git a/homeassistant/components/random/config_flow.py b/homeassistant/components/random/config_flow.py new file mode 100644 index 00000000000000..96dde9c87422f7 --- /dev/null +++ b/homeassistant/components/random/config_flow.py @@ -0,0 +1,186 @@ +"""Config flow for Random helper.""" +from collections.abc import Callable, Coroutine, Mapping +from enum import StrEnum +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import DEVICE_CLASS_UNITS, SensorDeviceClass +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_MAXIMUM, + CONF_MINIMUM, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + Platform, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, + SchemaFlowMenuStep, +) +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) + +from .const import DOMAIN +from .sensor import DEFAULT_MAX, DEFAULT_MIN + + +class _FlowType(StrEnum): + CONFIG = "config" + OPTION = "option" + + +def _generate_schema(domain: str, flow_type: _FlowType) -> vol.Schema: + """Generate schema.""" + schema: dict[vol.Marker, Any] = {} + + if flow_type == _FlowType.CONFIG: + schema[vol.Required(CONF_NAME)] = TextSelector() + + if domain == Platform.BINARY_SENSOR: + schema[vol.Optional(CONF_DEVICE_CLASS)] = SelectSelector( + SelectSelectorConfig( + options=[cls.value for cls in BinarySensorDeviceClass], + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="binary_sensor_device_class", + ), + ) + + if domain == Platform.SENSOR: + schema.update( + { + vol.Optional(CONF_MINIMUM, default=DEFAULT_MIN): cv.positive_int, + vol.Optional(CONF_MAXIMUM, default=DEFAULT_MAX): cv.positive_int, + vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + SelectSelectorConfig( + options=[ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ], + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="sensor_device_class", + ), + ), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + SelectSelectorConfig( + options=[ + str(unit) + for units in DEVICE_CLASS_UNITS.values() + for unit in units + if unit is not None + ], + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="sensor_unit_of_measurement", + custom_value=True, + ), + ), + } + ) + + return vol.Schema(schema) + + +async def choose_options_step(options: dict[str, Any]) -> str: + """Return next step_id for options flow according to template_type.""" + return cast(str, options["entity_type"]) + + +def _validate_unit(options: dict[str, Any]) -> None: + """Validate unit of measurement.""" + if ( + (device_class := options.get(CONF_DEVICE_CLASS)) + and (units := DEVICE_CLASS_UNITS.get(device_class)) + and (unit := options.get(CONF_UNIT_OF_MEASUREMENT)) not in units + ): + sorted_units = sorted( + [f"'{str(unit)}'" if unit else "no unit of measurement" for unit in units], + key=str.casefold, + ) + if len(sorted_units) == 1: + units_string = sorted_units[0] + else: + units_string = f"one of {', '.join(sorted_units)}" + + raise vol.Invalid( + f"'{unit}' is not a valid unit for device class '{device_class}'; " + f"expected {units_string}" + ) + + +def validate_user_input( + template_type: str, +) -> Callable[ + [SchemaCommonFlowHandler, dict[str, Any]], + Coroutine[Any, Any, dict[str, Any]], +]: + """Do post validation of user input. + + For sensors: Validate unit of measurement. + """ + + async def _validate_user_input( + _: SchemaCommonFlowHandler, + user_input: dict[str, Any], + ) -> dict[str, Any]: + """Add template type to user input.""" + if template_type == Platform.SENSOR: + _validate_unit(user_input) + return {"entity_type": template_type} | user_input + + return _validate_user_input + + +RANDOM_TYPES = [ + Platform.BINARY_SENSOR.value, + Platform.SENSOR.value, +] + +CONFIG_FLOW = { + "user": SchemaFlowMenuStep(RANDOM_TYPES), + Platform.BINARY_SENSOR: SchemaFlowFormStep( + _generate_schema(Platform.BINARY_SENSOR, _FlowType.CONFIG), + validate_user_input=validate_user_input(Platform.BINARY_SENSOR), + ), + Platform.SENSOR: SchemaFlowFormStep( + _generate_schema(Platform.SENSOR, _FlowType.CONFIG), + validate_user_input=validate_user_input(Platform.SENSOR), + ), +} + + +OPTIONS_FLOW = { + "init": SchemaFlowFormStep(next_step=choose_options_step), + Platform.BINARY_SENSOR: SchemaFlowFormStep( + _generate_schema(Platform.BINARY_SENSOR, _FlowType.OPTION), + validate_user_input=validate_user_input(Platform.BINARY_SENSOR), + ), + Platform.SENSOR: SchemaFlowFormStep( + _generate_schema(Platform.SENSOR, _FlowType.OPTION), + validate_user_input=validate_user_input(Platform.SENSOR), + ), +} + + +class RandomConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle config flow for random helper.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + @callback + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options["name"]) diff --git a/homeassistant/components/random/const.py b/homeassistant/components/random/const.py new file mode 100644 index 00000000000000..df6a18f8d11bbf --- /dev/null +++ b/homeassistant/components/random/const.py @@ -0,0 +1,5 @@ +"""Constants for random helper.""" +DOMAIN = "random" + +DEFAULT_MIN = 0 +DEFAULT_MAX = 20 diff --git a/homeassistant/components/random/manifest.json b/homeassistant/components/random/manifest.json index 164445fd8edc09..36396f0a1f6738 100644 --- a/homeassistant/components/random/manifest.json +++ b/homeassistant/components/random/manifest.json @@ -2,7 +2,9 @@ "domain": "random", "name": "Random", "codeowners": ["@fabaff"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/random", - "iot_class": "local_polling", + "integration_type": "helper", + "iot_class": "calculated", "quality_scale": "internal" } diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index d4db30fd61efe4..8e77f026253ad1 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -1,12 +1,16 @@ """Support for showing random numbers.""" from __future__ import annotations +from collections.abc import Mapping from random import randrange +from typing import Any import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_MAXIMUM, CONF_MINIMUM, CONF_NAME, @@ -17,12 +21,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DEFAULT_MAX, DEFAULT_MIN + ATTR_MAXIMUM = "maximum" ATTR_MINIMUM = "minimum" -DEFAULT_NAME = "Random Sensor" -DEFAULT_MIN = 0 -DEFAULT_MAX = 20 +DEFAULT_NAME = "Random sensor" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -42,26 +46,37 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Random number sensor.""" - name = config.get(CONF_NAME) - minimum = config.get(CONF_MINIMUM) - maximum = config.get(CONF_MAXIMUM) - unit = config.get(CONF_UNIT_OF_MEASUREMENT) - async_add_entities([RandomSensor(name, minimum, maximum, unit)], True) + async_add_entities([RandomSensor(config)], True) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize config entry.""" + + async_add_entities( + [RandomSensor(config_entry.options, config_entry.entry_id)], True + ) class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" _attr_icon = "mdi:hanger" + _state: int | None = None - def __init__(self, name, minimum, maximum, unit_of_measurement): + def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random sensor.""" - self._name = name - self._minimum = minimum - self._maximum = maximum - self._unit_of_measurement = unit_of_measurement - self._state = None + self._name = config.get(CONF_NAME) + self._minimum = config.get(CONF_MINIMUM, DEFAULT_MIN) + self._maximum = config.get(CONF_MAXIMUM, DEFAULT_MAX) + self._unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + if entry_id: + self._attr_unique_id = entry_id @property def name(self): diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json new file mode 100644 index 00000000000000..164f184ae884f8 --- /dev/null +++ b/homeassistant/components/random/strings.json @@ -0,0 +1,48 @@ +{ + "config": { + "step": { + "binary_sensor": { + "data": { + "device_class": "[%key:component::random::config::step::sensor::data::device_class%]", + "name": "[%key:common::config_flow::data::name%]" + }, + "title": "Random binary sensor" + }, + "sensor": { + "data": { + "device_class": "Device class", + "name": "[%key:common::config_flow::data::name%]", + "minimum": "Minimum", + "maximum": "Maximum", + "unit_of_measurement": "Unit of measurement" + }, + "title": "Random sensor" + }, + "user": { + "description": "This helper allow you to create a helper that emits a random value.", + "menu_options": { + "binary_sensor": "Random binary sensor", + "sensor": "Random sensor" + }, + "title": "Random helper" + } + } + }, + "options": { + "step": { + "binary_sensor": { + "title": "[%key:component::random::config::step::binary_sensor::title%]", + "description": "This helper does not have any options." + }, + "sensor": { + "data": { + "device_class": "[%key:component::random::config::step::sensor::data::device_class%]", + "minimum": "[%key:component::random::config::step::sensor::data::minimum%]", + "maximum": "[%key:component::random::config::step::sensor::data::maximum%]", + "unit_of_measurement": "[%key:component::random::config::step::sensor::data::unit_of_measurement%]" + }, + "title": "[%key:component::random::config::step::sensor::title%]" + } + } + } +} diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index 320b0fc7c6de8d..a8f6a6fbb4f6e0 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -69,6 +69,14 @@ class RoborockBinarySensorDescription( entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.water_shortage_status, ), + RoborockBinarySensorDescription( + key="in_cleaning", + translation_key="in_cleaning", + icon="mdi:vacuum", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.in_cleaning, + ), ] diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 87d06f92f46b40..06cffcc2291888 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -28,6 +28,9 @@ }, "entity": { "binary_sensor": { + "in_cleaning": { + "name": "Cleaning" + }, "mop_attached": { "name": "Mop attached" }, diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index e61ca04374f46e..69bed1af700ea2 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.9.3"] + "requirements": ["screenlogicpy==0.9.4"] } diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 92100eaddafcd4..368a997c62e02e 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -618,6 +618,13 @@ def _update_callback(self) -> None: super()._update_callback() return + async def async_update(self) -> None: + """Update the entity.""" + LOGGER.info( + "Entity %s comes from a sleeping device, update is not possible", + self.entity_id, + ) + class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): """Helper class to represent a sleeping rpc attribute.""" @@ -654,3 +661,10 @@ def __init__( ) elif entry is not None: self._attr_name = cast(str, entry.original_name) + + async def async_update(self) -> None: + """Update the entity.""" + LOGGER.info( + "Entity %s comes from a sleeping device, update is not possible", + self.entity_id, + ) diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index f2de59b10af6a9..e2f04b5d88062f 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -322,17 +322,23 @@ def async_reorder(self, item_ids, context=None): context=context, ) - async def async_move_item(self, uid: str, pos: int) -> None: + async def async_move_item(self, uid: str, previous: str | None = None) -> None: """Re-order a shopping list item.""" - found_item: dict[str, Any] | None = None - for idx, itm in enumerate(self.items): - if cast(str, itm["id"]) == uid: - found_item = itm - self.items.pop(idx) - break - if not found_item: + if uid == previous: + return + item_idx = {cast(str, itm["id"]): idx for idx, itm in enumerate(self.items)} + if uid not in item_idx: raise NoMatchingShoppingListItem(f"Item '{uid}' not found in shopping list") - self.items.insert(pos, found_item) + if previous and previous not in item_idx: + raise NoMatchingShoppingListItem( + f"Item '{previous}' not found in shopping list" + ) + dst_idx = item_idx[previous] + 1 if previous else 0 + src_idx = item_idx[uid] + src_item = self.items.pop(src_idx) + if dst_idx > src_idx: + dst_idx -= 1 + self.items.insert(dst_idx, src_item) await self.hass.async_add_executor_job(self.save) self._async_notify() self.hass.bus.async_fire( diff --git a/homeassistant/components/shopping_list/todo.py b/homeassistant/components/shopping_list/todo.py index 53c9e6b6d74aae..d89f376d662631 100644 --- a/homeassistant/components/shopping_list/todo.py +++ b/homeassistant/components/shopping_list/todo.py @@ -71,11 +71,13 @@ async def async_delete_todo_items(self, uids: list[str]) -> None: """Add an item to the To-do list.""" await self._data.async_remove_items(set(uids)) - async def async_move_todo_item(self, uid: str, pos: int) -> None: + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: """Re-order an item to the To-do list.""" try: - await self._data.async_move_item(uid, pos) + await self._data.async_move_item(uid, previous_uid) except NoMatchingShoppingListItem as err: raise HomeAssistantError( f"Shopping list item '{uid}' could not be re-ordered" diff --git a/homeassistant/components/starlink/manifest.json b/homeassistant/components/starlink/manifest.json index c719afa968dc92..b8733dd243567f 100644 --- a/homeassistant/components/starlink/manifest.json +++ b/homeassistant/components/starlink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/starlink", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["starlink-grpc-core==1.1.2"] + "requirements": ["starlink-grpc-core==1.1.3"] } diff --git a/homeassistant/components/starlink/strings.json b/homeassistant/components/starlink/strings.json index 0ec85c68956f10..bc6807e8ba7e1c 100644 --- a/homeassistant/components/starlink/strings.json +++ b/homeassistant/components/starlink/strings.json @@ -26,7 +26,7 @@ "name": "Heating" }, "power_save_idle": { - "name": "[%key:common::state::idle%]" + "name": "Sleep" }, "mast_near_vertical": { "name": "Mast near vertical" diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index c34348137e7221..8d3b2443b1866b 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -1,27 +1,28 @@ """The SwitchBot via API integration.""" from asyncio import gather -from dataclasses import dataclass +from dataclasses import dataclass, field from logging import getLogger from switchbot_api import CannotConnect, Device, InvalidAuth, Remote, SwitchBotAPI from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .coordinator import SwitchBotCoordinator _LOGGER = getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SWITCH] @dataclass class SwitchbotDevices: """Switchbot devices data.""" - switches: list[Device | Remote] + climates: list[Remote] = field(default_factory=list) + switches: list[Device | Remote] = field(default_factory=list) @dataclass @@ -32,18 +33,47 @@ class SwitchbotCloudData: devices: SwitchbotDevices +@callback def prepare_device( hass: HomeAssistant, api: SwitchBotAPI, device: Device | Remote, - coordinators: list[SwitchBotCoordinator], + coordinators_by_id: dict[str, SwitchBotCoordinator], ) -> tuple[Device | Remote, SwitchBotCoordinator]: """Instantiate coordinator and adds to list for gathering.""" - coordinator = SwitchBotCoordinator(hass, api, device) - coordinators.append(coordinator) + coordinator = coordinators_by_id.setdefault( + device.device_id, SwitchBotCoordinator(hass, api, device) + ) return (device, coordinator) +@callback +def make_device_data( + hass: HomeAssistant, + api: SwitchBotAPI, + devices: list[Device | Remote], + coordinators_by_id: dict[str, SwitchBotCoordinator], +) -> SwitchbotDevices: + """Make device data.""" + devices_data = SwitchbotDevices() + for device in devices: + if isinstance(device, Remote) and device.device_type.endswith( + "Air Conditioner" + ): + devices_data.climates.append( + prepare_device(hass, api, device, coordinators_by_id) + ) + if ( + isinstance(device, Device) + and device.device_type.startswith("Plug") + or isinstance(device, Remote) + ): + devices_data.switches.append( + prepare_device(hass, api, device, coordinators_by_id) + ) + return devices_data + + async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" token = config.data[CONF_API_TOKEN] @@ -60,25 +90,15 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: except CannotConnect as ex: raise ConfigEntryNotReady from ex _LOGGER.debug("Devices: %s", devices) - coordinators: list[SwitchBotCoordinator] = [] + coordinators_by_id: dict[str, SwitchBotCoordinator] = {} hass.data.setdefault(DOMAIN, {}) - data = SwitchbotCloudData( - api=api, - devices=SwitchbotDevices( - switches=[ - prepare_device(hass, api, device, coordinators) - for device in devices - if isinstance(device, Device) - and device.device_type.startswith("Plug") - or isinstance(device, Remote) - ], - ), + hass.data[DOMAIN][config.entry_id] = SwitchbotCloudData( + api=api, devices=make_device_data(hass, api, devices, coordinators_by_id) ) - hass.data[DOMAIN][config.entry_id] = data - for device_type, devices in vars(data.devices).items(): - _LOGGER.debug("%s: %s", device_type, devices) await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) - await gather(*[coordinator.async_refresh() for coordinator in coordinators]) + await gather( + *[coordinator.async_refresh() for coordinator in coordinators_by_id.values()] + ) return True diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py new file mode 100644 index 00000000000000..803669c806d759 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -0,0 +1,120 @@ +"""Support for SwitchBot Air Conditioner remotes.""" + +from typing import Any + +from switchbot_api import AirConditionerCommands + +import homeassistant.components.climate as FanState +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SwitchbotCloudData +from .const import DOMAIN +from .entity import SwitchBotCloudEntity + +_SWITCHBOT_HVAC_MODES: dict[HVACMode, int] = { + HVACMode.HEAT_COOL: 1, + HVACMode.COOL: 2, + HVACMode.DRY: 3, + HVACMode.FAN_ONLY: 4, + HVACMode.HEAT: 5, +} + +_DEFAULT_SWITCHBOT_HVAC_MODE = _SWITCHBOT_HVAC_MODES[HVACMode.FAN_ONLY] + +_SWITCHBOT_FAN_MODES: dict[str, int] = { + FanState.FAN_AUTO: 1, + FanState.FAN_LOW: 2, + FanState.FAN_MEDIUM: 3, + FanState.FAN_HIGH: 4, +} + +_DEFAULT_SWITCHBOT_FAN_MODE = _SWITCHBOT_FAN_MODES[FanState.FAN_AUTO] + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + SwitchBotCloudAirConditionner(data.api, device, coordinator) + for device, coordinator in data.devices.climates + ) + + +class SwitchBotCloudAirConditionner(SwitchBotCloudEntity, ClimateEntity): + """Representation of a SwitchBot air conditionner. + + As it is an IR device, we don't know the actual state. + """ + + _attr_assumed_state = True + _attr_supported_features = ( + ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + _attr_fan_modes = [ + FanState.FAN_AUTO, + FanState.FAN_LOW, + FanState.FAN_MEDIUM, + FanState.FAN_HIGH, + ] + _attr_fan_mode = FanState.FAN_AUTO + _attr_hvac_modes = [ + HVACMode.HEAT_COOL, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.HEAT, + ] + _attr_hvac_mode = HVACMode.FAN_ONLY + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature = 21 + _attr_name = None + + async def _do_send_command( + self, + hvac_mode: HVACMode | None = None, + fan_mode: str | None = None, + temperature: float | None = None, + ) -> None: + new_temperature = temperature or self._attr_target_temperature + new_mode = _SWITCHBOT_HVAC_MODES.get( + hvac_mode or self._attr_hvac_mode, _DEFAULT_SWITCHBOT_HVAC_MODE + ) + new_fan_speed = _SWITCHBOT_FAN_MODES.get( + fan_mode or self._attr_fan_mode, _DEFAULT_SWITCHBOT_FAN_MODE + ) + await self.send_command( + AirConditionerCommands.SET_ALL, + parameters=f"{new_temperature},{new_mode},{new_fan_speed},on", + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set target hvac mode.""" + await self._do_send_command(hvac_mode=hvac_mode) + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set target fan mode.""" + await self._do_send_command(fan_mode=fan_mode) + self._attr_fan_mode = fan_mode + self.async_write_ha_state() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + return + await self._do_send_command(temperature=temperature) + self._attr_target_temperature = temperature + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py index c63b1713b8de6c..4f2cdc22ba98c4 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -7,7 +7,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import DiscoveryInfoType from . import SwitchbotCloudData from .const import DOMAIN @@ -19,7 +18,6 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index a6660b0231a51f..12eac858f7544a 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -152,8 +152,15 @@ async def async_delete_todo_items(self, uids: list[str]) -> None: """Delete an item in the To-do list.""" raise NotImplementedError() - async def async_move_todo_item(self, uid: str, pos: int) -> None: - """Move an item in the To-do list.""" + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: + """Move an item in the To-do list. + + The To-do item with the specified `uid` should be moved to the position + in the list after the specified by `previous_uid` or `None` for the first + position in the To-do list. + """ raise NotImplementedError() @@ -190,7 +197,7 @@ async def websocket_handle_todo_item_list( vol.Required("type"): "todo/item/move", vol.Required("entity_id"): cv.entity_id, vol.Required("uid"): cv.string, - vol.Optional("pos", default=0): cv.positive_int, + vol.Optional("previous_uid"): cv.string, } ) @websocket_api.async_response @@ -215,9 +222,10 @@ async def websocket_handle_todo_item_move( ) ) return - try: - await entity.async_move_todo_item(uid=msg["uid"], pos=msg["pos"]) + await entity.async_move_todo_item( + uid=msg["uid"], previous_uid=msg.get("previous_uid") + ) except HomeAssistantError as ex: connection.send_error(msg["id"], "failed", str(ex)) else: diff --git a/homeassistant/components/todo/manifest.json b/homeassistant/components/todo/manifest.json index 2edf3309e327bd..8efc93ad4e7770 100644 --- a/homeassistant/components/todo/manifest.json +++ b/homeassistant/components/todo/manifest.json @@ -1,6 +1,6 @@ { "domain": "todo", - "name": "To-do", + "name": "To-do list", "codeowners": ["@home-assistant/core"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/todo", diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index cf5f3da2b3a20d..c31a7e888085e4 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -7,7 +7,7 @@ create_item: fields: summary: required: true - example: "Submit Income Tax Return" + example: "Submit income tax return" selector: text: status: @@ -29,7 +29,7 @@ update_item: selector: text: summary: - example: "Submit Income Tax Return" + example: "Submit income tax return" selector: text: status: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 4a5a33e94e50ff..623c46375f0fd1 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -1,5 +1,5 @@ { - "title": "To-do List", + "title": "To-do list", "entity_component": { "_": { "name": "[%key:component::todo::title%]" @@ -7,48 +7,48 @@ }, "services": { "create_item": { - "name": "Create To-do List Item", - "description": "Add a new To-do List Item.", + "name": "Create to-do list item", + "description": "Add a new to-do list item.", "fields": { "summary": { "name": "Summary", - "description": "The short summary that represents the To-do item." + "description": "The short summary that represents the to-do item." }, "status": { "name": "Status", - "description": "A status or confirmation of the To-do item." + "description": "A status or confirmation of the to-do item." } } }, "update_item": { - "name": "Update To-do List Item", - "description": "Update an existing To-do List Item based on either its Unique Id or Summary.", + "name": "Update to-do list item", + "description": "Update an existing to-do list item based on either its unique ID or summary.", "fields": { "uid": { - "name": "To-do Item Unique Id", - "description": "Unique Identifier for the To-do List Item." + "name": "To-do item unique ID", + "description": "Unique identifier for the to-do list item." }, "summary": { "name": "Summary", - "description": "The short summary that represents the To-do item." + "description": "The short summary that represents the to-do item." }, "status": { "name": "Status", - "description": "A status or confirmation of the To-do item." + "description": "A status or confirmation of the to-do item." } } }, "delete_item": { - "name": "Delete a To-do List Item", - "description": "Delete an existing To-do List Item either by its Unique Id or Summary.", + "name": "Delete a to-do list item", + "description": "Delete an existing to-do list item either by its unique ID or summary.", "fields": { "uid": { - "name": "To-do Item Unique Ids", - "description": "Unique Identifiers for the To-do List Items." + "name": "To-do item unique IDs", + "description": "Unique identifiers for the to-do list items." }, "summary": { "name": "Summary", - "description": "The short summary that represents the To-do item." + "description": "The short summary that represents the to-do item." } } } @@ -56,7 +56,7 @@ "selector": { "status": { "options": { - "needs_action": "Needs Action", + "needs_action": "Not completed", "completed": "Completed" } } diff --git a/homeassistant/components/todoist/__init__.py b/homeassistant/components/todoist/__init__.py index 12b75a40bae256..60c40b1c03c072 100644 --- a/homeassistant/components/todoist/__init__.py +++ b/homeassistant/components/todoist/__init__.py @@ -17,7 +17,7 @@ SCAN_INTERVAL = datetime.timedelta(minutes=1) -PLATFORMS: list[Platform] = [Platform.CALENDAR] +PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.TODO] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py index 6098df40ea047f..b8c79210dfbf95 100644 --- a/homeassistant/components/todoist/config_flow.py +++ b/homeassistant/components/todoist/config_flow.py @@ -44,7 +44,7 @@ async def async_step_user( await api.get_tasks() except HTTPError as err: if err.response.status_code == HTTPStatus.UNAUTHORIZED: - errors["base"] = "invalid_access_token" + errors["base"] = "invalid_api_key" else: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json index 123b5d07ed77d3..442114eb118939 100644 --- a/homeassistant/components/todoist/strings.json +++ b/homeassistant/components/todoist/strings.json @@ -9,10 +9,12 @@ } }, "error": { - "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" @@ -48,7 +50,7 @@ "description": "The day this task is due, in natural language." }, "due_date_lang": { - "name": "Due data language", + "name": "Due date language", "description": "The language of due_date_string." }, "due_date": { diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py new file mode 100644 index 00000000000000..c0d3ec6e2ce8be --- /dev/null +++ b/homeassistant/components/todoist/todo.py @@ -0,0 +1,111 @@ +"""A todo platform for Todoist.""" + +import asyncio +from typing import cast + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TodoistCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Todoist todo platform config entry.""" + coordinator: TodoistCoordinator = hass.data[DOMAIN][entry.entry_id] + projects = await coordinator.async_get_projects() + async_add_entities( + TodoistTodoListEntity(coordinator, entry.entry_id, project.id, project.name) + for project in projects + ) + + +class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntity): + """A Todoist TodoListEntity.""" + + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + ) + + def __init__( + self, + coordinator: TodoistCoordinator, + config_entry_id: str, + project_id: str, + project_name: str, + ) -> None: + """Initialize TodoistTodoListEntity.""" + super().__init__(coordinator=coordinator) + self._project_id = project_id + self._attr_unique_id = f"{config_entry_id}-{project_id}" + self._attr_name = project_name + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.coordinator.data is None: + self._attr_todo_items = None + else: + items = [] + for task in self.coordinator.data: + if task.project_id != self._project_id: + continue + if task.is_completed: + status = TodoItemStatus.COMPLETED + else: + status = TodoItemStatus.NEEDS_ACTION + items.append( + TodoItem( + summary=task.content, + uid=task.id, + status=status, + ) + ) + self._attr_todo_items = items + super()._handle_coordinator_update() + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Create a To-do item.""" + if item.status != TodoItemStatus.NEEDS_ACTION: + raise ValueError("Only active tasks may be created.") + await self.coordinator.api.add_task( + content=item.summary or "", + project_id=self._project_id, + ) + await self.coordinator.async_refresh() + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update a To-do item.""" + uid: str = cast(str, item.uid) + if item.summary: + await self.coordinator.api.update_task(task_id=uid, content=item.summary) + if item.status is not None: + if item.status == TodoItemStatus.COMPLETED: + await self.coordinator.api.close_task(task_id=uid) + else: + await self.coordinator.api.reopen_task(task_id=uid) + await self.coordinator.async_refresh() + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete a To-do item.""" + await asyncio.gather( + *[self.coordinator.api.delete_task(task_id=uid) for uid in uids] + ) + await self.coordinator.async_refresh() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass update state from existing coordinator data.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 626049276f5988..25b814c106aca8 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -327,6 +327,7 @@ class TomorrowioEntity(CoordinatorEntity[TomorrowioDataUpdateCoordinator]): """Base Tomorrow.io Entity.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, @@ -340,7 +341,6 @@ def __init__( self._config_entry = config_entry self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._config_entry.data[CONF_API_KEY])}, - name=INTEGRATION_NAME, manufacturer=INTEGRATION_NAME, sw_version=f"v{self.api_version}", entry_type=DeviceEntryType.SERVICE, diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 4aa2748ad30c26..947bbf6fd2f540 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -25,7 +25,6 @@ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, CONF_API_KEY, - CONF_NAME, PERCENTAGE, UnitOfIrradiance, UnitOfLength, @@ -75,10 +74,6 @@ class TomorrowioSensorEntityDescription(SensorEntityDescription): """Describes a Tomorrow.io sensor entity.""" - # TomorrowioSensor does not support UNDEFINED or None, - # restrict the type to str. - name: str = "" - attribute: str = "" unit_imperial: str | None = None unit_metric: str | None = None @@ -111,16 +106,16 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="feels_like", + translation_key="feels_like", attribute=TMRW_ATTR_FEELS_LIKE, - name="Feels Like", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TomorrowioSensorEntityDescription( key="dew_point", + translation_key="dew_point", attribute=TMRW_ATTR_DEW_POINT, - name="Dew Point", icon="mdi:thermometer-water", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -130,7 +125,6 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa TomorrowioSensorEntityDescription( key="pressure_surface_level", attribute=TMRW_ATTR_PRESSURE_SURFACE_LEVEL, - name="Pressure (Surface Level)", native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, @@ -140,7 +134,6 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa TomorrowioSensorEntityDescription( key="global_horizontal_irradiance", attribute=TMRW_ATTR_SOLAR_GHI, - name="Global Horizontal Irradiance", unit_imperial=UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT, unit_metric=UnitOfIrradiance.WATTS_PER_SQUARE_METER, imperial_conversion=(1 / 3.15459), @@ -150,8 +143,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( key="cloud_base", + translation_key="cloud_base", attribute=TMRW_ATTR_CLOUD_BASE, - name="Cloud Base", icon="mdi:cloud-arrow-down", unit_imperial=UnitOfLength.MILES, unit_metric=UnitOfLength.KILOMETERS, @@ -166,8 +159,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( key="cloud_ceiling", + translation_key="cloud_ceiling", attribute=TMRW_ATTR_CLOUD_CEILING, - name="Cloud Ceiling", icon="mdi:cloud-arrow-up", unit_imperial=UnitOfLength.MILES, unit_metric=UnitOfLength.KILOMETERS, @@ -181,16 +174,16 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa ), TomorrowioSensorEntityDescription( key="cloud_cover", + translation_key="cloud_cover", attribute=TMRW_ATTR_CLOUD_COVER, - name="Cloud Cover", icon="mdi:cloud-percent", native_unit_of_measurement=PERCENTAGE, ), # Data comes in as m/s, convert to mi/h for imperial TomorrowioSensorEntityDescription( key="wind_gust", + translation_key="wind_gust", attribute=TMRW_ATTR_WIND_GUST, - name="Wind Gust", icon="mdi:weather-windy", unit_imperial=UnitOfSpeed.MILES_PER_HOUR, unit_metric=UnitOfSpeed.METERS_PER_SECOND, @@ -202,10 +195,9 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa ), TomorrowioSensorEntityDescription( key="precipitation_type", + translation_key="precipitation_type", attribute=TMRW_ATTR_PRECIPITATION_TYPE, - name="Precipitation Type", value_map=PrecipitationType, - translation_key="precipitation_type", icon="mdi:weather-snowy-rainy", ), # Data comes in as ppb, convert to µg/m^3 @@ -213,7 +205,6 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa TomorrowioSensorEntityDescription( key="ozone", attribute=TMRW_ATTR_OZONE, - name="Ozone", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(48), device_class=SensorDeviceClass.OZONE, @@ -222,7 +213,6 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa TomorrowioSensorEntityDescription( key="particulate_matter_2_5_mm", attribute=TMRW_ATTR_PARTICULATE_MATTER_25, - name="Particulate Matter < 2.5 μm", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, @@ -230,7 +220,6 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa TomorrowioSensorEntityDescription( key="particulate_matter_10_mm", attribute=TMRW_ATTR_PARTICULATE_MATTER_10, - name="Particulate Matter < 10 μm", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, state_class=SensorStateClass.MEASUREMENT, @@ -240,7 +229,6 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa TomorrowioSensorEntityDescription( key="nitrogen_dioxide", attribute=TMRW_ATTR_NITROGEN_DIOXIDE, - name="Nitrogen Dioxide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(46.01), device_class=SensorDeviceClass.NITROGEN_DIOXIDE, @@ -250,7 +238,6 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa TomorrowioSensorEntityDescription( key="carbon_monoxide", attribute=TMRW_ATTR_CARBON_MONOXIDE, - name="Carbon Monoxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, multiplication_factor=1 / 1000, device_class=SensorDeviceClass.CO, @@ -261,7 +248,6 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa TomorrowioSensorEntityDescription( key="sulphur_dioxide", attribute=TMRW_ATTR_SULPHUR_DIOXIDE, - name="Sulphur Dioxide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(64.07), device_class=SensorDeviceClass.SULPHUR_DIOXIDE, @@ -269,90 +255,82 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa ), TomorrowioSensorEntityDescription( key="us_epa_air_quality_index", + translation_key="us_epa_air_quality_index", attribute=TMRW_ATTR_EPA_AQI, - name="US EPA Air Quality Index", device_class=SensorDeviceClass.AQI, state_class=SensorStateClass.MEASUREMENT, ), TomorrowioSensorEntityDescription( key="us_epa_primary_pollutant", + translation_key="primary_pollutant", attribute=TMRW_ATTR_EPA_PRIMARY_POLLUTANT, - name="US EPA Primary Pollutant", value_map=PrimaryPollutantType, - translation_key="primary_pollutant", ), TomorrowioSensorEntityDescription( key="us_epa_health_concern", + translation_key="health_concern", attribute=TMRW_ATTR_EPA_HEALTH_CONCERN, - name="US EPA Health Concern", value_map=HealthConcernType, - translation_key="health_concern", icon="mdi:hospital", ), TomorrowioSensorEntityDescription( key="china_mep_air_quality_index", + translation_key="china_mep_air_quality_index", attribute=TMRW_ATTR_CHINA_AQI, - name="China MEP Air Quality Index", device_class=SensorDeviceClass.AQI, ), TomorrowioSensorEntityDescription( key="china_mep_primary_pollutant", + translation_key="china_mep_primary_pollutant", attribute=TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, - name="China MEP Primary Pollutant", value_map=PrimaryPollutantType, - translation_key="primary_pollutant", ), TomorrowioSensorEntityDescription( key="china_mep_health_concern", + translation_key="china_mep_health_concern", attribute=TMRW_ATTR_CHINA_HEALTH_CONCERN, - name="China MEP Health Concern", value_map=HealthConcernType, - translation_key="health_concern", icon="mdi:hospital", ), TomorrowioSensorEntityDescription( key="tree_pollen_index", + translation_key="pollen_index", attribute=TMRW_ATTR_POLLEN_TREE, - name="Tree Pollen Index", icon="mdi:tree", value_map=PollenIndex, - translation_key="pollen_index", ), TomorrowioSensorEntityDescription( key="weed_pollen_index", + translation_key="weed_pollen_index", attribute=TMRW_ATTR_POLLEN_WEED, - name="Weed Pollen Index", value_map=PollenIndex, - translation_key="pollen_index", icon="mdi:flower-pollen", ), TomorrowioSensorEntityDescription( key="grass_pollen_index", + translation_key="grass_pollen_index", attribute=TMRW_ATTR_POLLEN_GRASS, - name="Grass Pollen Index", icon="mdi:grass", value_map=PollenIndex, - translation_key="pollen_index", ), TomorrowioSensorEntityDescription( key="fire_index", + translation_key="fire_index", attribute=TMRW_ATTR_FIRE_INDEX, - name="Fire Index", icon="mdi:fire", ), TomorrowioSensorEntityDescription( key="uv_index", + translation_key="uv_index", attribute=TMRW_ATTR_UV_INDEX, - name="UV Index", state_class=SensorStateClass.MEASUREMENT, icon="mdi:sun-wireless", ), TomorrowioSensorEntityDescription( key="uv_radiation_health_concern", + translation_key="uv_radiation_health_concern", attribute=TMRW_ATTR_UV_HEALTH_CONCERN, - name="UV Radiation Health Concern", value_map=UVDescription, - translation_key="uv_index", icon="mdi:weather-sunny-alert", ), ) @@ -399,7 +377,6 @@ def __init__( """Initialize Tomorrow.io Sensor Entity.""" super().__init__(config_entry, coordinator, api_version) self.entity_description = description - self._attr_name = f"{self._config_entry.data[CONF_NAME]} - {description.name}" self._attr_unique_id = f"{self._config_entry.unique_id}_{description.key}" if self.entity_description.native_unit_of_measurement is None: self._attr_native_unit_of_measurement = description.unit_metric diff --git a/homeassistant/components/tomorrowio/strings.json b/homeassistant/components/tomorrowio/strings.json index a104570f5c88a8..03a8a16992002b 100644 --- a/homeassistant/components/tomorrowio/strings.json +++ b/homeassistant/components/tomorrowio/strings.json @@ -33,46 +33,125 @@ }, "entity": { "sensor": { + "feels_like": { + "name": "Feels like" + }, + "dew_point": { + "name": "Dew point" + }, + "cloud_base": { + "name": "Cloud base" + }, + "cloud_ceiling": { + "name": "Cloud ceiling" + }, + "cloud_cover": { + "name": "Cloud cover" + }, + "wind_gust": { + "name": "Wind gust" + }, + "precipitation_type": { + "name": "Precipitation type", + "state": { + "none": "None", + "rain": "Rain", + "snow": "Snow", + "freezing_rain": "Freezing rain", + "ice_pellets": "Ice pellets" + } + }, + "us_epa_air_quality_index": { + "name": "US EPA air quality index" + }, + "primary_pollutant": { + "name": "US EPA primary pollutant", + "state": { + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "o3": "[%key:component::sensor::entity_component::ozone::name%]", + "no2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "so2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" + } + }, "health_concern": { + "name": "US EPA health concern", "state": { "good": "Good", "moderate": "Moderate", - "unhealthy_for_sensitive_groups": "Unhealthy for Sensitive Groups", + "unhealthy_for_sensitive_groups": "Unhealthy for sensitive groups", "unhealthy": "Unhealthy", - "very_unhealthy": "Very Unhealthy", + "very_unhealthy": "Very unhealthy", "hazardous": "Hazardous" } }, + "china_mep_air_quality_index": { + "name": "China MEP air quality index" + }, + "china_mep_primary_pollutant": { + "name": "China MEP primary pollutant", + "state": { + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "o3": "[%key:component::sensor::entity_component::ozone::name%]", + "no2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "so2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" + } + }, + "china_mep_health_concern": { + "name": "China MEP health concern", + "state": { + "good": "[%key:component::tomorrowio::entity::sensor::health_concern::state::good%]", + "moderate": "[%key:component::tomorrowio::entity::sensor::health_concern::state::moderate%]", + "unhealthy_for_sensitive_groups": "[%key:component::tomorrowio::entity::sensor::health_concern::state::unhealthy_for_sensitive_groups%]", + "unhealthy": "[%key:component::tomorrowio::entity::sensor::health_concern::state::unhealthy%]", + "very_unhealthy": "[%key:component::tomorrowio::entity::sensor::health_concern::state::very_unhealthy%]", + "hazardous": "[%key:component::tomorrowio::entity::sensor::health_concern::state::hazardous%]" + } + }, "pollen_index": { + "name": "Tree pollen index", "state": { "none": "None", - "very_low": "Very Low", + "very_low": "Very low", "low": "Low", "medium": "Medium", "high": "High", - "very_high": "Very High" + "very_high": "Very high" } }, - "precipitation_type": { + "weed_pollen_index": { + "name": "Weed pollen index", "state": { - "none": "None", - "rain": "Rain", - "snow": "Snow", - "freezing_rain": "Freezing Rain", - "ice_pellets": "Ice Pellets" + "none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]", + "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]", + "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]", + "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]", + "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]", + "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]" } }, - "primary_pollutant": { + "grass_pollen_index": { + "name": "Grass pollen index", "state": { - "pm25": "[%key:component::sensor::entity_component::pm25::name%]", - "pm10": "[%key:component::sensor::entity_component::pm10::name%]", - "o3": "[%key:component::sensor::entity_component::ozone::name%]", - "no2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", - "co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", - "so2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" + "none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]", + "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]", + "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]", + "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]", + "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]", + "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]" } }, + "fire_index": { + "name": "Fire index" + }, "uv_index": { + "name": "UV index" + }, + "uv_radiation_health_concern": { + "name": "UV radiation health concern", "state": { "low": "Low", "moderate": "Moderate", diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index b0b82d81463d62..06a147366e8f1e 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -24,7 +24,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, - CONF_NAME, UnitOfLength, UnitOfPrecipitationDepth, UnitOfPressure, @@ -118,7 +117,7 @@ def __init__( self._attr_entity_registry_enabled_default = ( forecast_type == DEFAULT_FORECAST_TYPE ) - self._attr_name = f"{config_entry.data[CONF_NAME]} - {forecast_type.title()}" + self._attr_name = forecast_type.title() self._attr_unique_id = _calculate_unique_id( config_entry.unique_id, forecast_type ) diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index fac4e770a26fc1..d16981add8774f 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -7,13 +7,7 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - CONF_SCAN_INTERVAL, - CONF_USERNAME, -) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -25,7 +19,6 @@ DEFAULT_NAME, DEFAULT_ORDER, DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, DOMAIN, SUPPORTED_ORDER_MODES, ) @@ -147,12 +140,6 @@ async def async_step_init( return self.async_create_entry(title="", data=user_input) options = { - vol.Optional( - CONF_SCAN_INTERVAL, - default=self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - ): int, vol.Optional( CONF_LIMIT, default=self.config_entry.options.get(CONF_LIMIT, DEFAULT_LIMIT), diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index a9cfc93eea044d..9df509b9783e80 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -8,7 +8,7 @@ from transmission_rpc.session import SessionStats from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -48,14 +48,9 @@ def __init__( hass, name=f"{DOMAIN} - {self.host}", logger=_LOGGER, - update_interval=timedelta(seconds=self.scan_interval), + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), ) - @property - def scan_interval(self) -> float: - """Return scan interval.""" - return self.config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - @property def limit(self) -> int: """Return limit.""" diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 2bfa065c19b04a..c3ba418f8854bc 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -36,7 +36,7 @@ async def async_setup_entry( config_entry.entry_id ] - dev = [ + entities = [ TransmissionSpeedSensor( coordinator, "download_speed", @@ -79,7 +79,7 @@ async def async_setup_entry( ), ] - async_add_entities(dev, True) + async_add_entities(entities) class TransmissionSensor( diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index bf01b5a9cdc4e3..6d236964987a14 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -27,11 +27,11 @@ async def async_setup_entry( config_entry.entry_id ] - dev = [] + entities = [] for switch_type, switch_name in SWITCH_TYPES.items(): - dev.append(TransmissionSwitch(switch_type, switch_name, coordinator)) + entities.append(TransmissionSwitch(switch_type, switch_name, coordinator)) - async_add_entities(dev, True) + async_add_entities(entities) class TransmissionSwitch( diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 509e7e170131fc..276d21f3821947 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -14,12 +14,10 @@ TuyaOpenMQ, ) -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import dispatcher_send from .const import ( @@ -30,14 +28,12 @@ CONF_COUNTRY_CODE, CONF_ENDPOINT, CONF_PASSWORD, - CONF_PROJECT_TYPE, CONF_USERNAME, DOMAIN, LOGGER, PLATFORMS, TUYA_DISCOVERY_NEW, TUYA_HA_SIGNAL_UPDATE_ENTITY, - DPCode, ) @@ -53,13 +49,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Async setup hass config entry.""" hass.data.setdefault(DOMAIN, {}) - # Project type has been renamed to auth type in the upstream Tuya IoT SDK. - # This migrates existing config entries to reflect that name change. - if CONF_PROJECT_TYPE in entry.data: - data = {**entry.data, CONF_AUTH_TYPE: entry.data[CONF_PROJECT_TYPE]} - data.pop(CONF_PROJECT_TYPE) - hass.config_entries.async_update_entry(entry, data=data) - auth_type = AuthType(entry.data[CONF_AUTH_TYPE]) api = TuyaOpenAPI( endpoint=entry.data[CONF_ENDPOINT], @@ -108,9 +97,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.async_add_executor_job(home_manager.update_device_cache) await cleanup_device_registry(hass, device_manager) - # Migrate old unique_ids to the new format - async_migrate_entities_unique_ids(hass, entry, device_manager) - # Register known device IDs device_registry = dr.async_get(hass) for device in device_manager.device_map.values(): @@ -139,83 +125,6 @@ async def cleanup_device_registry( break -@callback -def async_migrate_entities_unique_ids( - hass: HomeAssistant, config_entry: ConfigEntry, device_manager: TuyaDeviceManager -) -> None: - """Migrate unique_ids in the entity registry to the new format.""" - entity_registry = er.async_get(hass) - registry_entries = er.async_entries_for_config_entry( - entity_registry, config_entry.entry_id - ) - light_entries = { - entry.unique_id: entry - for entry in registry_entries - if entry.domain == LIGHT_DOMAIN - } - switch_entries = { - entry.unique_id: entry - for entry in registry_entries - if entry.domain == SWITCH_DOMAIN - } - - for device in device_manager.device_map.values(): - # Old lights where in `tuya.{device_id}` format, now the DPCode is added. - # - # If the device is a previously supported light category and still has - # the old format for the unique ID, migrate it to the new format. - # - # Previously only devices providing the SWITCH_LED DPCode were supported, - # thus this can be added to those existing IDs. - # - # `tuya.{device_id}` -> `tuya.{device_id}{SWITCH_LED}` - if ( - device.category in ("dc", "dd", "dj", "fs", "fwl", "jsq", "xdd", "xxj") - and (entry := light_entries.get(f"tuya.{device.id}")) - and f"tuya.{device.id}{DPCode.SWITCH_LED}" not in light_entries - ): - entity_registry.async_update_entity( - entry.entity_id, new_unique_id=f"tuya.{device.id}{DPCode.SWITCH_LED}" - ) - - # Old switches has different formats for the unique ID, but is mappable. - # - # If the device is a previously supported switch category and still has - # the old format for the unique ID, migrate it to the new format. - # - # `tuya.{device_id}` -> `tuya.{device_id}{SWITCH}` - # `tuya.{device_id}_1` -> `tuya.{device_id}{SWITCH_1}` - # ... - # `tuya.{device_id}_6` -> `tuya.{device_id}{SWITCH_6}` - # `tuya.{device_id}_usb1` -> `tuya.{device_id}{SWITCH_USB1}` - # ... - # `tuya.{device_id}_usb6` -> `tuya.{device_id}{SWITCH_USB6}` - # - # In all other cases, the unique ID is not changed. - if device.category in ("bh", "cwysj", "cz", "dlq", "kg", "kj", "pc", "xxj"): - for postfix, dpcode in ( - ("", DPCode.SWITCH), - ("_1", DPCode.SWITCH_1), - ("_2", DPCode.SWITCH_2), - ("_3", DPCode.SWITCH_3), - ("_4", DPCode.SWITCH_4), - ("_5", DPCode.SWITCH_5), - ("_6", DPCode.SWITCH_6), - ("_usb1", DPCode.SWITCH_USB1), - ("_usb2", DPCode.SWITCH_USB2), - ("_usb3", DPCode.SWITCH_USB3), - ("_usb4", DPCode.SWITCH_USB4), - ("_usb5", DPCode.SWITCH_USB5), - ("_usb6", DPCode.SWITCH_USB6), - ): - if ( - entry := switch_entries.get(f"tuya.{device.id}{postfix}") - ) and f"tuya.{device.id}{dpcode}" not in switch_entries: - entity_registry.async_update_entity( - entry.entity_id, new_unique_id=f"tuya.{device.id}{dpcode}" - ) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unloading the Tuya platforms.""" unload = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 108ff87d026f30..b89e64f285f80b 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -263,7 +263,7 @@ async def initialize(self) -> None: if entry.domain == Platform.DEVICE_TRACKER: macs.append(entry.unique_id.split("-", 1)[0]) - for mac in self.option_block_clients + macs: + for mac in self.option_supported_clients + self.option_block_clients + macs: if mac not in self.api.clients and mac in self.api.clients_all: self.api.clients.process_raw([dict(self.api.clients_all[mac].raw)]) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 7673402aaac43b..f1fc4777467691 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==63"], + "requirements": ["aiounifi==64"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/vasttrafik/manifest.json b/homeassistant/components/vasttrafik/manifest.json index aa1907a8a2337f..336d06e182c425 100644 --- a/homeassistant/components/vasttrafik/manifest.json +++ b/homeassistant/components/vasttrafik/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/vasttrafik", "iot_class": "cloud_polling", "loggers": ["vasttrafik"], - "requirements": ["vtjp==0.1.14"] + "requirements": ["vtjp==0.2.1"] } diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 711f66ea0330db..6a083232079427 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -1,7 +1,7 @@ """Support for Västtrafik public transport.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging import vasttrafik @@ -22,6 +22,9 @@ ATTR_DIRECTION = "direction" ATTR_LINE = "line" ATTR_TRACK = "track" +ATTR_FROM = "from" +ATTR_TO = "to" +ATTR_DELAY = "delay" CONF_DEPARTURES = "departures" CONF_FROM = "from" @@ -32,7 +35,6 @@ DEFAULT_DELAY = 0 - MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -101,7 +103,7 @@ def get_station_id(self, location): if location.isdecimal(): station_info = {"station_name": location, "station_id": location} else: - station_id = self._planner.location_name(location)[0]["id"] + station_id = self._planner.location_name(location)[0]["gid"] station_info = {"station_name": location, "station_id": station_id} return station_info @@ -143,20 +145,36 @@ def update(self) -> None: self._attributes = {} else: for departure in self._departureboard: - line = departure.get("sname") - if "cancelled" in departure: + service_journey = departure.get("serviceJourney", {}) + line = service_journey.get("line", {}) + + if departure.get("isCancelled"): continue - if not self._lines or line in self._lines: - if "rtTime" in departure: - self._state = departure["rtTime"] + if not self._lines or line.get("shortName") in self._lines: + if "estimatedOtherwisePlannedTime" in departure: + try: + self._state = datetime.fromisoformat( + departure["estimatedOtherwisePlannedTime"] + ).strftime("%H:%M") + except ValueError: + self._state = departure["estimatedOtherwisePlannedTime"] else: - self._state = departure["time"] + self._state = None + + stop_point = departure.get("stopPoint", {}) params = { - ATTR_ACCESSIBILITY: departure.get("accessibility"), - ATTR_DIRECTION: departure.get("direction"), - ATTR_LINE: departure.get("sname"), - ATTR_TRACK: departure.get("track"), + ATTR_ACCESSIBILITY: "wheelChair" + if line.get("isWheelchairAccessible") + else None, + ATTR_DIRECTION: service_journey.get("direction"), + ATTR_LINE: line.get("shortName"), + ATTR_TRACK: stop_point.get("platform"), + ATTR_FROM: stop_point.get("name"), + ATTR_TO: self._heading["station_name"] + if self._heading + else "ANY", + ATTR_DELAY: self._delay.seconds // 60 % 60, } self._attributes = {k: v for k, v in params.items() if v} diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 5c35303f8597c1..1888a1778955e7 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -3,7 +3,7 @@ from typing import Any -import velbusaio +import velbusaio.controller from velbusaio.exceptions import VelbusConnectionFailed import voluptuous as vol diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 229ee8458c6110..3c773e39e33bfc 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2023.10.1"], + "requirements": ["velbus-aio==2023.10.2"], "usb": [ { "vid": "10CF", diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 38fc80ac3af96d..a2cddcf9a65699 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -95,15 +95,19 @@ async def _async_update_data(self) -> UpdateCoordinatorDataType: """Update router data.""" _LOGGER.debug("Polling Vodafone Station host: %s", self._host) try: - logged = await self.api.login() - except exceptions.CannotConnect as err: - _LOGGER.warning("Connection error for %s", self._host) - raise UpdateFailed(f"Error fetching data: {repr(err)}") from err - except exceptions.CannotAuthenticate as err: - raise ConfigEntryAuthFailed from err - - if not logged: - raise ConfigEntryAuthFailed + try: + await self.api.login() + except exceptions.CannotAuthenticate as err: + raise ConfigEntryAuthFailed from err + except ( + exceptions.CannotConnect, + exceptions.AlreadyLogged, + exceptions.GenericLoginError, + ) as err: + raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + except (ConfigEntryAuthFailed, UpdateFailed): + await self.api.close() + raise utc_point_in_time = dt_util.utcnow() data_devices = { diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 628c25b987ecd4..2a1814c83d09dd 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vodafone_station", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "requirements": ["aiovodafone==0.4.1"] + "requirements": ["aiovodafone==0.4.2"] } diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json index 4af3ee95e357b2..814621b54031d4 100644 --- a/homeassistant/components/vulcan/strings.json +++ b/homeassistant/components/vulcan/strings.json @@ -4,7 +4,7 @@ "already_configured": "That student has already been added.", "all_student_already_configured": "All students have already been added.", "reauth_successful": "Reauth successful", - "no_matching_entries": "No matching entries found, please use different account or remove integration with outdated student.." + "no_matching_entries": "No matching entries found, please use different account or remove integration with outdated student." }, "error": { "unknown": "[%key:common::config_flow::error::unknown%]", diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index a29bee86116f5b..7d59fd39a0cfea 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -53,7 +53,7 @@ from . import const, decorators, messages from .connection import ActiveConnection -from .messages import construct_event_message, construct_result_message +from .messages import construct_result_message ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" @@ -92,6 +92,7 @@ def pong_message(iden: int) -> dict[str, Any]: return {"id": iden, "type": "pong"} +@callback def _forward_events_check_permissions( send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], user: User, @@ -109,6 +110,7 @@ def _forward_events_check_permissions( send_message(messages.cached_event_message(msg_id, event)) +@callback def _forward_events_unconditional( send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], msg_id: int, @@ -135,17 +137,15 @@ def handle_subscribe_events( raise Unauthorized if event_type == EVENT_STATE_CHANGED: - forward_events = callback( - partial( - _forward_events_check_permissions, - connection.send_message, - connection.user, - msg["id"], - ) + forward_events = partial( + _forward_events_check_permissions, + connection.send_message, + connection.user, + msg["id"], ) else: - forward_events = callback( - partial(_forward_events_unconditional, connection.send_message, msg["id"]) + forward_events = partial( + _forward_events_unconditional, connection.send_message, msg["id"] ) connection.subscriptions[msg["id"]] = hass.bus.async_listen( @@ -294,10 +294,12 @@ def _send_handle_get_states_response( connection: ActiveConnection, msg_id: int, serialized_states: list[str] ) -> None: """Send handle get states response.""" - joined_states = ",".join(serialized_states) - connection.send_message(construct_result_message(msg_id, f"[{joined_states}]")) + connection.send_message( + construct_result_message(msg_id, f'[{",".join(serialized_states)}]') + ) +@callback def _forward_entity_changes( send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], entity_ids: set[str], @@ -337,14 +339,12 @@ def handle_subscribe_entities( states = _async_get_allowed_states(hass, connection) connection.subscriptions[msg["id"]] = hass.bus.async_listen( EVENT_STATE_CHANGED, - callback( - partial( - _forward_entity_changes, - connection.send_message, - entity_ids, - connection.user, - msg["id"], - ) + partial( + _forward_entity_changes, + connection.send_message, + entity_ids, + connection.user, + msg["id"], ), run_immediately=True, ) @@ -384,9 +384,8 @@ def _send_handle_entities_init_response( connection: ActiveConnection, msg_id: int, serialized_states: list[str] ) -> None: """Send handle entities init response.""" - joined_states = ",".join(serialized_states) connection.send_message( - construct_event_message(msg_id, f'{{"a":{{{joined_states}}}}}') + f'{{"id":{msg_id},"type":"event","event":{{"a":{{{",".join(serialized_states)}}}}}}}' ) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 238cd6d746588e..f2f667368c38c8 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -159,8 +159,7 @@ async def _writer(self) -> None: messages.append(message) messages_remaining -= 1 - joined_messages = ",".join(messages) - coalesced_messages = f"[{joined_messages}]" + coalesced_messages = f'[{",".join(messages)}]' if debug_enabled: debug("%s: Sending %s", self.description, coalesced_messages) await send_str(coalesced_messages) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index e1b038f42221d4..12e649219bcb5c 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -74,11 +74,6 @@ def error_message(iden: int | None, code: str, message: str) -> dict[str, Any]: } -def construct_event_message(iden: int, payload: str) -> str: - """Construct an event message JSON.""" - return f'{{"id":{iden},"type":"event","event":{payload}}}' - - def event_message(iden: int, event: Any) -> dict[str, Any]: """Return an event message.""" return {"id": iden, "type": "event", "event": event} diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 92cec96ce97142..496aba290bad47 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -59,9 +59,10 @@ WithingsGoalsDataUpdateCoordinator, WithingsMeasurementDataUpdateCoordinator, WithingsSleepDataUpdateCoordinator, + WithingsWorkoutDataUpdateCoordinator, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] CONFIG_SCHEMA = vol.Schema( { @@ -128,11 +129,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class WithingsData: """Dataclass to hold withings domain data.""" + client: WithingsClient measurement_coordinator: WithingsMeasurementDataUpdateCoordinator sleep_coordinator: WithingsSleepDataUpdateCoordinator bed_presence_coordinator: WithingsBedPresenceDataUpdateCoordinator goals_coordinator: WithingsGoalsDataUpdateCoordinator activity_coordinator: WithingsActivityDataUpdateCoordinator + workout_coordinator: WithingsWorkoutDataUpdateCoordinator coordinators: set[WithingsDataUpdateCoordinator] = field(default_factory=set) def __post_init__(self) -> None: @@ -143,6 +146,7 @@ def __post_init__(self) -> None: self.bed_presence_coordinator, self.goals_coordinator, self.activity_coordinator, + self.workout_coordinator, } @@ -171,11 +175,13 @@ async def _refresh_token() -> str: client.refresh_token_function = _refresh_token withings_data = WithingsData( + client=client, measurement_coordinator=WithingsMeasurementDataUpdateCoordinator(hass, client), sleep_coordinator=WithingsSleepDataUpdateCoordinator(hass, client), bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator(hass, client), goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, client), activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, client), + workout_coordinator=WithingsWorkoutDataUpdateCoordinator(hass, client), ) for coordinator in withings_data.coordinators: diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py new file mode 100644 index 00000000000000..19572682d1a8d1 --- /dev/null +++ b/homeassistant/components/withings/calendar.py @@ -0,0 +1,104 @@ +"""Calendar platform for Withings.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime + +from aiowithings import WithingsClient, WorkoutCategory + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er + +from . import DOMAIN, WithingsData +from .coordinator import WithingsWorkoutDataUpdateCoordinator +from .entity import WithingsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the calendar platform for entity.""" + ent_reg = er.async_get(hass) + withings_data: WithingsData = hass.data[DOMAIN][entry.entry_id] + + workout_coordinator = withings_data.workout_coordinator + + calendar_setup_before = ent_reg.async_get_entity_id( + Platform.CALENDAR, + DOMAIN, + f"withings_{entry.unique_id}_workout", + ) + + if workout_coordinator.data is not None or calendar_setup_before: + async_add_entities( + [WithingsWorkoutCalendarEntity(withings_data.client, workout_coordinator)], + ) + else: + remove_calendar_listener: Callable[[], None] + + def _async_add_calendar_entity() -> None: + """Add calendar entity.""" + if workout_coordinator.data is not None: + async_add_entities( + [ + WithingsWorkoutCalendarEntity( + withings_data.client, workout_coordinator + ) + ], + ) + remove_calendar_listener() + + remove_calendar_listener = workout_coordinator.async_add_listener( + _async_add_calendar_entity + ) + + +def get_event_name(category: WorkoutCategory) -> str: + """Return human-readable category.""" + name = category.name.lower().capitalize() + return name.replace("_", " ") + + +class WithingsWorkoutCalendarEntity( + CalendarEntity, WithingsEntity[WithingsWorkoutDataUpdateCoordinator] +): + """A calendar entity.""" + + _attr_translation_key = "workout" + + def __init__( + self, client: WithingsClient, coordinator: WithingsWorkoutDataUpdateCoordinator + ) -> None: + """Create the Calendar entity.""" + super().__init__(coordinator, "workout") + self.client = client + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + return None + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + workouts = await self.client.get_workouts_in_period( + start_date.date(), end_date.date() + ) + event_list = [] + for workout in workouts: + event = CalendarEvent( + start=workout.start_date, + end=workout.end_date, + summary=get_event_name(workout.category), + ) + + event_list.append(event) + + return event_list diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 7964a755b4d2d8..35eeb6e62b6331 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -13,6 +13,7 @@ WithingsAuthenticationFailedError, WithingsClient, WithingsUnauthorizedError, + Workout, aggregate_measurements, ) @@ -224,3 +225,39 @@ async def _internal_update_data(self) -> Activity | None: if self._previous_data and self._previous_data.date == today: return self._previous_data return None + + +class WithingsWorkoutDataUpdateCoordinator( + WithingsDataUpdateCoordinator[Workout | None] +): + """Withings workout coordinator.""" + + _previous_data: Workout | None = None + + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + """Initialize the Withings data coordinator.""" + super().__init__(hass, client) + self.notification_categories = { + NotificationCategory.ACTIVITY, + } + + async def _internal_update_data(self) -> Workout | None: + """Retrieve latest workout.""" + if self._last_valid_update is None: + now = dt_util.utcnow() + startdate = now - timedelta(days=14) + workouts = await self._client.get_workouts_in_period( + startdate.date(), now.date() + ) + else: + workouts = await self._client.get_workouts_since(self._last_valid_update) + if not workouts: + return self._previous_data + latest_workout = max(workouts, key=lambda workout: workout.end_date) + if ( + self._previous_data is None + or self._previous_data.end_date >= latest_workout.end_date + ): + self._previous_data = latest_workout + self._last_valid_update = latest_workout.end_date + return self._previous_data diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py index 8d2c815b3402d9..7f3e694533cbae 100644 --- a/homeassistant/components/withings/entity.py +++ b/homeassistant/components/withings/entity.py @@ -1,21 +1,25 @@ """Base entity for Withings.""" from __future__ import annotations +from typing import TypeVar + from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import WithingsDataUpdateCoordinator +_T = TypeVar("_T", bound=WithingsDataUpdateCoordinator) + -class WithingsEntity(CoordinatorEntity[WithingsDataUpdateCoordinator]): +class WithingsEntity(CoordinatorEntity[_T]): """Base class for withings entities.""" _attr_has_entity_name = True def __init__( self, - coordinator: WithingsDataUpdateCoordinator, + coordinator: _T, key: str, ) -> None: """Initialize the Withings entity.""" diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index a1df31ceecced7..d43ae7da50c0d1 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aiowithings"], "quality_scale": "platinum", - "requirements": ["aiowithings==1.0.1"] + "requirements": ["aiowithings==1.0.2"] } diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index a531bf4998665f..1bef72c48ec3ef 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -4,8 +4,16 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime - -from aiowithings import Activity, Goals, MeasurementType, SleepSummary +from typing import Generic, TypeVar + +from aiowithings import ( + Activity, + Goals, + MeasurementType, + SleepSummary, + Workout, + WorkoutCategory, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -44,6 +52,7 @@ WithingsGoalsDataUpdateCoordinator, WithingsMeasurementDataUpdateCoordinator, WithingsSleepDataUpdateCoordinator, + WithingsWorkoutDataUpdateCoordinator, ) from .entity import WithingsEntity @@ -420,7 +429,7 @@ class WithingsActivitySensorEntityDescription( value_fn=lambda activity: activity.steps, translation_key="activity_steps_today", icon="mdi:shoe-print", - native_unit_of_measurement="Steps", + native_unit_of_measurement="steps", state_class=SensorStateClass.TOTAL, ), WithingsActivitySensorEntityDescription( @@ -438,7 +447,7 @@ class WithingsActivitySensorEntityDescription( value_fn=lambda activity: activity.floors_climbed, translation_key="activity_floors_climbed_today", icon="mdi:stairs-up", - native_unit_of_measurement="Floors", + native_unit_of_measurement="floors", state_class=SensorStateClass.TOTAL, ), WithingsActivitySensorEntityDescription( @@ -485,7 +494,7 @@ class WithingsActivitySensorEntityDescription( value_fn=lambda activity: activity.active_calories_burnt, suggested_display_precision=1, translation_key="activity_active_calories_burnt_today", - native_unit_of_measurement="Calories", + native_unit_of_measurement="calories", state_class=SensorStateClass.TOTAL, ), WithingsActivitySensorEntityDescription( @@ -493,7 +502,7 @@ class WithingsActivitySensorEntityDescription( value_fn=lambda activity: activity.total_calories_burnt, suggested_display_precision=1, translation_key="activity_total_calories_burnt_today", - native_unit_of_measurement="Calories", + native_unit_of_measurement="calories", state_class=SensorStateClass.TOTAL, ), ] @@ -524,7 +533,7 @@ class WithingsGoalsSensorEntityDescription( value_fn=lambda goals: goals.steps, icon="mdi:shoe-print", translation_key="step_goal", - native_unit_of_measurement="Steps", + native_unit_of_measurement="steps", state_class=SensorStateClass.MEASUREMENT, ), SLEEP_GOAL: WithingsGoalsSensorEntityDescription( @@ -548,6 +557,84 @@ class WithingsGoalsSensorEntityDescription( } +@dataclass +class WithingsWorkoutSensorEntityDescriptionMixin: + """Mixin for describing withings data.""" + + value_fn: Callable[[Workout], StateType] + + +@dataclass +class WithingsWorkoutSensorEntityDescription( + SensorEntityDescription, WithingsWorkoutSensorEntityDescriptionMixin +): + """Immutable class for describing withings data.""" + + +_WORKOUT_CATEGORY = [ + workout_category.name.lower() for workout_category in WorkoutCategory +] + + +WORKOUT_SENSORS = [ + WithingsWorkoutSensorEntityDescription( + key="workout_type", + value_fn=lambda workout: workout.category.name.lower(), + device_class=SensorDeviceClass.ENUM, + translation_key="workout_type", + options=_WORKOUT_CATEGORY, + ), + WithingsWorkoutSensorEntityDescription( + key="workout_active_calories_burnt", + value_fn=lambda workout: workout.active_calories_burnt, + translation_key="workout_active_calories_burnt", + suggested_display_precision=1, + native_unit_of_measurement="calories", + ), + WithingsWorkoutSensorEntityDescription( + key="workout_distance", + value_fn=lambda workout: workout.distance, + translation_key="workout_distance", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.METERS, + suggested_display_precision=0, + icon="mdi:map-marker-distance", + ), + WithingsWorkoutSensorEntityDescription( + key="workout_floors_climbed", + value_fn=lambda workout: workout.floors_climbed, + translation_key="workout_floors_climbed", + icon="mdi:stairs-up", + native_unit_of_measurement="floors", + ), + WithingsWorkoutSensorEntityDescription( + key="workout_intensity", + value_fn=lambda workout: workout.intensity, + translation_key="workout_intensity", + ), + WithingsWorkoutSensorEntityDescription( + key="workout_pause_duration", + value_fn=lambda workout: workout.pause_duration or 0, + translation_key="workout_pause_duration", + icon="mdi:timer-pause", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + ), + WithingsWorkoutSensorEntityDescription( + key="workout_duration", + value_fn=lambda workout: ( + workout.end_date - workout.start_date + ).total_seconds(), + translation_key="workout_duration", + icon="mdi:timer", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + ), +] + + def get_current_goals(goals: Goals) -> set[str]: """Return a list of present goals.""" result = set() @@ -619,26 +706,28 @@ def _async_goals_listener() -> None: activity_coordinator = withings_data.activity_coordinator - activity_callback: Callable[[], None] | None = None - activity_entities_setup_before = ent_reg.async_get_entity_id( Platform.SENSOR, DOMAIN, f"withings_{entry.unique_id}_activity_steps_today" ) - def _async_add_activity_entities() -> None: - """Add activity entities.""" - if activity_coordinator.data is not None or activity_entities_setup_before: - async_add_entities( - WithingsActivitySensor(activity_coordinator, attribute) - for attribute in ACTIVITY_SENSORS - ) - if activity_callback: - activity_callback() - if activity_coordinator.data is not None or activity_entities_setup_before: - _async_add_activity_entities() + entities.extend( + WithingsActivitySensor(activity_coordinator, attribute) + for attribute in ACTIVITY_SENSORS + ) else: - activity_callback = activity_coordinator.async_add_listener( + remove_activity_listener: Callable[[], None] + + def _async_add_activity_entities() -> None: + """Add activity entities.""" + if activity_coordinator.data is not None: + async_add_entities( + WithingsActivitySensor(activity_coordinator, attribute) + for attribute in ACTIVITY_SENSORS + ) + remove_activity_listener() + + remove_activity_listener = activity_coordinator.async_add_listener( _async_add_activity_entities ) @@ -656,7 +745,7 @@ def _async_add_activity_entities() -> None: for attribute in SLEEP_SENSORS ) else: - remove_listener: Callable[[], None] + remove_sleep_listener: Callable[[], None] def _async_add_sleep_entities() -> None: """Add sleep entities.""" @@ -665,35 +754,69 @@ def _async_add_sleep_entities() -> None: WithingsSleepSensor(sleep_coordinator, attribute) for attribute in SLEEP_SENSORS ) - remove_listener() + remove_sleep_listener() - remove_listener = sleep_coordinator.async_add_listener( + remove_sleep_listener = sleep_coordinator.async_add_listener( _async_add_sleep_entities ) + workout_coordinator = withings_data.workout_coordinator + + workout_entities_setup_before = ent_reg.async_get_entity_id( + Platform.SENSOR, DOMAIN, f"withings_{entry.unique_id}_workout_type" + ) + + if workout_coordinator.data is not None or workout_entities_setup_before: + entities.extend( + WithingsWorkoutSensor(workout_coordinator, attribute) + for attribute in WORKOUT_SENSORS + ) + else: + remove_workout_listener: Callable[[], None] + + def _async_add_workout_entities() -> None: + """Add workout entities.""" + if workout_coordinator.data is not None: + async_add_entities( + WithingsWorkoutSensor(workout_coordinator, attribute) + for attribute in WORKOUT_SENSORS + ) + remove_workout_listener() + + remove_workout_listener = workout_coordinator.async_add_listener( + _async_add_workout_entities + ) + async_add_entities(entities) -class WithingsSensor(WithingsEntity, SensorEntity): +_T = TypeVar("_T", bound=WithingsDataUpdateCoordinator) +_ED = TypeVar("_ED", bound=SensorEntityDescription) + + +class WithingsSensor(WithingsEntity[_T], SensorEntity, Generic[_T, _ED]): """Implementation of a Withings sensor.""" + entity_description: _ED + def __init__( self, - coordinator: WithingsDataUpdateCoordinator, - entity_description: SensorEntityDescription, + coordinator: _T, + entity_description: _ED, ) -> None: """Initialize sensor.""" super().__init__(coordinator, entity_description.key) self.entity_description = entity_description -class WithingsMeasurementSensor(WithingsSensor): +class WithingsMeasurementSensor( + WithingsSensor[ + WithingsMeasurementDataUpdateCoordinator, + WithingsMeasurementSensorEntityDescription, + ] +): """Implementation of a Withings measurement sensor.""" - coordinator: WithingsMeasurementDataUpdateCoordinator - - entity_description: WithingsMeasurementSensorEntityDescription - @property def native_value(self) -> float: """Return the state of the entity.""" @@ -708,13 +831,14 @@ def available(self) -> bool: ) -class WithingsSleepSensor(WithingsSensor): +class WithingsSleepSensor( + WithingsSensor[ + WithingsSleepDataUpdateCoordinator, + WithingsSleepSensorEntityDescription, + ] +): """Implementation of a Withings sleep sensor.""" - coordinator: WithingsSleepDataUpdateCoordinator - - entity_description: WithingsSleepSensorEntityDescription - @property def native_value(self) -> StateType: """Return the state of the entity.""" @@ -723,13 +847,14 @@ def native_value(self) -> StateType: return self.entity_description.value_fn(self.coordinator.data) -class WithingsGoalsSensor(WithingsSensor): +class WithingsGoalsSensor( + WithingsSensor[ + WithingsGoalsDataUpdateCoordinator, + WithingsGoalsSensorEntityDescription, + ] +): """Implementation of a Withings goals sensor.""" - coordinator: WithingsGoalsDataUpdateCoordinator - - entity_description: WithingsGoalsSensorEntityDescription - @property def native_value(self) -> StateType: """Return the state of the entity.""" @@ -737,13 +862,14 @@ def native_value(self) -> StateType: return self.entity_description.value_fn(self.coordinator.data) -class WithingsActivitySensor(WithingsSensor): +class WithingsActivitySensor( + WithingsSensor[ + WithingsActivityDataUpdateCoordinator, + WithingsActivitySensorEntityDescription, + ] +): """Implementation of a Withings activity sensor.""" - coordinator: WithingsActivityDataUpdateCoordinator - - entity_description: WithingsActivitySensorEntityDescription - @property def native_value(self) -> StateType: """Return the state of the entity.""" @@ -755,3 +881,19 @@ def native_value(self) -> StateType: def last_reset(self) -> datetime: """These values reset every day.""" return dt_util.start_of_local_day() + + +class WithingsWorkoutSensor( + WithingsSensor[ + WithingsWorkoutDataUpdateCoordinator, + WithingsWorkoutSensorEntityDescription, + ] +): + """Implementation of a Withings workout sensor.""" + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + if not self.coordinator.data: + return None + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index a6a832d83949cb..fb447f3578ec0e 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -29,6 +29,11 @@ "name": "In bed" } }, + "calendar": { + "workout": { + "name": "Workouts" + } + }, "sensor": { "fat_mass": { "name": "Fat mass" @@ -170,6 +175,78 @@ }, "activity_total_calories_burnt_today": { "name": "Total calories burnt today" + }, + "workout_type": { + "name": "Last workout type", + "state": { + "walk": "Walking", + "run": "Running", + "hiking": "Hiking", + "skating": "Skating", + "bmx": "BMX", + "bicycling": "Bicycling", + "swimming": "Swimming", + "surfing": "Surfing", + "kitesurfing": "Kitesurfing", + "windsurfing": "Windsurfing", + "bodyboard": "Bodyboard", + "tennis": "Tennis", + "table_tennis": "Table tennis", + "squash": "Squash", + "badminton": "Badminton", + "lift_weights": "Lift weights", + "calisthenics": "Calisthenics", + "elliptical": "Elliptical", + "pilates": "Pilates", + "basket_ball": "Basket ball", + "soccer": "Soccer", + "football": "Football", + "rugby": "Rugby", + "volley_ball": "Volley ball", + "waterpolo": "Waterpolo", + "horse_riding": "Horse riding", + "golf": "Golf", + "yoga": "Yoga", + "dancing": "Dancing", + "boxing": "Boxing", + "fencing": "Fencing", + "wrestling": "Wrestling", + "martial_arts": "Martial arts", + "skiing": "Skiing", + "snowboarding": "Snowboarding", + "other": "Other", + "no_activity": "No activity", + "rowing": "Rowing", + "zumba": "Zumba", + "baseball": "Baseball", + "handball": "Handball", + "hockey": "Hockey", + "ice_hockey": "Ice hockey", + "climbing": "Climbing", + "ice_skating": "Ice skating", + "multi_sport": "Multi sport", + "indoor_walk": "Indoor walking", + "indoor_running": "Indoor running", + "indoor_cycling": "Indoor cycling" + } + }, + "workout_active_calories_burnt": { + "name": "Calories burnt last workout" + }, + "workout_distance": { + "name": "Distance travelled last workout" + }, + "workout_floors_climbed": { + "name": "Floors climbed last workout" + }, + "workout_intensity": { + "name": "Last workout intensity" + }, + "workout_pause_duration": { + "name": "Pause during last workout" + }, + "workout_duration": { + "name": "Last workout duration" } } } diff --git a/homeassistant/components/xiaomi_ble/__init__.py b/homeassistant/components/xiaomi_ble/__init__.py index b12f4df7db1c52..ced8c3cc47145a 100644 --- a/homeassistant/components/xiaomi_ble/__init__.py +++ b/homeassistant/components/xiaomi_ble/__init__.py @@ -15,7 +15,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import CoreState, HomeAssistant -from homeassistant.helpers.device_registry import DeviceRegistry, async_get +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceRegistry, + async_get, +) from .const import ( CONF_DISCOVERED_EVENT_CLASSES, @@ -55,6 +59,7 @@ def process_service_info( sensor_device_info = update.devices[device_key.device_id] device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, + connections={(CONNECTION_BLUETOOTH, address)}, identifiers={(BLUETOOTH_DOMAIN, address)}, manufacturer=sensor_device_info.manufacturer, model=sensor_device_info.model, diff --git a/homeassistant/components/zha/core/cluster_handlers/closures.py b/homeassistant/components/zha/core/cluster_handlers/closures.py index 4262a16800de1d..980a6f88a757b5 100644 --- a/homeassistant/components/zha/core/cluster_handlers/closures.py +++ b/homeassistant/components/zha/core/cluster_handlers/closures.py @@ -124,11 +124,19 @@ class WindowCoveringClient(ClientClusterHandler): class WindowCovering(ClusterHandler): """Window cluster handler.""" - _value_attribute = 8 + _value_attribute_lift = ( + closures.WindowCovering.AttributeDefs.current_position_lift_percentage.id + ) + _value_attribute_tilt = ( + closures.WindowCovering.AttributeDefs.current_position_tilt_percentage.id + ) REPORT_CONFIG = ( AttrReportConfig( attr="current_position_lift_percentage", config=REPORT_CONFIG_IMMEDIATE ), + AttrReportConfig( + attr="current_position_tilt_percentage", config=REPORT_CONFIG_IMMEDIATE + ), ) async def async_update(self): @@ -140,10 +148,21 @@ async def async_update(self): if result is not None: self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - 8, + self._value_attribute_lift, "current_position_lift_percentage", result, ) + result = await self.get_attribute_value( + "current_position_tilt_percentage", from_cache=False + ) + self.debug("read current tilt position: %s", result) + if result is not None: + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + self._value_attribute_tilt, + "current_position_tilt_percentage", + result, + ) @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: @@ -152,7 +171,7 @@ def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: self.debug( "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) - if attrid == self._value_attribute: + if attrid in (self._value_attribute_lift, self._value_attribute_tilt): self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value ) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index c286d0112e9272..9874fddc5982e7 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -48,6 +48,7 @@ ATTR_PROFILE_ID = "profile_id" ATTR_QUIRK_APPLIED = "quirk_applied" ATTR_QUIRK_CLASS = "quirk_class" +ATTR_QUIRK_ID = "quirk_id" ATTR_ROUTES = "routes" ATTR_RSSI = "rssi" ATTR_SIGNATURE = "signature" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 8f5b087f068af5..44acbb172fcce7 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -59,6 +59,7 @@ ATTR_POWER_SOURCE, ATTR_QUIRK_APPLIED, ATTR_QUIRK_CLASS, + ATTR_QUIRK_ID, ATTR_ROUTES, ATTR_RSSI, ATTR_SIGNATURE, @@ -135,6 +136,7 @@ def __init__( f"{self._zigpy_device.__class__.__module__}." f"{self._zigpy_device.__class__.__name__}" ) + self.quirk_id = getattr(self._zigpy_device, ATTR_QUIRK_ID, None) if self.is_mains_powered: self.consider_unavailable_time = async_get_zha_config_value( @@ -537,6 +539,7 @@ def device_info(self) -> dict[str, Any]: ATTR_NAME: self.name or ieee, ATTR_QUIRK_APPLIED: self.quirk_applied, ATTR_QUIRK_CLASS: self.quirk_class, + ATTR_QUIRK_ID: self.quirk_id, ATTR_MANUFACTURER_CODE: self.manufacturer_code, ATTR_POWER_SOURCE: self.power_source, ATTR_LQI: self.lqi, diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index a56e7044d3a61c..90ed68f9b00da4 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -122,7 +122,7 @@ def discover_by_device_type(self, endpoint: Endpoint) -> None: endpoint.device.manufacturer, endpoint.device.model, cluster_handlers, - endpoint.device.quirk_class, + endpoint.device.quirk_id, ) if platform_entity_class is None: return @@ -181,7 +181,7 @@ def probe_single_cluster( endpoint.device.manufacturer, endpoint.device.model, cluster_handler_list, - endpoint.device.quirk_class, + endpoint.device.quirk_id, ) if entity_class is None: return @@ -226,14 +226,14 @@ def discover_multi_entities( endpoint.device.manufacturer, endpoint.device.model, list(endpoint.all_cluster_handlers.values()), - endpoint.device.quirk_class, + endpoint.device.quirk_id, ) else: matches, claimed = zha_regs.ZHA_ENTITIES.get_multi_entity( endpoint.device.manufacturer, endpoint.device.model, endpoint.unclaimed_cluster_handlers(), - endpoint.device.quirk_class, + endpoint.device.quirk_id, ) endpoint.claim_cluster_handlers(claimed) diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 74f724bdc493dd..4bdedebfff9ff0 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -147,7 +147,7 @@ class MatchRule: aux_cluster_handlers: frozenset[str] | Callable = attr.ib( factory=_get_empty_frozenset, converter=set_or_callable ) - quirk_classes: frozenset[str] | Callable = attr.ib( + quirk_ids: frozenset[str] | Callable = attr.ib( factory=_get_empty_frozenset, converter=set_or_callable ) @@ -165,10 +165,8 @@ def weight(self) -> int: multiple cluster handlers a better priority over rules matching a single cluster handler. """ weight = 0 - if self.quirk_classes: - weight += 501 - ( - 1 if callable(self.quirk_classes) else len(self.quirk_classes) - ) + if self.quirk_ids: + weight += 501 - (1 if callable(self.quirk_ids) else len(self.quirk_ids)) if self.models: weight += 401 - (1 if callable(self.models) else len(self.models)) @@ -204,19 +202,31 @@ def claim_cluster_handlers( return claimed def strict_matched( - self, manufacturer: str, model: str, cluster_handlers: list, quirk_class: str + self, + manufacturer: str, + model: str, + cluster_handlers: list, + quirk_id: str | None, ) -> bool: """Return True if this device matches the criteria.""" - return all(self._matched(manufacturer, model, cluster_handlers, quirk_class)) + return all(self._matched(manufacturer, model, cluster_handlers, quirk_id)) def loose_matched( - self, manufacturer: str, model: str, cluster_handlers: list, quirk_class: str + self, + manufacturer: str, + model: str, + cluster_handlers: list, + quirk_id: str | None, ) -> bool: """Return True if this device matches the criteria.""" - return any(self._matched(manufacturer, model, cluster_handlers, quirk_class)) + return any(self._matched(manufacturer, model, cluster_handlers, quirk_id)) def _matched( - self, manufacturer: str, model: str, cluster_handlers: list, quirk_class: str + self, + manufacturer: str, + model: str, + cluster_handlers: list, + quirk_id: str | None, ) -> list: """Return a list of field matches.""" if not any(attr.asdict(self).values()): @@ -243,14 +253,11 @@ def _matched( else: matches.append(model in self.models) - if self.quirk_classes: - if callable(self.quirk_classes): - matches.append(self.quirk_classes(quirk_class)) + if self.quirk_ids and quirk_id: + if callable(self.quirk_ids): + matches.append(self.quirk_ids(quirk_id)) else: - matches.append( - quirk_class.split(".")[-2:] - in [x.split(".")[-2:] for x in self.quirk_classes] - ) + matches.append(quirk_id in self.quirk_ids) return matches @@ -292,13 +299,13 @@ def get_entity( manufacturer: str, model: str, cluster_handlers: list[ClusterHandler], - quirk_class: str, + quirk_id: str | None, default: type[ZhaEntity] | None = None, ) -> tuple[type[ZhaEntity] | None, list[ClusterHandler]]: """Match a ZHA ClusterHandler to a ZHA Entity class.""" matches = self._strict_registry[component] for match in sorted(matches, key=WEIGHT_ATTR, reverse=True): - if match.strict_matched(manufacturer, model, cluster_handlers, quirk_class): + if match.strict_matched(manufacturer, model, cluster_handlers, quirk_id): claimed = match.claim_cluster_handlers(cluster_handlers) return self._strict_registry[component][match], claimed @@ -309,7 +316,7 @@ def get_multi_entity( manufacturer: str, model: str, cluster_handlers: list[ClusterHandler], - quirk_class: str, + quirk_id: str | None, ) -> tuple[ dict[Platform, list[EntityClassAndClusterHandlers]], list[ClusterHandler] ]: @@ -323,7 +330,7 @@ def get_multi_entity( sorted_matches = sorted(matches, key=WEIGHT_ATTR, reverse=True) for match in sorted_matches: if match.strict_matched( - manufacturer, model, cluster_handlers, quirk_class + manufacturer, model, cluster_handlers, quirk_id ): claimed = match.claim_cluster_handlers(cluster_handlers) for ent_class in stop_match_groups[stop_match_grp][match]: @@ -342,7 +349,7 @@ def get_config_diagnostic_entity( manufacturer: str, model: str, cluster_handlers: list[ClusterHandler], - quirk_class: str, + quirk_id: str | None, ) -> tuple[ dict[Platform, list[EntityClassAndClusterHandlers]], list[ClusterHandler] ]: @@ -359,7 +366,7 @@ def get_config_diagnostic_entity( sorted_matches = sorted(matches, key=WEIGHT_ATTR, reverse=True) for match in sorted_matches: if match.strict_matched( - manufacturer, model, cluster_handlers, quirk_class + manufacturer, model, cluster_handlers, quirk_id ): claimed = match.claim_cluster_handlers(cluster_handlers) for ent_class in stop_match_groups[stop_match_grp][match]: @@ -385,7 +392,7 @@ def strict_match( manufacturers: Callable | set[str] | str | None = None, models: Callable | set[str] | str | None = None, aux_cluster_handlers: Callable | set[str] | str | None = None, - quirk_classes: set[str] | str | None = None, + quirk_ids: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a strict match rule.""" @@ -395,7 +402,7 @@ def strict_match( manufacturers, models, aux_cluster_handlers, - quirk_classes, + quirk_ids, ) def decorator(zha_ent: _ZhaEntityT) -> _ZhaEntityT: @@ -417,7 +424,7 @@ def multipass_match( models: Callable | set[str] | str | None = None, aux_cluster_handlers: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, - quirk_classes: set[str] | str | None = None, + quirk_ids: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" @@ -427,7 +434,7 @@ def multipass_match( manufacturers, models, aux_cluster_handlers, - quirk_classes, + quirk_ids, ) def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: @@ -452,7 +459,7 @@ def config_diagnostic_match( models: Callable | set[str] | str | None = None, aux_cluster_handlers: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, - quirk_classes: set[str] | str | None = None, + quirk_ids: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" @@ -462,7 +469,7 @@ def config_diagnostic_match( manufacturers, models, aux_cluster_handlers, - quirk_classes, + quirk_ids, ) def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index d142aa2726b187..f36cbc13533b5e 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -11,6 +11,7 @@ from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, + ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, ) @@ -80,6 +81,7 @@ def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._cover_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_COVER) self._current_position = None + self._tilt_position = None async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" @@ -94,6 +96,10 @@ def async_restore_last_state(self, last_state): self._state = last_state.state if "current_position" in last_state.attributes: self._current_position = last_state.attributes["current_position"] + if "current_tilt_position" in last_state.attributes: + self._tilt_position = last_state.attributes[ + "current_tilt_position" + ] # first allocation activate tilt @property def is_closed(self) -> bool | None: @@ -120,11 +126,20 @@ def current_cover_position(self) -> int | None: """ return self._current_position + @property + def current_cover_tilt_position(self) -> int | None: + """Return the current tilt position of the cover.""" + return self._tilt_position + @callback def async_set_position(self, attr_id, attr_name, value): """Handle position update from cluster handler.""" - _LOGGER.debug("setting position: %s", value) - self._current_position = 100 - value + _LOGGER.debug("setting position: %s %s %s", attr_id, attr_name, value) + if attr_name == "current_position_lift_percentage": + self._current_position = 100 - value + elif attr_name == "current_position_tilt_percentage": + self._tilt_position = 100 - value + if self._current_position == 0: self._state = STATE_CLOSED elif self._current_position == 100: @@ -145,6 +160,13 @@ async def async_open_cover(self, **kwargs: Any) -> None: raise HomeAssistantError(f"Failed to open cover: {res[1]}") self.async_update_state(STATE_OPENING) + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + res = await self._cover_cluster_handler.go_to_tilt_percentage(0) + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to open cover tilt: {res[1]}") + self.async_update_state(STATE_OPENING) + async def async_close_cover(self, **kwargs: Any) -> None: """Close the window cover.""" res = await self._cover_cluster_handler.down_close() @@ -152,6 +174,13 @@ async def async_close_cover(self, **kwargs: Any) -> None: raise HomeAssistantError(f"Failed to close cover: {res[1]}") self.async_update_state(STATE_CLOSING) + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + res = await self._cover_cluster_handler.go_to_tilt_percentage(100) + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to close cover tilt: {res[1]}") + self.async_update_state(STATE_CLOSING) + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the roller shutter to a specific position.""" new_pos = kwargs[ATTR_POSITION] @@ -162,6 +191,16 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: STATE_CLOSING if new_pos < self._current_position else STATE_OPENING ) + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover til to a specific position.""" + new_pos = kwargs[ATTR_TILT_POSITION] + res = await self._cover_cluster_handler.go_to_tilt_percentage(100 - new_pos) + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to set cover tilt position: {res[1]}") + self.async_update_state( + STATE_CLOSING if new_pos < self._tilt_position else STATE_OPENING + ) + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the window cover.""" res = await self._cover_cluster_handler.stop() @@ -170,28 +209,9 @@ async def async_stop_cover(self, **kwargs: Any) -> None: self._state = STATE_OPEN if self._current_position > 0 else STATE_CLOSED self.async_write_ha_state() - async def async_update(self) -> None: - """Attempt to retrieve the open/close state of the cover.""" - await super().async_update() - await self.async_get_state() - - async def async_get_state(self, from_cache=True): - """Fetch the current state.""" - _LOGGER.debug("polling current state") - if self._cover_cluster_handler: - pos = await self._cover_cluster_handler.get_attribute_value( - "current_position_lift_percentage", from_cache=from_cache - ) - _LOGGER.debug("read pos=%s", pos) - - if pos is not None: - self._current_position = 100 - pos - self._state = ( - STATE_OPEN if self.current_cover_position > 0 else STATE_CLOSED - ) - else: - self._current_position = None - self._state = None + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover tilt.""" + await self.async_stop_cover() @MULTI_MATCH( diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index b8cf2cd0339849..05bf3469c7b8fd 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -299,3 +299,23 @@ def default_on_percentage(self) -> int: return int( (100 / self.speed_count) * self.preset_name_to_mode[PRESET_MODE_AUTO] ) + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_FAN, + models={"HBUniversalCFRemote", "HDC52EastwindFan"}, +) +class KofFan(ZhaFan): + """Representation of a fan made by King Of Fans.""" + + _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE + + @property + def speed_range(self) -> tuple[int, int]: + """Return the range of speeds the fan supports. Off is not included.""" + return (1, 4) + + @property + def preset_modes_to_name(self) -> dict[int, str]: + """Return a dict from preset mode to name.""" + return {6: PRESET_MODE_SMART} diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 6770ca3b563355..6a01d550466794 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -850,8 +850,8 @@ def async_restore_last_state(self, last_state): self._off_with_transition = last_state.attributes["off_with_transition"] if "off_brightness" in last_state.attributes: self._off_brightness = last_state.attributes["off_brightness"] - if "color_mode" in last_state.attributes: - self._attr_color_mode = ColorMode(last_state.attributes["color_mode"]) + if (color_mode := last_state.attributes.get("color_mode")) is not None: + self._attr_color_mode = ColorMode(color_mode) if "color_temp" in last_state.attributes: self._attr_color_temp = last_state.attributes["color_temp"] if "xy_color" in last_state.attributes: diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index dc8f06882e2295..af2c8405e5fe44 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -2,7 +2,7 @@ "domain": "zha", "name": "Zigbee Home Automation", "after_dependencies": ["onboarding", "usb"], - "codeowners": ["@dmulcahey", "@adminiuga", "@puddly"], + "codeowners": ["@dmulcahey", "@adminiuga", "@puddly", "@TheJulianJES"], "config_flow": true, "dependencies": ["file_upload"], "documentation": "https://www.home-assistant.io/integrations/zha", @@ -21,13 +21,13 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.5", + "bellows==0.36.8", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.105", + "zha-quirks==0.0.106", "zigpy-deconz==0.21.1", - "zigpy==0.58.1", - "zigpy-xbee==0.18.3", + "zigpy==0.59.0", + "zigpy-xbee==0.19.0", "zigpy-zigate==0.11.0", "zigpy-znp==0.11.6", "universal-silabs-flasher==0.0.14", diff --git a/homeassistant/components/zwave_js/README.md b/homeassistant/components/zwave_js/README.md index f82f421f752842..da49e67c60a3d7 100644 --- a/homeassistant/components/zwave_js/README.md +++ b/homeassistant/components/zwave_js/README.md @@ -10,7 +10,20 @@ The Z-Wave integration uses a discovery mechanism to create the necessary entiti In cases where an entity's functionality requires interaction with multiple Values, the discovery rule for that particular entity type is based on the primary Value, or the Value that must be there to indicate that this entity needs to be created, and then the rest of the Values required are discovered by the class instance for that entity. A good example of this is the discovery logic for the `climate` entity. Currently, the discovery logic is tied to the discovery of a Value with a property of `mode` and a command class of `Thermostat Mode`, but the actual entity uses many more Values than that to be fully functional as evident in the [code](./climate.py). -There are several ways that device support can be improved within Home Assistant, but regardless of the reason, it is important to add device specific tests in these use cases. To do so, add the device's data (from device diagnostics) to the [fixtures folder](../../../tests/components/zwave_js/fixtures) and then define the new fixtures in [conftest.py](../../../tests/components/zwave_js/conftest.py). Use existing tests as the model but the tests can go in the [test_discovery.py module](../../../tests/components/zwave_js/test_discovery.py). +There are several ways that device support can be improved within Home Assistant, but regardless of the reason, it is important to add device specific tests in these use cases. To do so, add the device's data to the [fixtures folder](../../../tests/components/zwave_js/fixtures) and then define the new fixtures in [conftest.py](../../../tests/components/zwave_js/conftest.py). Use existing tests as the model but the tests can go in the [test_discovery.py module](../../../tests/components/zwave_js/test_discovery.py). To learn how to generate fixtures, see the following section. + +### Generating device fixtures + +To generate a device fixture, download a diagnostics dump of the device from your Home Assistant instance. The dumped data will need to be modified to match the expected format. You can always do this transformation by hand, but the integration provides a [helper script](scripts/convert_device_diagnostics_to_fixture.py) that will generate the appropriate fixture data from a device diagnostics dump for you. To use it, run the script with the path to the diagnostics dump you downloaded: + +`python homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py ` + +The script will print the fixture data to standard output, and you can use Unix piping to create a file from the fixture data: + +`python homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py > ` + +You can alternatively pass the `--file` flag to the script and it will create the file for you in the [fixtures folder](../../../tests/components/zwave_js/fixtures): +`python homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py --file` ### Switching HA support for a device from one entity type to another. diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 0e7c36c479df1d..a917aa448893a0 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -2463,5 +2463,24 @@ async def websocket_hard_reset_controller( driver: Driver, ) -> None: """Hard reset controller.""" + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + for unsub in unsubs: + unsub() + unsubs.clear() + + @callback + def _handle_device_added(device: dr.DeviceEntry) -> None: + """Handle device is added.""" + if entry.entry_id in device.config_entries: + connection.send_result(msg[ID], device.id) + async_cleanup() + + msg[DATA_UNSUBSCRIBE] = unsubs = [ + async_dispatcher_connect( + hass, EVENT_DEVICE_ADDED_TO_REGISTRY, _handle_device_added + ) + ] await driver.async_hard_reset() - connection.send_result(msg[ID]) diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 28084eecfa60c3..d511a030fb1a21 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -259,11 +259,9 @@ def _set_modes_and_presets(self) -> None: def _current_mode_setpoint_enums(self) -> list[ThermostatSetpointType]: """Return the list of enums that are relevant to the current thermostat mode.""" if self._current_mode is None or self._current_mode.value is None: - # Thermostat with no support for setting a mode is just a setpoint - if self.info.primary_value.property_key is None: - return [] - return [ThermostatSetpointType(int(self.info.primary_value.property_key))] - + # Thermostat(valve) with no support for setting a mode + # is considered heating-only + return [ThermostatSetpointType.HEATING] return THERMOSTAT_MODE_SETPOINT_MAP.get(int(self._current_mode.value), []) @property diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 0b9c68e9664383..e7e110e7db6b7b 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -206,7 +206,7 @@ def generate_name( ): name += f" ({primary_value.endpoint})" - return name + return name.strip() @property def available(self) -> bool: diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 505196c43eb749..f0c1dcec6b5a96 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.52.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.53.1"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/scripts/__init__.py b/homeassistant/components/zwave_js/scripts/__init__.py new file mode 100644 index 00000000000000..fda5d0f5c39ac3 --- /dev/null +++ b/homeassistant/components/zwave_js/scripts/__init__.py @@ -0,0 +1 @@ +"""Scripts module for Z-Wave JS.""" diff --git a/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py b/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py new file mode 100644 index 00000000000000..1e8d295227fe8a --- /dev/null +++ b/homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py @@ -0,0 +1,91 @@ +"""Script to convert a device diagnostics file to a fixture.""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +from homeassistant.util import slugify + + +def get_arguments() -> argparse.Namespace: + """Get parsed passed in arguments.""" + parser = argparse.ArgumentParser(description="Z-Wave JS Fixture generator") + parser.add_argument( + "diagnostics_file", type=Path, help="Device diagnostics file to convert" + ) + parser.add_argument( + "--file", + action="store_true", + help=( + "Dump fixture to file in fixtures folder. By default, the fixture will be " + "printed to standard output." + ), + ) + + arguments = parser.parse_args() + + return arguments + + +def get_fixtures_dir_path(data: dict) -> Path: + """Get path to fixtures directory.""" + device_config = data["deviceConfig"] + filename = slugify( + f"{device_config['manufacturer']}-{device_config['label']}_state" + ) + path = Path(__file__).parents[1] + index = path.parts.index("homeassistant") + return Path( + *path.parts[:index], + "tests", + *path.parts[index + 1 :], + "fixtures", + f"{filename}.json", + ) + + +def load_file(path: Path) -> Any: + """Load file from path.""" + return json.loads(path.read_text("utf8")) + + +def extract_fixture_data(diagnostics_data: Any) -> dict: + """Extract fixture data from file.""" + if ( + not isinstance(diagnostics_data, dict) + or "data" not in diagnostics_data + or "state" not in diagnostics_data["data"] + ): + raise ValueError("Invalid diagnostics file format") + state: dict = diagnostics_data["data"]["state"] + if isinstance(state["values"], list): + return state + values_dict: dict[str, dict] = state.pop("values") + state["values"] = list(values_dict.values()) + + return state + + +def create_fixture_file(path: Path, state_text: str) -> None: + """Create a file for the state dump in the fixtures directory.""" + path.write_text(state_text, "utf8") + + +def main() -> None: + """Run the main script.""" + args = get_arguments() + diagnostics_path: Path = args.diagnostics_file + diagnostics = load_file(diagnostics_path) + fixture_data = extract_fixture_data(diagnostics) + fixture_text = json.dumps(fixture_data, indent=2) + if args.file: + fixture_path = get_fixtures_dir_path(fixture_data) + create_fixture_file(fixture_path, fixture_text) + return + print(fixture_text) # noqa: T201 + + +if __name__ == "__main__": + main() diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index 6de6b0f4e45bb4..7df88f7dca4244 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -72,6 +72,8 @@ def __init__( if self._attr_available_tones: self._attr_supported_features |= SirenEntityFeature.TONES + self._attr_name = self.generate_name(include_value_name=True) + @property def is_on(self) -> bool | None: """Return whether device is on.""" diff --git a/homeassistant/config.py b/homeassistant/config.py index 8d316eb773beed..1b7e90996dc533 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -88,6 +88,8 @@ *LOAD_EXCEPTIONS, ) +SAFE_MODE_FILENAME = "safe-mode" + DEFAULT_CONFIG = f""" # Loads default set of integrations. Do not remove. default_config: @@ -1007,3 +1009,24 @@ def async_notify_setup_error( persistent_notification.async_create( hass, message, "Invalid config", "invalid_config" ) + + +def safe_mode_enabled(config_dir: str) -> bool: + """Return if safe mode is enabled. + + If safe mode is enabled, the safe mode file will be removed. + """ + safe_mode_path = os.path.join(config_dir, SAFE_MODE_FILENAME) + safe_mode = os.path.exists(safe_mode_path) + if safe_mode: + os.remove(safe_mode_path) + return safe_mode + + +async def async_enable_safe_mode(hass: HomeAssistant) -> None: + """Enable safe mode.""" + + def _enable_safe_mode() -> None: + Path(hass.config.path(SAFE_MODE_FILENAME)).touch() + + await hass.async_add_executor_job(_enable_safe_mode) diff --git a/homeassistant/const.py b/homeassistant/const.py index 77c5582464e46f..c6655ba3900762 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -6,7 +6,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 11 +MINOR_VERSION: Final = 12 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/homeassistant/core.py b/homeassistant/core.py index e495973440ebe4..48cc70e7727df5 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -209,6 +209,18 @@ def is_callback(func: Callable[..., Any]) -> bool: return getattr(func, "_hass_callback", False) is True +def is_callback_check_partial(target: Callable[..., Any]) -> bool: + """Check if function is safe to be called in the event loop. + + This version of is_callback will also check if the target is a partial + and walk the chain of partials to find the original function. + """ + check_target = target + while isinstance(check_target, functools.partial): + check_target = check_target.func + return is_callback(check_target) + + class _Hass(threading.local): """Container which makes a HomeAssistant instance available to the event loop.""" @@ -1141,9 +1153,9 @@ def async_listen( This method must be run in the event loop. """ - if event_filter is not None and not is_callback(event_filter): + if event_filter is not None and not is_callback_check_partial(event_filter): raise HomeAssistantError(f"Event filter {event_filter} is not a callback") - if run_immediately and not is_callback(listener): + if run_immediately and not is_callback_check_partial(listener): raise HomeAssistantError(f"Event listener {listener} is not a callback") return self._async_listen_filterable_job( event_type, @@ -2135,6 +2147,9 @@ def __init__(self, hass: HomeAssistant, config_dir: str) -> None: # Use legacy template behavior self.legacy_templates: bool = False + # If Home Assistant is running in safe mode + self.safe_mode: bool = False + def distance(self, lat: float, lon: float) -> float | None: """Calculate distance from Home Assistant. @@ -2215,6 +2230,7 @@ def as_dict(self) -> dict[str, Any]: "currency": self.currency, "country": self.country, "language": self.language, + "safe_mode": self.safe_mode, } def set_time_zone(self, time_zone_str: str) -> None: diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index a4db1b4c0de4bd..060080517bf03a 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -11,6 +11,7 @@ "google_assistant_sdk", "google_mail", "google_sheets", + "google_tasks", "home_connect", "lametric", "lyric", diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index c2b24b68d295e2..13700a4521c1dd 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -217,6 +217,11 @@ "domain": "idasen_desk", "service_uuid": "99fa0001-338a-1024-8a49-009c0215f78a", }, + { + "domain": "improv_ble", + "service_data_uuid": "00004677-0000-1000-8000-00805f9b34fb", + "service_uuid": "00467768-6228-2272-4663-277478268000", + }, { "connectable": False, "domain": "inkbird", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 86027f50e41a5b..25ee0ce67c0484 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -9,6 +9,7 @@ "group", "integration", "min_max", + "random", "switch_as_x", "template", "threshold", @@ -121,7 +122,6 @@ "ecowitt", "edl21", "efergy", - "eight_sleep", "electrasmart", "electric_kiwi", "elgato", @@ -183,6 +183,7 @@ "google_generative_ai_conversation", "google_mail", "google_sheets", + "google_tasks", "google_translate", "google_travel_time", "govee_ble", @@ -219,6 +220,7 @@ "idasen_desk", "ifttt", "imap", + "improv_ble", "inkbird", "insteon", "intellifire", @@ -263,6 +265,7 @@ "livisi", "local_calendar", "local_ip", + "local_todo", "locative", "logi_circle", "lookin", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d131b4281af55d..7fd6fa92f26b22 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1375,12 +1375,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "eight_sleep": { - "name": "Eight Sleep", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "electrasmart": { "name": "Electra Smart", "integration_type": "hub", @@ -2168,6 +2162,12 @@ "iot_class": "cloud_polling", "name": "Google Sheets" }, + "google_tasks": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Tasks" + }, "google_translate": { "integration_type": "hub", "config_flow": true, @@ -2620,6 +2620,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "improv_ble": { + "name": "Improv via BLE", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "incomfort": { "name": "Intergas InComfort/Intouch Lan2RF gateway", "integration_type": "hub", @@ -3111,6 +3117,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "local_todo": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "locative": { "name": "Locative", "integration_type": "hub", @@ -4596,12 +4607,6 @@ "config_flow": true, "iot_class": "local_polling" }, - "random": { - "name": "Random", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "rapt_ble": { "name": "RAPT Bluetooth", "integration_type": "hub", @@ -6769,6 +6774,12 @@ "config_flow": true, "iot_class": "calculated" }, + "random": { + "name": "Random", + "integration_type": "helper", + "config_flow": true, + "iot_class": "calculated" + }, "schedule": { "integration_type": "helper", "config_flow": false @@ -6831,6 +6842,7 @@ "islamic_prayer_times", "local_calendar", "local_ip", + "local_todo", "min_max", "mobile_app", "moehlenhoff_alpha2", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 20351efff530a8..b8d810d899b7b8 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -7,7 +7,7 @@ from ssl import SSLContext import sys from types import MappingProxyType -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import aiohttp from aiohttp import web @@ -29,9 +29,8 @@ DATA_CONNECTOR = "aiohttp_connector" -DATA_CONNECTOR_NOTVERIFY = "aiohttp_connector_notverify" DATA_CLIENTSESSION = "aiohttp_clientsession" -DATA_CLIENTSESSION_NOTVERIFY = "aiohttp_clientsession_notverify" + SERVER_SOFTWARE = "{0}/{1} aiohttp/{2} Python/{3[0]}.{3[1]}".format( APPLICATION_NAME, __version__, aiohttp.__version__, sys.version_info ) @@ -88,22 +87,31 @@ async def json( @callback @bind_hass def async_get_clientsession( - hass: HomeAssistant, verify_ssl: bool = True + hass: HomeAssistant, verify_ssl: bool = True, family: int = 0 ) -> aiohttp.ClientSession: """Return default aiohttp ClientSession. This method must be run in the event loop. """ - key = DATA_CLIENTSESSION if verify_ssl else DATA_CLIENTSESSION_NOTVERIFY + session_key = _make_key(verify_ssl, family) + if DATA_CLIENTSESSION not in hass.data: + sessions: dict[tuple[bool, int], aiohttp.ClientSession] = {} + hass.data[DATA_CLIENTSESSION] = sessions + else: + sessions = hass.data[DATA_CLIENTSESSION] - if key not in hass.data: - hass.data[key] = _async_create_clientsession( + if session_key not in sessions: + session = _async_create_clientsession( hass, verify_ssl, auto_cleanup_method=_async_register_default_clientsession_shutdown, + family=family, ) + sessions[session_key] = session + else: + session = sessions[session_key] - return cast(aiohttp.ClientSession, hass.data[key]) + return session @callback @@ -112,6 +120,7 @@ def async_create_clientsession( hass: HomeAssistant, verify_ssl: bool = True, auto_cleanup: bool = True, + family: int = 0, **kwargs: Any, ) -> aiohttp.ClientSession: """Create a new ClientSession with kwargs, i.e. for cookies. @@ -131,6 +140,7 @@ def async_create_clientsession( hass, verify_ssl, auto_cleanup_method=auto_cleanup_method, + family=family, **kwargs, ) @@ -143,11 +153,12 @@ def _async_create_clientsession( verify_ssl: bool = True, auto_cleanup_method: Callable[[HomeAssistant, aiohttp.ClientSession], None] | None = None, + family: int = 0, **kwargs: Any, ) -> aiohttp.ClientSession: """Create a new ClientSession with kwargs, i.e. for cookies.""" clientsession = aiohttp.ClientSession( - connector=_async_get_connector(hass, verify_ssl), + connector=_async_get_connector(hass, verify_ssl, family), json_serialize=json_dumps, response_class=HassClientResponse, **kwargs, @@ -275,18 +286,29 @@ def _async_close_websession(event: Event) -> None: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_websession) +@callback +def _make_key(verify_ssl: bool = True, family: int = 0) -> tuple[bool, int]: + """Make a key for connector or session pool.""" + return (verify_ssl, family) + + @callback def _async_get_connector( - hass: HomeAssistant, verify_ssl: bool = True + hass: HomeAssistant, verify_ssl: bool = True, family: int = 0 ) -> aiohttp.BaseConnector: """Return the connector pool for aiohttp. This method must be run in the event loop. """ - key = DATA_CONNECTOR if verify_ssl else DATA_CONNECTOR_NOTVERIFY + connector_key = _make_key(verify_ssl, family) + if DATA_CONNECTOR not in hass.data: + connectors: dict[tuple[bool, int], aiohttp.BaseConnector] = {} + hass.data[DATA_CONNECTOR] = connectors + else: + connectors = hass.data[DATA_CONNECTOR] - if key in hass.data: - return cast(aiohttp.BaseConnector, hass.data[key]) + if connector_key in connectors: + return connectors[connector_key] if verify_ssl: ssl_context: bool | SSLContext = ssl_util.get_default_context() @@ -294,12 +316,13 @@ def _async_get_connector( ssl_context = ssl_util.get_default_no_verify_context() connector = aiohttp.TCPConnector( + family=family, enable_cleanup_closed=ENABLE_CLEANUP_CLOSED, ssl=ssl_context, limit=MAXIMUM_CONNECTIONS, limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST, ) - hass.data[key] = connector + connectors[connector_key] = connector async def _async_close_connector(event: Event) -> None: """Close connector pool.""" diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 3218c1e839b33f..a5e68cb877d8e2 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -127,7 +127,7 @@ def _comp_error(ex: Exception, domain: str, config: ConfigType) -> None: try: integration = await async_get_integration_with_requirements(hass, domain) except loader.IntegrationNotFound as ex: - if not hass.config.recovery_mode: + if not hass.config.recovery_mode and not hass.config.safe_mode: result.add_error(f"Integration error: {domain} - {ex}") continue except RequirementsNotFound as ex: @@ -216,7 +216,7 @@ def _comp_error(ex: Exception, domain: str, config: ConfigType) -> None: ) platform = p_integration.get_platform(domain) except loader.IntegrationNotFound as ex: - if not hass.config.recovery_mode: + if not hass.config.recovery_mode and not hass.config.safe_mode: result.add_error(f"Platform error {domain}.{p_name} - {ex}") continue except ( diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 2da8a48be987ee..ab0fc25f04d77b 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -394,8 +394,8 @@ def _async_track_event( if listeners_key not in hass_data: hass_data[listeners_key] = hass.bus.async_listen( event_type, - callback(ft.partial(dispatcher_callable, hass, callbacks)), - event_filter=callback(ft.partial(filter_callable, hass, callbacks)), + ft.partial(dispatcher_callable, hass, callbacks), + event_filter=ft.partial(filter_callable, hass, callbacks), ) job = HassJob(action, f"track {event_type} event {keys}") @@ -1357,7 +1357,10 @@ def async_track_point_in_time( | Callable[[datetime], Coroutine[Any, Any, None] | None], point_in_time: datetime, ) -> CALLBACK_TYPE: - """Add a listener that fires once after a specific point in time.""" + """Add a listener that fires once at or after a specific point in time. + + The listener is passed the time it fires in local time. + """ job = ( action if isinstance(action, HassJob) @@ -1388,7 +1391,10 @@ def async_track_point_in_utc_time( | Callable[[datetime], Coroutine[Any, Any, None] | None], point_in_time: datetime, ) -> CALLBACK_TYPE: - """Add a listener that fires once after a specific point in UTC time.""" + """Add a listener that fires once at or after a specific point in time. + + The listener is passed the time it fires in UTC time. + """ # Ensure point_in_time is UTC utc_point_in_time = dt_util.as_utc(point_in_time) expected_fire_timestamp = dt_util.utc_to_timestamp(utc_point_in_time) @@ -1450,7 +1456,10 @@ def async_call_at( | Callable[[datetime], Coroutine[Any, Any, None] | None], loop_time: float, ) -> CALLBACK_TYPE: - """Add a listener that is called at .""" + """Add a listener that fires at or after . + + The listener is passed the time it fires in UTC time. + """ job = ( action if isinstance(action, HassJob) @@ -1467,7 +1476,10 @@ def async_call_later( action: HassJob[[datetime], Coroutine[Any, Any, None] | None] | Callable[[datetime], Coroutine[Any, Any, None] | None], ) -> CALLBACK_TYPE: - """Add a listener that is called in .""" + """Add a listener that fires at or after . + + The listener is passed the time it fires in UTC time. + """ if isinstance(delay, timedelta): delay = delay.total_seconds() job = ( @@ -1492,7 +1504,10 @@ def async_track_time_interval( name: str | None = None, cancel_on_shutdown: bool | None = None, ) -> CALLBACK_TYPE: - """Add a listener that fires repetitively at every timedelta interval.""" + """Add a listener that fires repetitively at every timedelta interval. + + The listener is passed the time it fires in UTC time. + """ remove: CALLBACK_TYPE interval_listener_job: HassJob[[datetime], None] interval_seconds = interval.total_seconds() @@ -1636,7 +1651,10 @@ def async_track_utc_time_change( second: Any | None = None, local: bool = False, ) -> CALLBACK_TYPE: - """Add a listener that will fire if time matches a pattern.""" + """Add a listener that will fire every time the UTC or local time matches a pattern. + + The listener is passed the time it fires in UTC or local time. + """ # We do not have to wrap the function with time pattern matching logic # if no pattern given if all(val is None or val == "*" for val in (hour, minute, second)): @@ -1711,7 +1729,10 @@ def async_track_time_change( minute: Any | None = None, second: Any | None = None, ) -> CALLBACK_TYPE: - """Add a listener that will fire if local time matches a pattern.""" + """Add a listener that will fire every time the local time matches a pattern. + + The listener is passed the time it fires in local time. + """ return async_track_utc_time_change(hass, action, hour, minute, second, local=True) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 26b0674a3512ed..06280a26ccd6a9 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1956,6 +1956,41 @@ def is_number(value): return True +def _is_list(value: Any) -> bool: + """Return whether a value is a list.""" + return isinstance(value, list) + + +def _is_set(value: Any) -> bool: + """Return whether a value is a set.""" + return isinstance(value, set) + + +def _is_tuple(value: Any) -> bool: + """Return whether a value is a tuple.""" + return isinstance(value, tuple) + + +def _to_set(value: Any) -> set[Any]: + """Convert value to set.""" + return set(value) + + +def _to_tuple(value): + """Convert value to tuple.""" + return tuple(value) + + +def _is_datetime(value: Any) -> bool: + """Return whether a value is a datetime.""" + return isinstance(value, datetime) + + +def _is_string_like(value: Any) -> bool: + """Return whether a value is a string or string like object.""" + return isinstance(value, (str, bytes, bytearray)) + + def regex_match(value, find="", ignorecase=False): """Match value using regex.""" if not isinstance(value, str): @@ -2387,6 +2422,8 @@ def __init__( self.globals["max"] = min_max_from_filter(self.filters["max"], "max") self.globals["min"] = min_max_from_filter(self.filters["min"], "min") self.globals["is_number"] = is_number + self.globals["set"] = _to_set + self.globals["tuple"] = _to_tuple self.globals["int"] = forgiving_int self.globals["pack"] = struct_pack self.globals["unpack"] = struct_unpack @@ -2395,6 +2432,11 @@ def __init__( self.globals["bool"] = forgiving_boolean self.globals["version"] = version self.tests["is_number"] = is_number + self.tests["list"] = _is_list + self.tests["set"] = _is_set + self.tests["tuple"] = _is_tuple + self.tests["datetime"] = _is_datetime + self.tests["string_like"] = _is_string_like self.tests["match"] = regex_match self.tests["search"] = regex_search self.tests["contains"] = contains diff --git a/homeassistant/loader.py b/homeassistant/loader.py index e4f36f11a36fae..39564846de3a02 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -188,7 +188,7 @@ async def _async_get_custom_components( hass: HomeAssistant, ) -> dict[str, Integration]: """Return list of custom integrations.""" - if hass.config.recovery_mode: + if hass.config.recovery_mode or hass.config.safe_mode: return {} try: @@ -1179,7 +1179,7 @@ def _async_mount_config_dir(hass: HomeAssistant) -> None: def _lookup_path(hass: HomeAssistant) -> list[str]: """Return the lookup paths for legacy lookups.""" - if hass.config.recovery_mode: + if hass.config.recovery_mode or hass.config.safe_mode: return [PACKAGE_BUILTIN] return [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ac3245c2ff1ad7..5d68cead747e19 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -8,7 +8,7 @@ atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==23.8.0 bcrypt==4.0.1 -bleak-retry-connector==3.2.1 +bleak-retry-connector==3.3.0 bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.3 @@ -19,10 +19,10 @@ cryptography==41.0.4 dbus-fast==2.12.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 -hass-nabucasa==0.73.0 +hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231005.0 +home-assistant-frontend==20231027.0 home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 diff --git a/homeassistant/runner.py b/homeassistant/runner.py index ca658c154a235e..622e69ecf8c5e2 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -54,6 +54,8 @@ class RuntimeConfig: debug: bool = False open_ui: bool = False + safe_mode: bool = False + def can_use_pidfd() -> bool: """Check if pidfd_open is available. diff --git a/mypy.ini b/mypy.ini index 43ec39ebc56b44..92b96e756598aa 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1801,6 +1801,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.local_todo.*] +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.lock.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 845b70b72ba873..f43dd9b667209c 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2469,7 +2469,7 @@ class ClassTypeHintMatch: function_name="async_move_todo_item", arg_types={ 1: "str", - 2: "int", + 2: "str | None", }, return_type="None", ), diff --git a/pyproject.toml b/pyproject.toml index ee3860da30b47f..235e41a7ccaa47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0.dev0" +version = "2023.12.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -491,9 +491,6 @@ filterwarnings = [ "ignore:`jaraco.functools.call_aside` is deprecated, use `jaraco.functools.invoke` instead:DeprecationWarning:jaraco.abode.helpers.timeline", # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", - # https://github.com/pytest-dev/pytest/pull/10894 - >=7.4.0 - "ignore:ast.(Str|Num|NameConstant) is deprecated and will be removed in Python 3.14:DeprecationWarning:_pytest.assertion.rewrite", - "ignore:Attribute s is deprecated and will be removed in Python 3.14:DeprecationWarning:_pytest.assertion.rewrite", # https://github.com/bachya/pytile/pull/280 - >2023.08.0 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pytile.tile", # https://github.com/rytilahti/python-miio/pull/1809 - >0.5.12 diff --git a/requirements_all.txt b/requirements_all.txt index 0facf6a5b92d71..fc1690d702c2e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -26,7 +26,7 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.9.0 +HAP-python==4.9.1 # homeassistant.components.tasmota HATasmota==0.7.3 @@ -147,7 +147,7 @@ TwitterAPI==2.7.12 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==1.0.0 +accuweather==2.0.0 # homeassistant.components.adax adax==0.3.0 @@ -192,7 +192,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.0 +aioairzone-cloud==0.3.1 # homeassistant.components.airzone aioairzone==0.6.9 @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.11 +aioesphomeapi==18.2.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -369,13 +369,13 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==63 +aiounifi==64 # homeassistant.components.vlc_telnet aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.1 +aiovodafone==0.4.2 # homeassistant.components.waqi aiowaqi==2.1.0 @@ -387,7 +387,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==1.0.1 +aiowithings==1.0.2 # homeassistant.components.yandex_transport aioymaps==1.2.2 @@ -521,7 +521,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.5 +bellows==0.36.8 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.2 @@ -530,7 +530,7 @@ bimmer-connected==0.14.2 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.2.1 +bleak-retry-connector==3.3.0 # homeassistant.components.bluetooth bleak==0.21.1 @@ -885,7 +885,7 @@ georss-qld-bushfire-alert-client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==3.1.0 +gios==3.2.0 # homeassistant.components.gitter gitterpy==0.1.7 @@ -897,9 +897,10 @@ glances-api==0.4.3 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.2.31 +goodwe==0.2.32 # homeassistant.components.google_mail +# homeassistant.components.google_tasks google-api-python-client==2.71.0 # homeassistant.components.google_pubsub @@ -912,7 +913,7 @@ google-cloud-texttospeech==2.12.3 google-generativeai==0.1.0 # homeassistant.components.nest -google-nest-sdm==3.0.2 +google-nest-sdm==3.0.3 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -973,7 +974,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.73.0 +hass-nabucasa==0.74.0 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -1009,7 +1010,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231005.0 +home-assistant-frontend==20231027.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 @@ -1018,7 +1019,7 @@ home-assistant-intents==2023.10.16 homeconnect==0.7.2 # homeassistant.components.homematicip_cloud -homematicip==1.0.15 +homematicip==1.0.16 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 @@ -1048,6 +1049,7 @@ ibeacon-ble==1.0.1 ibmiotf==0.3.4 # homeassistant.components.local_calendar +# homeassistant.components.local_todo ical==5.1.0 # homeassistant.components.ping @@ -1282,7 +1284,7 @@ netdata==1.1.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==2.1.0 +nettigo-air-monitor==2.2.0 # homeassistant.components.neurio_energy neurio==0.3.1 @@ -1297,7 +1299,7 @@ nextcloudmonitor==1.4.0 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==1.4.0 +nextdns==2.0.0 # homeassistant.components.nibe_heatpump nibe==2.4.0 @@ -1398,7 +1400,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.37 +opower==0.0.38 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1461,7 +1463,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.33.1 +plugwise==0.33.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1518,6 +1520,9 @@ py-cpuinfo==9.0.0 # homeassistant.components.dormakaba_dkey py-dormakaba-dkey==1.0.5 +# homeassistant.components.improv_ble +py-improv-ble-client==1.0.3 + # homeassistant.components.melissa py-melissa-climate==2.1.4 @@ -1554,9 +1559,6 @@ pyControl4==1.1.0 # homeassistant.components.duotecno pyDuotecno==2023.10.1 -# homeassistant.components.eight_sleep -pyEight==0.3.2 - # homeassistant.components.electrasmart pyElectra==1.2.0 @@ -1685,10 +1687,10 @@ pydroid-ipcam==2.0.0 pyebox==1.1.4 # homeassistant.components.ecoforest -pyecoforest==0.3.0 +pyecoforest==0.4.0 # homeassistant.components.econet -pyeconet==0.1.20 +pyeconet==0.1.22 # homeassistant.components.edimax pyedimax==0.2.1 @@ -2106,7 +2108,7 @@ python-clementine-remote==1.0.1 python-digitalocean==1.13.2 # homeassistant.components.ecobee -python-ecobee-api==0.2.14 +python-ecobee-api==0.2.17 # homeassistant.components.eq3btsmart # python-eq3bt==0.2 @@ -2151,7 +2153,7 @@ python-kasa[speedups]==0.5.3 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==3.7.0 +python-matter-server==4.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -2386,7 +2388,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.3 +screenlogicpy==0.9.4 # homeassistant.components.scsgate scsgate==0.1.0 @@ -2492,7 +2494,7 @@ starline==0.1.5 starlingbank==3.2 # homeassistant.components.starlink -starlink-grpc-core==1.1.2 +starlink-grpc-core==1.1.3 # homeassistant.components.statsd statsd==3.2.1 @@ -2665,7 +2667,7 @@ vallox-websocket-api==3.3.0 vehicle==2.0.0 # homeassistant.components.velbus -velbus-aio==2023.10.1 +velbus-aio==2023.10.2 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2686,7 +2688,7 @@ volvooncall==0.10.3 vsure==2.6.6 # homeassistant.components.vasttrafik -vtjp==0.1.14 +vtjp==0.2.1 # homeassistant.components.vulcan vulcan-api==2.3.0 @@ -2744,7 +2746,7 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.3.0 +xknxproject==3.4.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2798,7 +2800,7 @@ zeroconf==0.119.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.105 +zha-quirks==0.0.106 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 @@ -2810,7 +2812,7 @@ ziggo-mediabox-xl==1.1.0 zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.3 +zigpy-xbee==0.19.0 # homeassistant.components.zha zigpy-zigate==0.11.0 @@ -2819,13 +2821,13 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.6 # homeassistant.components.zha -zigpy==0.58.1 +zigpy==0.59.0 # homeassistant.components.zoneminder zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.52.1 +zwave-js-server-python==0.53.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index 69f8936b18b1f8..1dc9139fde724e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -28,7 +28,7 @@ pytest-timeout==2.1.0 pytest-unordered==0.5.2 pytest-picked==0.4.6 pytest-xdist==3.3.1 -pytest==7.3.1 +pytest==7.4.3 requests-mock==1.11.0 respx==0.20.2 syrupy==4.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e594c978fab92f..4595e8642acff7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -25,7 +25,7 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.9.0 +HAP-python==4.9.1 # homeassistant.components.tasmota HATasmota==0.7.3 @@ -128,7 +128,7 @@ Tami4EdgeAPI==2.1 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==1.0.0 +accuweather==2.0.0 # homeassistant.components.adax adax==0.3.0 @@ -173,7 +173,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.0 +aioairzone-cloud==0.3.1 # homeassistant.components.airzone aioairzone==0.6.9 @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.11 +aioesphomeapi==18.2.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -344,13 +344,13 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==63 +aiounifi==64 # homeassistant.components.vlc_telnet aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.1 +aiovodafone==0.4.2 # homeassistant.components.waqi aiowaqi==2.1.0 @@ -362,7 +362,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==1.0.1 +aiowithings==1.0.2 # homeassistant.components.yandex_transport aioymaps==1.2.2 @@ -445,13 +445,13 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.5 +bellows==0.36.8 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.2 # homeassistant.components.bluetooth -bleak-retry-connector==3.2.1 +bleak-retry-connector==3.3.0 # homeassistant.components.bluetooth bleak==0.21.1 @@ -707,7 +707,7 @@ georss-qld-bushfire-alert-client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==3.1.0 +gios==3.2.0 # homeassistant.components.glances glances-api==0.4.3 @@ -716,9 +716,10 @@ glances-api==0.4.3 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.2.31 +goodwe==0.2.32 # homeassistant.components.google_mail +# homeassistant.components.google_tasks google-api-python-client==2.71.0 # homeassistant.components.google_pubsub @@ -728,7 +729,7 @@ google-cloud-pubsub==2.13.11 google-generativeai==0.1.0 # homeassistant.components.nest -google-nest-sdm==3.0.2 +google-nest-sdm==3.0.3 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -774,7 +775,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.73.0 +hass-nabucasa==0.74.0 # homeassistant.components.conversation hassil==1.2.5 @@ -798,7 +799,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231005.0 +home-assistant-frontend==20231027.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 @@ -807,7 +808,7 @@ home-assistant-intents==2023.10.16 homeconnect==0.7.2 # homeassistant.components.homematicip_cloud -homematicip==1.0.15 +homematicip==1.0.16 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 @@ -828,6 +829,7 @@ iaqualink==0.5.0 ibeacon-ble==1.0.1 # homeassistant.components.local_calendar +# homeassistant.components.local_todo ical==5.1.0 # homeassistant.components.ping @@ -1002,7 +1004,7 @@ nessclient==1.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==2.1.0 +nettigo-air-monitor==2.2.0 # homeassistant.components.nexia nexia==2.0.7 @@ -1014,7 +1016,7 @@ nextcloudmonitor==1.4.0 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==1.4.0 +nextdns==2.0.0 # homeassistant.components.nibe_heatpump nibe==2.4.0 @@ -1076,7 +1078,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.37 +opower==0.0.38 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1121,7 +1123,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.33.1 +plugwise==0.33.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 @@ -1163,6 +1165,9 @@ py-cpuinfo==9.0.0 # homeassistant.components.dormakaba_dkey py-dormakaba-dkey==1.0.5 +# homeassistant.components.improv_ble +py-improv-ble-client==1.0.3 + # homeassistant.components.melissa py-melissa-climate==2.1.4 @@ -1187,9 +1192,6 @@ pyControl4==1.1.0 # homeassistant.components.duotecno pyDuotecno==2023.10.1 -# homeassistant.components.eight_sleep -pyEight==0.3.2 - # homeassistant.components.electrasmart pyElectra==1.2.0 @@ -1270,10 +1272,10 @@ pydrawise==2023.10.0 pydroid-ipcam==2.0.0 # homeassistant.components.ecoforest -pyecoforest==0.3.0 +pyecoforest==0.4.0 # homeassistant.components.econet -pyeconet==0.1.20 +pyeconet==0.1.22 # homeassistant.components.efergy pyefergy==22.1.1 @@ -1586,7 +1588,7 @@ python-awair==0.2.4 python-bsblan==0.5.16 # homeassistant.components.ecobee -python-ecobee-api==0.2.14 +python-ecobee-api==0.2.17 # homeassistant.components.fully_kiosk python-fullykiosk==0.0.12 @@ -1604,7 +1606,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.5.3 # homeassistant.components.matter -python-matter-server==3.7.0 +python-matter-server==4.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -1773,7 +1775,7 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.3 +screenlogicpy==0.9.4 # homeassistant.components.backup securetar==2023.3.0 @@ -1855,7 +1857,7 @@ srpenergy==1.3.6 starline==0.1.5 # homeassistant.components.starlink -starlink-grpc-core==1.1.2 +starlink-grpc-core==1.1.3 # homeassistant.components.statsd statsd==3.2.1 @@ -1983,7 +1985,7 @@ vallox-websocket-api==3.3.0 vehicle==2.0.0 # homeassistant.components.velbus -velbus-aio==2023.10.1 +velbus-aio==2023.10.2 # homeassistant.components.venstar venstarcolortouch==0.19 @@ -2047,7 +2049,7 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.3.0 +xknxproject==3.4.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2092,13 +2094,13 @@ zeroconf==0.119.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.105 +zha-quirks==0.0.106 # homeassistant.components.zha zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.3 +zigpy-xbee==0.19.0 # homeassistant.components.zha zigpy-zigate==0.11.0 @@ -2107,10 +2109,10 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.6 # homeassistant.components.zha -zigpy==0.58.1 +zigpy==0.59.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.52.1 +zwave-js-server-python==0.53.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 5c6d7b19719ce4..4483aacd80475f 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -37,6 +37,7 @@ "islamic_prayer_times", "local_calendar", "local_ip", + "local_todo", "nmap_tracker", "rpi_power", "waze_travel_time", diff --git a/script/version_bump.py b/script/version_bump.py index 5e383ab7d4bbe4..3a6c0fa75400f2 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -177,7 +177,7 @@ def main(): if not arguments.commit: return - subprocess.run(["git", "commit", "-nam", f"Bumped version to {bumped}"], check=True) + subprocess.run(["git", "commit", "-nam", f"Bump version to {bumped}"], check=True) def test_bump_version(): diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index b045092d78d12d..f5f12e48a40962 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -73,7 +73,7 @@ async def test_climate_async_setup_entry( assert state.attributes.get(ATTR_MIN_TEMP) == 16 assert state.attributes.get(ATTR_MAX_TEMP) == 32 assert state.attributes.get(ATTR_TEMPERATURE) == 24 - assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) is None + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 25 entry = registry.async_get(entity_id) assert entry diff --git a/tests/components/aemet/snapshots/test_weather.ambr b/tests/components/aemet/snapshots/test_weather.ambr index 3078cab448065c..08cc379267d7f8 100644 --- a/tests/components/aemet/snapshots/test_weather.ambr +++ b/tests/components/aemet/snapshots/test_weather.ambr @@ -14,7 +14,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 3.0, 'templow': -7.0, 'wind_bearing': 0.0, @@ -23,7 +23,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-12T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': -1.0, 'templow': -13.0, 'wind_bearing': None, @@ -31,7 +31,7 @@ dict({ 'condition': 'sunny', 'datetime': '2021-01-13T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -11.0, 'wind_bearing': None, @@ -39,7 +39,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-14T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -7.0, 'wind_bearing': None, @@ -47,7 +47,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-15T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 5.0, 'templow': -4.0, 'wind_bearing': None, @@ -151,6 +151,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -160,6 +161,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 45.0, @@ -169,6 +171,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -178,7 +181,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 10, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 12.0, @@ -187,6 +191,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -196,6 +201,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -205,6 +211,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -214,6 +221,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -223,6 +231,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 0.0, @@ -232,7 +241,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T06:00:00+00:00', - 'precipitation_probability': 10, + 'precipitation': 0.0, + 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 0.0, 'wind_gust_speed': 13.0, @@ -241,6 +251,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -2.0, 'wind_bearing': 45.0, @@ -250,6 +261,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -259,6 +271,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -268,6 +281,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -277,6 +291,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -286,7 +301,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T12:00:00+00:00', - 'precipitation_probability': 15, + 'precipitation': 0.0, + 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 32.0, @@ -295,6 +311,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -304,6 +321,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -313,6 +331,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 4.0, 'wind_bearing': 45.0, @@ -322,6 +341,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -331,6 +351,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -340,7 +361,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T18:00:00+00:00', - 'precipitation_probability': 5, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -349,7 +371,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T19:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -358,7 +381,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T20:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -367,7 +391,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T21:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -376,7 +401,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T22:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -385,7 +411,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T23:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -394,7 +421,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -403,7 +431,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T01:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -412,7 +441,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T02:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 22.0, @@ -421,7 +451,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T03:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 17.0, @@ -430,7 +461,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T04:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, @@ -439,7 +471,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T05:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -4.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, @@ -471,7 +504,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 3.0, 'templow': -7.0, 'wind_bearing': 0.0, @@ -480,7 +513,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-12T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': -1.0, 'templow': -13.0, 'wind_bearing': None, @@ -488,7 +521,7 @@ dict({ 'condition': 'sunny', 'datetime': '2021-01-13T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -11.0, 'wind_bearing': None, @@ -496,7 +529,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-14T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -7.0, 'wind_bearing': None, @@ -504,7 +537,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-15T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 5.0, 'templow': -4.0, 'wind_bearing': None, @@ -525,7 +558,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 3.0, 'templow': -7.0, 'wind_bearing': 0.0, @@ -534,7 +567,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-12T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': -1.0, 'templow': -13.0, 'wind_bearing': None, @@ -542,7 +575,7 @@ dict({ 'condition': 'sunny', 'datetime': '2021-01-13T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -11.0, 'wind_bearing': None, @@ -550,7 +583,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-14T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 6.0, 'templow': -7.0, 'wind_bearing': None, @@ -558,7 +591,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-15T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation_probability': 0, 'temperature': 5.0, 'templow': -4.0, 'wind_bearing': None, @@ -660,6 +693,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -669,6 +703,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 45.0, @@ -678,6 +713,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -687,7 +723,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 10, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 12.0, @@ -696,6 +733,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -705,6 +743,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -714,6 +753,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -723,6 +763,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -732,6 +773,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 0.0, @@ -741,7 +783,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T06:00:00+00:00', - 'precipitation_probability': 10, + 'precipitation': 0.0, + 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 0.0, 'wind_gust_speed': 13.0, @@ -750,6 +793,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -2.0, 'wind_bearing': 45.0, @@ -759,6 +803,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -768,6 +813,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -777,6 +823,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -786,6 +833,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -795,7 +843,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T12:00:00+00:00', - 'precipitation_probability': 15, + 'precipitation': 0.0, + 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 32.0, @@ -804,6 +853,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -813,6 +863,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -822,6 +873,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 4.0, 'wind_bearing': 45.0, @@ -831,6 +883,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -840,6 +893,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -849,7 +903,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T18:00:00+00:00', - 'precipitation_probability': 5, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -858,7 +913,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T19:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -867,7 +923,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T20:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -876,7 +933,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T21:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -885,7 +943,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T22:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -894,7 +953,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T23:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -903,7 +963,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -912,7 +973,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T01:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -921,7 +983,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T02:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 22.0, @@ -930,7 +993,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T03:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 17.0, @@ -939,7 +1003,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T04:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, @@ -948,7 +1013,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T05:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -4.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, @@ -1060,6 +1126,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T21:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -1069,6 +1136,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-09T22:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 45.0, @@ -1078,6 +1146,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-09T23:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 100, 'temperature': 1.0, 'wind_bearing': 90.0, @@ -1087,7 +1156,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 10, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 12.0, @@ -1096,6 +1166,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T01:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -1105,6 +1176,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T02:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -1114,6 +1186,7 @@ dict({ 'condition': 'fog', 'datetime': '2021-01-10T03:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': 0.0, 'wind_bearing': 0.0, @@ -1123,6 +1196,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T04:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -1132,6 +1206,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T05:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 10, 'temperature': -1.0, 'wind_bearing': 0.0, @@ -1141,7 +1216,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T06:00:00+00:00', - 'precipitation_probability': 10, + 'precipitation': 0.0, + 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 0.0, 'wind_gust_speed': 13.0, @@ -1150,6 +1226,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T07:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -2.0, 'wind_bearing': 45.0, @@ -1159,6 +1236,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T08:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -1168,6 +1246,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T09:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': -1.0, 'wind_bearing': 45.0, @@ -1177,6 +1256,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T10:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 0.0, 'wind_bearing': 45.0, @@ -1186,6 +1266,7 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T11:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 15, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -1195,7 +1276,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T12:00:00+00:00', - 'precipitation_probability': 15, + 'precipitation': 0.0, + 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 32.0, @@ -1204,6 +1286,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T13:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -1213,6 +1296,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T14:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -1222,6 +1306,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T15:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 4.0, 'wind_bearing': 45.0, @@ -1231,6 +1316,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T16:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 3.0, 'wind_bearing': 45.0, @@ -1240,6 +1326,7 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T17:00:00+00:00', + 'precipitation': 0.0, 'precipitation_probability': 5, 'temperature': 2.0, 'wind_bearing': 45.0, @@ -1249,7 +1336,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T18:00:00+00:00', - 'precipitation_probability': 5, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -1258,7 +1346,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-10T19:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -1267,7 +1356,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T20:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 25.0, @@ -1276,7 +1366,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T21:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 24.0, @@ -1285,7 +1376,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T22:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -1294,7 +1386,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-10T23:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': 0.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -1303,7 +1396,8 @@ dict({ 'condition': 'cloudy', 'datetime': '2021-01-11T00:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 30.0, @@ -1312,7 +1406,8 @@ dict({ 'condition': 'partlycloudy', 'datetime': '2021-01-11T01:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -1.0, 'wind_bearing': 45.0, 'wind_gust_speed': 27.0, @@ -1321,7 +1416,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T02:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 22.0, @@ -1330,7 +1426,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T03:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -2.0, 'wind_bearing': 45.0, 'wind_gust_speed': 17.0, @@ -1339,7 +1436,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T04:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -3.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, @@ -1348,7 +1446,8 @@ dict({ 'condition': 'clear-night', 'datetime': '2021-01-11T05:00:00+00:00', - 'precipitation_probability': None, + 'precipitation': 0.0, + 'precipitation_probability': 0, 'temperature': -4.0, 'wind_bearing': 45.0, 'wind_gust_speed': 15.0, diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index d0042faaaa0640..67cdbe7805db82 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -54,25 +54,25 @@ async def test_aemet_weather( state = hass.states.get("weather.aemet") assert state assert state.state == ATTR_CONDITION_SNOWY - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 99.0 - assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1004.4 # 100440.0 Pa -> hPa - assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == -0.7 - assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 90.0 - assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 24.0 - assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 15.0 # 4.17 m/s -> km/h - forecast = state.attributes.get(ATTR_FORECAST)[0] - assert forecast.get(ATTR_FORECAST_CONDITION) == ATTR_CONDITION_PARTLYCLOUDY - assert forecast.get(ATTR_FORECAST_PRECIPITATION) is None - assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 30 - assert forecast.get(ATTR_FORECAST_TEMP) == 4 - assert forecast.get(ATTR_FORECAST_TEMP_LOW) == -4 + assert state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + assert state.attributes[ATTR_WEATHER_HUMIDITY] == 99.0 + assert state.attributes[ATTR_WEATHER_PRESSURE] == 1004.4 # 100440.0 Pa -> hPa + assert state.attributes[ATTR_WEATHER_TEMPERATURE] == -0.7 + assert state.attributes[ATTR_WEATHER_WIND_BEARING] == 122.0 + assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 12.2 + assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 3.2 + forecast = state.attributes[ATTR_FORECAST][0] + assert forecast[ATTR_FORECAST_CONDITION] == ATTR_CONDITION_PARTLYCLOUDY + assert ATTR_FORECAST_PRECIPITATION not in forecast + assert forecast[ATTR_FORECAST_PRECIPITATION_PROBABILITY] == 30 + assert forecast[ATTR_FORECAST_TEMP] == 4 + assert forecast[ATTR_FORECAST_TEMP_LOW] == -4 assert ( - forecast.get(ATTR_FORECAST_TIME) + forecast[ATTR_FORECAST_TIME] == dt_util.parse_datetime("2021-01-10 00:00:00+00:00").isoformat() ) - assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 45.0 - assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 20.0 # 5.56 m/s -> km/h + assert forecast[ATTR_FORECAST_WIND_BEARING] == 45.0 + assert forecast[ATTR_FORECAST_WIND_SPEED] == 20.0 # 5.56 m/s -> km/h state = hass.states.get("weather.aemet_hourly") assert state is None @@ -98,25 +98,25 @@ async def test_aemet_weather_legacy( state = hass.states.get("weather.aemet_daily") assert state assert state.state == ATTR_CONDITION_SNOWY - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 99.0 - assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1004.4 # 100440.0 Pa -> hPa - assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == -0.7 - assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 90.0 - assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 24.0 - assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 15.0 # 4.17 m/s -> km/h - forecast = state.attributes.get(ATTR_FORECAST)[0] - assert forecast.get(ATTR_FORECAST_CONDITION) == ATTR_CONDITION_PARTLYCLOUDY - assert forecast.get(ATTR_FORECAST_PRECIPITATION) is None - assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 30 - assert forecast.get(ATTR_FORECAST_TEMP) == 4 - assert forecast.get(ATTR_FORECAST_TEMP_LOW) == -4 + assert state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + assert state.attributes[ATTR_WEATHER_HUMIDITY] == 99.0 + assert state.attributes[ATTR_WEATHER_PRESSURE] == 1004.4 # 100440.0 Pa -> hPa + assert state.attributes[ATTR_WEATHER_TEMPERATURE] == -0.7 + assert state.attributes[ATTR_WEATHER_WIND_BEARING] == 122.0 + assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 12.2 + assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 3.2 + forecast = state.attributes[ATTR_FORECAST][0] + assert forecast[ATTR_FORECAST_CONDITION] == ATTR_CONDITION_PARTLYCLOUDY + assert ATTR_FORECAST_PRECIPITATION not in forecast + assert forecast[ATTR_FORECAST_PRECIPITATION_PROBABILITY] == 30 + assert forecast[ATTR_FORECAST_TEMP] == 4 + assert forecast[ATTR_FORECAST_TEMP_LOW] == -4 assert ( - forecast.get(ATTR_FORECAST_TIME) + forecast[ATTR_FORECAST_TIME] == dt_util.parse_datetime("2021-01-10 00:00:00+00:00").isoformat() ) - assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 45.0 - assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 20.0 # 5.56 m/s -> km/h + assert forecast[ATTR_FORECAST_WIND_BEARING] == 45.0 + assert forecast[ATTR_FORECAST_WIND_SPEED] == 20.0 # 5.56 m/s -> km/h state = hass.states.get("weather.aemet_hourly") assert state is None diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index b9ab7198148b01..9cb6e550711957 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -229,6 +229,7 @@ 'mac': '**REDACTED**', 'wifi_channel': 6, 'wifi_rssi': -42, + 'ws_type': 'ws_az', }), }), 'config_entry': dict({ @@ -323,7 +324,9 @@ }), 'version': '1.62', 'webserver': dict({ + 'full-name': 'Airzone WebServer', 'mac': '**REDACTED**', + 'model': 'Airzone WebServer', 'wifi-channel': 6, 'wifi-rssi': -42, }), diff --git a/tests/components/airzone/test_binary_sensor.py b/tests/components/airzone/test_binary_sensor.py index 8033871f5c3b0a..a620a3338c2b75 100644 --- a/tests/components/airzone/test_binary_sensor.py +++ b/tests/components/airzone/test_binary_sensor.py @@ -21,7 +21,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.despacho_air_demand") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.despacho_battery_low") + state = hass.states.get("binary_sensor.despacho_battery") assert state.state == STATE_ON state = hass.states.get("binary_sensor.despacho_floor_demand") @@ -34,7 +34,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.dorm_1_air_demand") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.dorm_1_battery_low") + state = hass.states.get("binary_sensor.dorm_1_battery") assert state.state == STATE_OFF state = hass.states.get("binary_sensor.dorm_1_floor_demand") @@ -46,7 +46,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.dorm_2_air_demand") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.dorm_2_battery_low") + state = hass.states.get("binary_sensor.dorm_2_battery") assert state.state == STATE_OFF state = hass.states.get("binary_sensor.dorm_2_floor_demand") @@ -58,7 +58,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.dorm_ppal_air_demand") assert state.state == STATE_ON - state = hass.states.get("binary_sensor.dorm_ppal_battery_low") + state = hass.states.get("binary_sensor.dorm_ppal_battery") assert state.state == STATE_OFF state = hass.states.get("binary_sensor.dorm_ppal_floor_demand") @@ -70,7 +70,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.salon_air_demand") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.salon_battery_low") + state = hass.states.get("binary_sensor.salon_battery") assert state is None state = hass.states.get("binary_sensor.salon_floor_demand") @@ -79,13 +79,13 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.salon_problem") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.airzone_2_1_battery_low") + state = hass.states.get("binary_sensor.airzone_2_1_battery") assert state is None state = hass.states.get("binary_sensor.airzone_2_1_problem") assert state.state == STATE_OFF - state = hass.states.get("binary_sensor.dkn_plus_battery_low") + state = hass.states.get("binary_sensor.dkn_plus_battery") assert state is None state = hass.states.get("binary_sensor.dkn_plus_problem") diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index 6d94defa004a10..1511cd4362cc48 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -34,7 +34,7 @@ async def test_airzone_create_sensors( assert state.state == "43" # WebServer - state = hass.states.get("sensor.webserver_rssi") + state = hass.states.get("sensor.airzone_webserver_rssi") assert state.state == "-42" # Zones diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index eb687731eb7e2d..a3454549e05044 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -50,6 +50,8 @@ API_VERSION, API_WIFI_CHANNEL, API_WIFI_RSSI, + API_WS_AZ, + API_WS_TYPE, API_ZONE_ID, ) @@ -301,6 +303,7 @@ HVAC_WEBSERVER_MOCK = { API_MAC: "11:22:33:44:55:66", + API_WS_TYPE: API_WS_AZ, API_WIFI_CHANNEL: 6, API_WIFI_RSSI: -42, } diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 412f0df133765b..76349d06481d23 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -101,6 +101,7 @@ API_WS_ID: WS_ID, }, { + API_CONFIG: {}, API_DEVICE_ID: "zone1", API_NAME: "Salon", API_TYPE: API_AZ_ZONE, @@ -111,6 +112,7 @@ API_WS_ID: WS_ID, }, { + API_CONFIG: {}, API_DEVICE_ID: "zone2", API_NAME: "Dormitorio", API_TYPE: API_AZ_ZONE, diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py index 8ba196de54532d..08ccef373360cb 100644 --- a/tests/components/alarm_control_panel/test_device_action.py +++ b/tests/components/alarm_control_panel/test_device_action.py @@ -426,6 +426,7 @@ async def test_get_action_capabilities_arm_code_legacy( async def test_action( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: @@ -433,10 +434,17 @@ async def test_action( platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES["no_arm_code"].unique_id, + device_id=device_entry.id, ) assert await async_setup_component( @@ -451,7 +459,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "arm_away", }, @@ -463,7 +471,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "arm_home", }, @@ -475,7 +483,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "arm_night", }, @@ -487,7 +495,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "arm_vacation", }, @@ -496,7 +504,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_event_disarm"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "disarm", "code": "1234", @@ -509,7 +517,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "trigger", }, @@ -549,6 +557,7 @@ async def test_action( async def test_action_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: @@ -556,10 +565,17 @@ async def test_action_legacy( platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES["no_arm_code"].unique_id, + device_id=device_entry.id, ) assert await async_setup_component( @@ -574,7 +590,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.entity_id, "type": "arm_away", }, diff --git a/tests/components/alarm_control_panel/test_device_condition.py b/tests/components/alarm_control_panel/test_device_condition.py index f1719b83d38f10..6e85c94379f665 100644 --- a/tests/components/alarm_control_panel/test_device_condition.py +++ b/tests/components/alarm_control_panel/test_device_condition.py @@ -185,10 +185,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], ) -> None: """Test for all conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -201,7 +212,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_triggered", } @@ -223,7 +234,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_disarmed", } @@ -245,7 +256,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_armed_home", } @@ -267,7 +278,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_armed_away", } @@ -289,7 +300,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_armed_night", } @@ -311,7 +322,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_armed_vacation", } @@ -333,7 +344,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_armed_custom_bypass", } @@ -438,10 +449,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], ) -> None: """Test for all conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -454,7 +476,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_triggered", } diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index 57b9f8125c2472..70d700bb29066f 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -246,10 +246,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], ): """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ALARM_PENDING) @@ -262,7 +273,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "triggered", }, @@ -284,7 +295,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "disarmed", }, @@ -306,7 +317,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "armed_home", }, @@ -328,7 +339,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "armed_away", }, @@ -350,7 +361,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "armed_night", }, @@ -372,7 +383,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "armed_vacation", }, @@ -450,10 +461,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ALARM_DISARMED) @@ -466,7 +488,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "triggered", "for": {"seconds": 5}, @@ -507,10 +529,21 @@ async def test_if_fires_on_state_change_with_for( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ALARM_DISARMED) @@ -523,7 +556,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "triggered", }, diff --git a/tests/components/apple_tv/test_remote.py b/tests/components/apple_tv/test_remote.py new file mode 100644 index 00000000000000..db2a4964f6cfef --- /dev/null +++ b/tests/components/apple_tv/test_remote.py @@ -0,0 +1,28 @@ +"""Test apple_tv remote.""" +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 + + +@pytest.mark.parametrize( + ("command", "method"), + [ + ("up", "remote_control.up"), + ("wakeup", "power.turn_on"), + ("volume_up", "audio.volume_up"), + ("home_hold", "remote_control.home"), + ], + ids=["up", "wakeup", "volume_up", "home_hold"], +) +async def test_send_command(command: str, method: str) -> 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} + ) + assert len(remote.atv.method_calls) == 1 + assert str(remote.atv.method_calls[0]) == f"call.{method}()" diff --git a/tests/components/arcam_fmj/test_device_trigger.py b/tests/components/arcam_fmj/test_device_trigger.py index 7caba687ff2246..d073e9c75da044 100644 --- a/tests/components/arcam_fmj/test_device_trigger.py +++ b/tests/components/arcam_fmj/test_device_trigger.py @@ -82,7 +82,7 @@ async def test_if_fires_on_turn_on_request( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": entry.device_id, "entity_id": entry.id, "type": "turn_on", }, @@ -128,7 +128,7 @@ async def test_if_fires_on_turn_on_request_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": entry.device_id, "entity_id": entry.entity_id, "type": "turn_on", }, diff --git a/tests/components/automation/test_blueprint.py b/tests/components/automation/test_blueprint.py index ad35a2cfbdd043..2976886881d762 100644 --- a/tests/components/automation/test_blueprint.py +++ b/tests/components/automation/test_blueprint.py @@ -7,13 +7,15 @@ import pytest +from homeassistant import config_entries from homeassistant.components import automation from homeassistant.components.blueprint import models from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, yaml -from tests.common import async_fire_time_changed, async_mock_service +from tests.common import MockConfigEntry, async_fire_time_changed, async_mock_service BUILTIN_BLUEPRINT_FOLDER = pathlib.Path(automation.__file__).parent / "blueprints" @@ -40,8 +42,18 @@ def mock_load_blueprint(self, path): yield -async def test_notify_leaving_zone(hass: HomeAssistant) -> None: +async def test_notify_leaving_zone( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test notifying leaving a zone blueprint.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:01")}, + ) def set_person_state(state, extra={}): hass.states.async_set( @@ -68,7 +80,7 @@ def set_person_state(state, extra={}): "input": { "person_entity": "person.test_person", "zone_entity": "zone.school", - "notify_device": "abcdefgh", + "notify_device": device.id, }, } } @@ -89,7 +101,7 @@ def set_person_state(state, extra={}): "alias": "Notify that a person has left the zone", "domain": "mobile_app", "type": "notify", - "device_id": "abcdefgh", + "device_id": device.id, } message_tpl.hass = hass assert message_tpl.async_render(variables) == "Paulus has left School" diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 0d983864e44c6d..6d83b00517d8ff 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -6,6 +6,7 @@ import pytest +from homeassistant import config_entries import homeassistant.components.automation as automation from homeassistant.components.automation import ( ATTR_SOURCE, @@ -36,6 +37,7 @@ callback, ) from homeassistant.exceptions import HomeAssistantError, Unauthorized +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.script import ( SCRIPT_MODE_CHOICES, SCRIPT_MODE_PARALLEL, @@ -49,6 +51,7 @@ import homeassistant.util.dt as dt_util from tests.common import ( + MockConfigEntry, MockUser, assert_setup_component, async_capture_events, @@ -1589,8 +1592,31 @@ async def test_extraction_functions_unavailable_automation(hass: HomeAssistant) assert automation.entities_in_automation(hass, entity_id) == [] -async def test_extraction_functions(hass: HomeAssistant) -> None: +async def test_extraction_functions( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test extraction functions.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + + condition_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:01")}, + ) + device_in_both = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:02")}, + ) + device_in_last = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:03")}, + ) + trigger_device_2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:04")}, + ) + await async_setup_component(hass, "homeassistant", {}) await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) assert await async_setup_component( @@ -1652,7 +1678,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: }, { "domain": "light", - "device_id": "device-in-both", + "device_id": device_in_both.id, "entity_id": "light.bla", "type": "turn_on", }, @@ -1670,7 +1696,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "domain": "light", "type": "turned_on", "entity_id": "light.trigger_2", - "device_id": "trigger-device-2", + "device_id": trigger_device_2.id, }, { "platform": "tag", @@ -1702,7 +1728,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: ], "condition": { "condition": "device", - "device_id": "condition-device", + "device_id": condition_device.id, "domain": "light", "type": "is_on", "entity_id": "light.bla", @@ -1720,13 +1746,13 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: {"scene": "scene.hello"}, { "domain": "light", - "device_id": "device-in-both", + "device_id": device_in_both.id, "entity_id": "light.bla", "type": "turn_on", }, { "domain": "light", - "device_id": "device-in-last", + "device_id": device_in_last.id, "entity_id": "light.bla", "type": "turn_on", }, @@ -1755,7 +1781,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: ], "condition": { "condition": "device", - "device_id": "condition-device", + "device_id": condition_device.id, "domain": "light", "type": "is_on", "entity_id": "light.bla", @@ -1799,15 +1825,15 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "light.in_both", "light.in_first", } - assert set(automation.automations_with_device(hass, "device-in-both")) == { + assert set(automation.automations_with_device(hass, device_in_both.id)) == { "automation.test1", "automation.test2", } assert set(automation.devices_in_automation(hass, "automation.test2")) == { - "trigger-device-2", - "condition-device", - "device-in-both", - "device-in-last", + trigger_device_2.id, + condition_device.id, + device_in_both.id, + device_in_last.id, "device-trigger-event", "device-trigger-tag1", "device-trigger-tag2", diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index b25ab7877915c6..c902caf31ae1fd 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -234,6 +234,7 @@ async def test_get_condition_capabilities_legacy( async def test_if_state( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -245,7 +246,14 @@ async def test_if_state( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(platform.ENTITIES["battery"].entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -258,7 +266,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_bat_low", } @@ -277,7 +285,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_not_bat_low", } @@ -312,6 +320,7 @@ async def test_if_state( async def test_if_state_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -323,7 +332,14 @@ async def test_if_state_legacy( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(platform.ENTITIES["battery"].entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -336,7 +352,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_bat_low", } @@ -364,6 +380,7 @@ async def test_if_state_legacy( async def test_if_fires_on_for_condition( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -379,7 +396,14 @@ async def test_if_fires_on_for_condition( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(platform.ENTITIES["battery"].entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) with freeze_time(point1) as time_freeze: assert await async_setup_component( @@ -392,7 +416,7 @@ async def test_if_fires_on_for_condition( "condition": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_not_bat_low", "for": {"seconds": 5}, diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index 4b8318e2d79623..47abb29ae86b64 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -235,6 +235,7 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -245,10 +246,17 @@ async def test_if_fires_on_state_change( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES["battery"].unique_id, + device_id=device_entry.id, ) assert await async_setup_component( @@ -260,7 +268,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "bat_low", }, @@ -284,7 +292,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "not_bat_low", }, @@ -329,6 +337,7 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -340,10 +349,17 @@ async def test_if_fires_on_state_change_with_for( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES["battery"].unique_id, + device_id=device_entry.id, ) assert await async_setup_component( @@ -355,7 +371,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, @@ -398,6 +414,7 @@ async def test_if_fires_on_state_change_with_for( async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -409,10 +426,17 @@ async def test_if_fires_on_state_change_legacy( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES["battery"].unique_id, + device_id=device_entry.id, ) assert await async_setup_component( @@ -424,7 +448,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_off", }, diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index fc870f2bfe3816..31d90a6e93d81b 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -42,7 +42,10 @@ from tests.common import async_fire_time_changed, load_fixture -async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.parametrize("name_2", [None, "w"]) +async def test_remote_scanner( + hass: HomeAssistant, enable_bluetooth: None, name_2: str | None +) -> None: """Test the remote scanner base class merges advertisement_data.""" manager = _get_manager() @@ -61,12 +64,25 @@ async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> No ) switchbot_device_2 = generate_ble_device( "44:44:33:11:23:45", - "w", + name_2, {}, rssi=-100, ) switchbot_device_adv_2 = generate_advertisement_data( - local_name="wohand", + local_name=name_2, + service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], + service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, + manufacturer_data={1: b"\x01", 2: b"\x02"}, + rssi=-100, + ) + switchbot_device_3 = generate_ble_device( + "44:44:33:11:23:45", + "wohandlonger", + {}, + rssi=-100, + ) + switchbot_device_adv_3 = generate_advertisement_data( + local_name="wohandlonger", service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x01", 2: b"\x02"}, @@ -125,6 +141,15 @@ def inject_advertisement( "00000001-0000-1000-8000-00805f9b34fb", } + # The longer name should be used + scanner.inject_advertisement(switchbot_device_3, switchbot_device_adv_3) + assert discovered_device.name == switchbot_device_3.name + + # Inject the shorter name / None again to make + # sure we always keep the longer name + scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_2) + assert discovered_device.name == switchbot_device_3.name + cancel() unsetup() diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 9e3f954a0c5c76..8cc76e01d8c13e 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -1208,6 +1208,7 @@ def _async_generate_mock_data( assert entity_one.unique_id == "aa:bb:cc:dd:ee:ff-temperature" assert entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "name": "Generic", } assert entity_one.entity_key == PassiveBluetoothEntityKey( @@ -1396,6 +1397,7 @@ def _mock_update_method( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1412,6 +1414,7 @@ def _mock_update_method( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1556,6 +1559,7 @@ def _mock_update_method( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1572,6 +1576,7 @@ def _mock_update_method( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1636,6 +1641,7 @@ def _mock_update_method( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1652,6 +1658,7 @@ def _mock_update_method( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1730,6 +1737,7 @@ def _mock_update_method( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1746,6 +1754,7 @@ def _mock_update_method( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", diff --git a/tests/components/button/test_device_action.py b/tests/components/button/test_device_action.py index 43e2d3f855f863..3fefa58072473a 100644 --- a/tests/components/button/test_device_action.py +++ b/tests/components/button/test_device_action.py @@ -95,9 +95,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for press action.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -111,7 +123,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "press", }, @@ -131,10 +143,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for press action.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -148,7 +170,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "press", }, diff --git a/tests/components/button/test_device_trigger.py b/tests/components/button/test_device_trigger.py index 32f10044206799..e231fc3ae197bc 100644 --- a/tests/components/button/test_device_trigger.py +++ b/tests/components/button/test_device_trigger.py @@ -105,10 +105,21 @@ async def test_get_triggers_hidden_auxiliary( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, "unknown") @@ -121,7 +132,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "pressed", }, @@ -154,10 +165,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, "unknown") @@ -170,7 +192,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "pressed", }, diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index f64cf6994513cd..b7c9ed32244252 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -1,9 +1,13 @@ """The tests for the webdav calendar component.""" +from collections.abc import Awaitable, Callable import datetime from http import HTTPStatus +from typing import Any from unittest.mock import MagicMock, Mock, patch from caldav.objects import Event +from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.const import STATE_OFF, STATE_ON @@ -11,7 +15,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -DEVICE_DATA = {"name": "Private Calendar", "device_id": "Private Calendar"} +from tests.typing import ClientSessionGenerator EVENTS = [ """BEGIN:VCALENDAR @@ -288,64 +292,64 @@ "url": "http://test.local", "custom_calendars": [], } +UTC = "UTC" +AMERICA_NEW_YORK = "America/New_York" +ASIA_BAGHDAD = "Asia/Baghdad" - -@pytest.fixture -def set_tz(request): - """Set the default TZ to the one requested.""" - return request.getfixturevalue(request.param) +TEST_ENTITY = "calendar.example" +CALENDAR_NAME = "Example" -@pytest.fixture -def utc(hass): - """Set the default TZ to UTC.""" - hass.config.set_time_zone("UTC") +@pytest.fixture(name="tz") +def mock_tz() -> str | None: + """Fixture to specify the Home Assistant timezone to use during the test.""" + return None -@pytest.fixture -def new_york(hass): - """Set the default TZ to America/New_York.""" - hass.config.set_time_zone("America/New_York") - - -@pytest.fixture -def baghdad(hass): - """Set the default TZ to Asia/Baghdad.""" - hass.config.set_time_zone("Asia/Baghdad") +@pytest.fixture(autouse=True) +def set_tz(hass: HomeAssistant, tz: str | None) -> None: + """Fixture to set the default TZ to the one requested.""" + if tz is not None: + hass.config.set_time_zone(tz) @pytest.fixture(autouse=True) -def mock_http(hass): +def mock_http(hass: HomeAssistant) -> None: """Mock the http component.""" hass.http = Mock() -@pytest.fixture -def mock_dav_client(): - """Mock the dav client.""" - patch_dav_client = patch( - "caldav.DAVClient", return_value=_mocked_dav_client("First", "Second") - ) - with patch_dav_client as dav_client: - yield dav_client +@pytest.fixture(name="calendar_names") +def mock_calendar_names() -> list[str]: + """Fixture to provide calendars returned by CalDAV client.""" + return ["Example"] -@pytest.fixture(name="calendar") -def mock_private_cal(): - """Mock a private calendar.""" - _calendar = _mock_calendar("Private") - calendars = [_calendar] - client = _mocked_dav_client(calendars=calendars) - patch_dav_client = patch("caldav.DAVClient", return_value=client) - with patch_dav_client: - yield _calendar +@pytest.fixture(name="calendars") +def mock_calendars(calendar_names: list[str]) -> list[Mock]: + """Fixture to provide calendars returned by CalDAV client.""" + return [_mock_calendar(name) for name in calendar_names] + + +@pytest.fixture(name="dav_client", autouse=True) +def mock_dav_client(calendars: list[Mock]) -> Mock: + """Fixture to mock the DAVClient.""" + with patch( + "homeassistant.components.caldav.calendar.caldav.DAVClient" + ) as mock_client: + mock_client.return_value.principal.return_value.calendars.return_value = ( + calendars + ) + yield mock_client @pytest.fixture -def get_api_events(hass_client): +def get_api_events( + hass_client: ClientSessionGenerator, +) -> Callable[[str], Awaitable[dict[str, Any]]]: """Fixture to return events for a specific calendar using the API.""" - async def api_call(entity_id): + async def api_call(entity_id: str) -> dict[str, Any]: client = await hass_client() response = await client.get( # The start/end times are arbitrary since they are ignored by `_mock_calendar` @@ -358,24 +362,12 @@ async def api_call(entity_id): return api_call -def _local_datetime(hours, minutes): +def _local_datetime(hours: int, minutes: int) -> datetime.datetime: """Build a datetime object for testing in the correct timezone.""" return dt_util.as_local(datetime.datetime(2017, 11, 27, hours, minutes, 0)) -def _mocked_dav_client(*names, calendars=None): - """Mock requests.get invocations.""" - if calendars is None: - calendars = [_mock_calendar(name) for name in names] - principal = Mock() - principal.calendars = MagicMock(return_value=calendars) - - client = Mock() - client.principal = MagicMock(return_value=principal) - return client - - -def _mock_calendar(name, supported_components=None): +def _mock_calendar(name: str, supported_components: list[str] | None = None) -> Mock: calendar = Mock() events = [] for idx, event in enumerate(EVENTS): @@ -388,77 +380,78 @@ def _mock_calendar(name, supported_components=None): return calendar -async def test_setup_component(hass: HomeAssistant, mock_dav_client) -> None: - """Test setup component with calendars.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.first") - assert state.name == "First" - state = hass.states.get("calendar.second") - assert state.name == "Second" +@pytest.fixture(name="config") +def mock_config() -> dict[str, Any]: + """Fixture to provide calendar configuration.yaml.""" + return {} -async def test_setup_component_with_no_calendar_matching( - hass: HomeAssistant, mock_dav_client -) -> None: - """Test setup component with wrong calendar.""" - config = dict(CALDAV_CONFIG) - config["calendars"] = ["none"] +@pytest.fixture(name="setup_platform_cb") +async def mock_setup_platform_cb( + hass: HomeAssistant, config: dict[str, Any] +) -> Callable[[], Awaitable[None]]: + """Fixture that returns a function to setup the calendar platform.""" - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() + async def _run() -> None: + assert await async_setup_component( + hass, "calendar", {"calendar": {**CALDAV_CONFIG, **config}} + ) + await hass.async_block_till_done() - all_calendar_states = hass.states.async_entity_ids("calendar") - assert not all_calendar_states + return _run -async def test_setup_component_with_a_calendar_match( - hass: HomeAssistant, mock_dav_client +@pytest.mark.parametrize( + ("calendar_names", "config", "expected_entities"), + [ + (["First", "Second"], {}, ["calendar.first", "calendar.second"]), + ( + ["First", "Second"], + {"calendars": ["none"]}, + [], + ), + (["First", "Second"], {"calendars": ["Second"]}, ["calendar.second"]), + ( + ["First", "Second"], + { + "custom_calendars": { + "name": "HomeOffice", + "calendar": "Second", + "search": "HomeOffice", + }, + }, + ["calendar.second_homeoffice"], + ), + ], + ids=("config", "no_match", "match", "custom"), +) +async def test_setup_component_config( + hass: HomeAssistant, + config: dict[str, Any], + expected_entities: list[str], + setup_platform_cb: Callable[[], Awaitable[None]], ) -> None: - """Test setup component with right calendar.""" - config = dict(CALDAV_CONFIG) - config["calendars"] = ["Second"] - - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() + """Test setup component with wrong calendar.""" + await setup_platform_cb() - all_calendar_states = hass.states.async_entity_ids("calendar") - assert len(all_calendar_states) == 1 - state = hass.states.get("calendar.second") - assert state.name == "Second" + all_calendar_entities = hass.states.async_entity_ids("calendar") + assert all_calendar_entities == expected_entities -async def test_setup_component_with_one_custom_calendar( - hass: HomeAssistant, mock_dav_client +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(17, 45)) +async def test_ongoing_event( + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: - """Test setup component with custom calendars.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "HomeOffice", "calendar": "Second", "search": "HomeOffice"} - ] - - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - all_calendar_states = hass.states.async_entity_ids("calendar") - assert len(all_calendar_states) == 1 - state = hass.states.get("calendar.second_homeoffice") - assert state.name == "HomeOffice" - - -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(17, 45)) -async def test_ongoing_event(mock_now, hass: HomeAssistant, calendar, set_tz) -> None: """Test that the ongoing event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a normal event", "all_day": False, "offset_reached": False, @@ -469,20 +462,20 @@ async def test_ongoing_event(mock_now, hass: HomeAssistant, calendar, set_tz) -> } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(17, 30)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(17, 30)) async def test_just_ended_event( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the next ongoing event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a normal event", "all_day": False, "offset_reached": False, @@ -493,20 +486,20 @@ async def test_just_ended_event( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(17, 00)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(17, 00)) async def test_ongoing_event_different_tz( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the ongoing event with another timezone is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "Enjoy the sun", "all_day": False, "offset_reached": False, @@ -517,20 +510,20 @@ async def test_ongoing_event_different_tz( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(19, 10)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(19, 10)) async def test_ongoing_floating_event_returned( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that floating events without timezones work.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a floating Event", "all_day": False, "offset_reached": False, @@ -541,20 +534,20 @@ async def test_ongoing_floating_event_returned( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(8, 30)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(8, 30)) async def test_ongoing_event_with_offset( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the offset is taken into account.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is an offset event", "all_day": False, "offset_reached": True, @@ -565,23 +558,36 @@ async def test_ongoing_event_with_offset( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(12, 00)) -async def test_matching_filter(mock_now, hass: HomeAssistant, calendar, set_tz) -> None: +@pytest.mark.parametrize( + ("tz", "config"), + [ + ( + UTC, + { + "custom_calendars": [ + { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": "This is a normal event", + } + ] + }, + ) + ], +) +@freeze_time(_local_datetime(12, 00)) +async def test_matching_filter( + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] +) -> None: """Test that the matching event is returned.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": "This is a normal event"} - ] + await setup_platform_cb() - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a normal event", "all_day": False, "offset_reached": False, @@ -592,25 +598,37 @@ async def test_matching_filter(mock_now, hass: HomeAssistant, calendar, set_tz) } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(12, 00)) +@pytest.mark.parametrize( + ("tz", "config"), + [ + ( + UTC, + { + "custom_calendars": [ + { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": r".*rainy", + } + ] + }, + ) + ], +) +@freeze_time(_local_datetime(12, 00)) async def test_matching_filter_real_regexp( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the event matching the regexp is returned.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": r".*rainy"} - ] - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a normal event", "all_day": False, "offset_reached": False, @@ -621,138 +639,137 @@ async def test_matching_filter_real_regexp( } -@patch("homeassistant.util.dt.now", return_value=_local_datetime(20, 00)) +@pytest.mark.parametrize( + "config", + [ + { + "custom_calendars": [ + { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": "This is a normal event", + } + ] + } + ], +) +@freeze_time(_local_datetime(20, 00)) async def test_filter_matching_past_event( - mock_now, hass: HomeAssistant, calendar + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the matching past event is not returned.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": "This is a normal event"} - ] - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME assert state.state == "off" + assert dict(state.attributes) == { + "friendly_name": CALENDAR_NAME, + "offset_reached": False, + } -@patch("homeassistant.util.dt.now", return_value=_local_datetime(12, 00)) +@pytest.mark.parametrize( + "config", + [ + { + "custom_calendars": [ + { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": "This is a non-existing event", + } + ] + } + ], +) +@freeze_time(_local_datetime(12, 00)) async def test_no_result_with_filtering( - mock_now, hass: HomeAssistant, calendar + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that nothing is returned since nothing matches.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - { - "name": "Private", - "calendar": "Private", - "search": "This is a non-existing event", - } - ] + await setup_platform_cb() - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME assert state.state == "off" + assert dict(state.attributes) == { + "friendly_name": CALENDAR_NAME, + "offset_reached": False, + } -async def _day_event_returned(hass, calendar, config, date_time): - with patch("homeassistant.util.dt.now", return_value=date_time): - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name - assert state.state == STATE_ON - assert dict(state.attributes) == { - "friendly_name": "Private", - "message": "This is an all day event", - "all_day": True, - "offset_reached": False, - "start_time": "2017-11-27 00:00:00", - "end_time": "2017-11-28 00:00:00", - "location": "Hamburg", - "description": "What a beautiful day", - } - - -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_all_day_event_returned_early( - hass: HomeAssistant, calendar, set_tz +@pytest.mark.parametrize( + ("tz", "target_datetime"), + [ + # Early + (UTC, datetime.datetime(2017, 11, 27, 0, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2017, 11, 27, 0, 30)), + (ASIA_BAGHDAD, datetime.datetime(2017, 11, 27, 0, 30)), + # Mid + (UTC, datetime.datetime(2017, 11, 27, 12, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2017, 11, 27, 12, 30)), + (ASIA_BAGHDAD, datetime.datetime(2017, 11, 27, 12, 30)), + # Late + (UTC, datetime.datetime(2017, 11, 27, 23, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2017, 11, 27, 23, 30)), + (ASIA_BAGHDAD, datetime.datetime(2017, 11, 27, 23, 30)), + ], +) +async def test_all_day_event( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + target_datetime: datetime.datetime, ) -> None: """Test that the event lasting the whole day is returned, if it's early in the local day.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _day_event_returned( + freezer.move_to(target_datetime.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)) + assert await async_setup_component( hass, - calendar, - config, - datetime.datetime(2017, 11, 27, 0, 30).replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE - ), + "calendar", + { + "calendar": { + **CALDAV_CONFIG, + "custom_calendars": [ + {"name": CALENDAR_NAME, "calendar": CALENDAR_NAME, "search": ".*"} + ], + } + }, ) + await hass.async_block_till_done() - -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_all_day_event_returned_mid( - hass: HomeAssistant, calendar, set_tz -) -> None: - """Test that the event lasting the whole day is returned, if it's in the middle of the local day.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _day_event_returned( - hass, - calendar, - config, - datetime.datetime(2017, 11, 27, 12, 30).replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE - ), - ) + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME + assert state.state == STATE_ON + assert dict(state.attributes) == { + "friendly_name": CALENDAR_NAME, + "message": "This is an all day event", + "all_day": True, + "offset_reached": False, + "start_time": "2017-11-27 00:00:00", + "end_time": "2017-11-28 00:00:00", + "location": "Hamburg", + "description": "What a beautiful day", + } -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_all_day_event_returned_late( - hass: HomeAssistant, calendar, set_tz +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(21, 45)) +async def test_event_rrule( + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: - """Test that the event lasting the whole day is returned, if it's late in the local day.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _day_event_returned( - hass, - calendar, - config, - datetime.datetime(2017, 11, 27, 23, 30).replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE - ), - ) - - -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(21, 45)) -async def test_event_rrule(mock_now, hass: HomeAssistant, calendar, set_tz) -> None: """Test that the future recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a recurring event", "all_day": False, "offset_reached": False, @@ -763,20 +780,20 @@ async def test_event_rrule(mock_now, hass: HomeAssistant, calendar, set_tz) -> N } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(22, 15)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(22, 15)) async def test_event_rrule_ongoing( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the current recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a recurring event", "all_day": False, "offset_reached": False, @@ -787,20 +804,20 @@ async def test_event_rrule_ongoing( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(22, 45)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(22, 45)) async def test_event_rrule_duration( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the future recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a recurring event with a duration", "all_day": False, "offset_reached": False, @@ -811,20 +828,20 @@ async def test_event_rrule_duration( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(23, 15)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(23, 15)) async def test_event_rrule_duration_ongoing( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the ongoing recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a recurring event with a duration", "all_day": False, "offset_reached": False, @@ -835,20 +852,20 @@ async def test_event_rrule_duration_ongoing( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch("homeassistant.util.dt.now", return_value=_local_datetime(23, 37)) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(_local_datetime(23, 37)) async def test_event_rrule_endless( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is a recurring event that never ends", "all_day": False, "offset_reached": False, @@ -859,95 +876,76 @@ async def test_event_rrule_endless( } -async def _event_rrule_all_day(hass, calendar, config, date_time): - with patch("homeassistant.util.dt.now", return_value=date_time): - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private_private") - assert state.name == calendar.name - assert state.state == STATE_ON - assert dict(state.attributes) == { - "friendly_name": "Private", - "message": "This is a recurring all day event", - "all_day": True, - "offset_reached": False, - "start_time": "2016-12-01 00:00:00", - "end_time": "2016-12-02 00:00:00", - "location": "Hamburg", - "description": "Groundhog Day", - } - - -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_event_rrule_all_day_early(hass: HomeAssistant, calendar, set_tz) -> None: +@pytest.mark.parametrize( + ("tz", "target_datetime"), + [ + # Early + (UTC, datetime.datetime(2016, 12, 1, 0, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2016, 12, 1, 0, 30)), + (ASIA_BAGHDAD, datetime.datetime(2016, 12, 1, 0, 30)), + # Mid + (UTC, datetime.datetime(2016, 12, 1, 17, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2016, 12, 1, 17, 30)), + (ASIA_BAGHDAD, datetime.datetime(2016, 12, 1, 17, 30)), + # Late + (UTC, datetime.datetime(2016, 12, 1, 23, 30)), + (AMERICA_NEW_YORK, datetime.datetime(2016, 12, 1, 23, 30)), + (ASIA_BAGHDAD, datetime.datetime(2016, 12, 1, 23, 30)), + ], +) +async def test_event_rrule_all_day_early( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + target_datetime: datetime.datetime, +) -> None: """Test that the recurring all day event is returned early in the local day, and not on the first occurrence.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _event_rrule_all_day( - hass, - calendar, - config, - datetime.datetime(2016, 12, 1, 0, 30).replace(tzinfo=dt_util.DEFAULT_TIME_ZONE), - ) - - -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_event_rrule_all_day_mid(hass: HomeAssistant, calendar, set_tz) -> None: - """Test that the recurring all day event is returned in the middle of the local day, and not on the first occurrence.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _event_rrule_all_day( + freezer.move_to(target_datetime.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)) + assert await async_setup_component( hass, - calendar, - config, - datetime.datetime(2016, 12, 1, 17, 30).replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE - ), + "calendar", + { + "calendar": { + **CALDAV_CONFIG, + "custom_calendars": { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": ".*", + }, + }, + }, ) + await hass.async_block_till_done() - -@pytest.mark.parametrize("set_tz", ["utc", "new_york", "baghdad"], indirect=True) -async def test_event_rrule_all_day_late(hass: HomeAssistant, calendar, set_tz) -> None: - """Test that the recurring all day event is returned late in the local day, and not on the first occurrence.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": ".*"} - ] - - await _event_rrule_all_day( - hass, - calendar, - config, - datetime.datetime(2016, 12, 1, 23, 30).replace( - tzinfo=dt_util.DEFAULT_TIME_ZONE - ), - ) + state = hass.states.get("calendar.example_example") + assert state + assert state.name == CALENDAR_NAME + assert state.state == STATE_ON + assert dict(state.attributes) == { + "friendly_name": CALENDAR_NAME, + "message": "This is a recurring all day event", + "all_day": True, + "offset_reached": False, + "start_time": "2016-12-01 00:00:00", + "end_time": "2016-12-02 00:00:00", + "location": "Hamburg", + "description": "Groundhog Day", + } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch( - "homeassistant.util.dt.now", - return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 0, 15)), -) +@pytest.mark.parametrize("tz", [UTC]) +@freeze_time(dt_util.as_local(datetime.datetime(2015, 11, 27, 0, 15))) async def test_event_rrule_hourly_on_first( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is an hourly recurring event", "all_day": False, "offset_reached": False, @@ -958,23 +956,20 @@ async def test_event_rrule_hourly_on_first( } -@pytest.mark.parametrize("set_tz", ["utc"], indirect=True) -@patch( - "homeassistant.util.dt.now", - return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 11, 15)), -) +@pytest.mark.parametrize("tz", ["UTC"]) +@freeze_time(dt_util.as_local(datetime.datetime(2015, 11, 27, 11, 15))) async def test_event_rrule_hourly_on_last( - mock_now, hass: HomeAssistant, calendar, set_tz + hass: HomeAssistant, setup_platform_cb: Callable[[], Awaitable[None]] ) -> None: """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_ON assert dict(state.attributes) == { - "friendly_name": "Private", + "friendly_name": CALENDAR_NAME, "message": "This is an hourly recurring event", "all_day": False, "offset_reached": False, @@ -985,77 +980,67 @@ async def test_event_rrule_hourly_on_last( } -@patch( - "homeassistant.util.dt.now", - return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 0, 45)), +@pytest.mark.parametrize( + ("target_datetime"), + [ + datetime.datetime(2015, 11, 27, 0, 45), + datetime.datetime(2015, 11, 27, 11, 45), + datetime.datetime(2015, 11, 27, 12, 15), + ], ) -async def test_event_rrule_hourly_off_first( - mock_now, hass: HomeAssistant, calendar -) -> None: - """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private") - assert state.name == calendar.name - assert state.state == STATE_OFF - - -@patch( - "homeassistant.util.dt.now", - return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 11, 45)), -) -async def test_event_rrule_hourly_off_last( - mock_now, hass: HomeAssistant, calendar +async def test_event_rrule_hourly( + hass: HomeAssistant, + setup_platform_cb: Callable[[], Awaitable[None]], + freezer: FrozenDateTimeFactory, + target_datetime: datetime.datetime, ) -> None: """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + freezer.move_to(dt_util.as_local(target_datetime)) + await setup_platform_cb() - state = hass.states.get("calendar.private") - assert state.name == calendar.name + state = hass.states.get(TEST_ENTITY) + assert state + assert state.name == CALENDAR_NAME assert state.state == STATE_OFF -@patch( - "homeassistant.util.dt.now", - return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 12, 15)), -) -async def test_event_rrule_hourly_ended( - mock_now, hass: HomeAssistant, calendar +async def test_get_events( + hass: HomeAssistant, + get_api_events: Callable[[str], Awaitable[dict[str, Any]]], + setup_platform_cb: Callable[[], Awaitable[None]], + calendars: list[Mock], ) -> None: - """Test that the endless recurring event is returned.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() - - state = hass.states.get("calendar.private") - assert state.name == calendar.name - assert state.state == STATE_OFF - - -async def test_get_events(hass: HomeAssistant, calendar, get_api_events) -> None: """Test that all events are returned on API.""" - assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) - await hass.async_block_till_done() + await setup_platform_cb() - events = await get_api_events("calendar.private") + events = await get_api_events(TEST_ENTITY) assert len(events) == 18 - assert calendar.call + assert calendars[0].call +@pytest.mark.parametrize( + "config", + [ + { + "custom_calendars": [ + { + "name": CALENDAR_NAME, + "calendar": CALENDAR_NAME, + "search": "This is a normal event", + } + ] + } + ], +) async def test_get_events_custom_calendars( - hass: HomeAssistant, calendar, get_api_events + hass: HomeAssistant, + get_api_events: Callable[[str], Awaitable[dict[str, Any]]], + setup_platform_cb: Callable[[], Awaitable[None]], ) -> None: """Test that only searched events are returned on API.""" - config = dict(CALDAV_CONFIG) - config["custom_calendars"] = [ - {"name": "Private", "calendar": "Private", "search": "This is a normal event"} - ] - - assert await async_setup_component(hass, "calendar", {"calendar": config}) - await hass.async_block_till_done() + await setup_platform_cb() - events = await get_api_events("calendar.private_private") + events = await get_api_events("calendar.example_example") assert events == [ { "end": {"dateTime": "2017-11-27T10:00:00-08:00"}, @@ -1070,31 +1055,34 @@ async def test_get_events_custom_calendars( ] +@pytest.mark.parametrize( + ("calendars"), + [ + [ + _mock_calendar("Calendar 1", supported_components=["VEVENT"]), + _mock_calendar("Calendar 2", supported_components=["VEVENT", "VJOURNAL"]), + _mock_calendar("Calendar 3", supported_components=["VTODO"]), + # Fallback to allow when no components are supported to be conservative + _mock_calendar("Calendar 4", supported_components=[]), + ] + ], +) async def test_calendar_components( hass: HomeAssistant, + dav_client: Mock, ) -> None: """Test that only calendars that support events are created.""" - calendars = [ - _mock_calendar("Calendar 1", supported_components=["VEVENT"]), - _mock_calendar("Calendar 2", supported_components=["VEVENT", "VJOURNAL"]), - _mock_calendar("Calendar 3", supported_components=["VTODO"]), - # Fallback to allow when no components are supported to be conservative - _mock_calendar("Calendar 4", supported_components=[]), - ] - with patch( - "homeassistant.components.caldav.calendar.caldav.DAVClient", - return_value=_mocked_dav_client(calendars=calendars), - ): - assert await async_setup_component( - hass, "calendar", {"calendar": CALDAV_CONFIG} - ) - await hass.async_block_till_done() + + assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG}) + await hass.async_block_till_done() state = hass.states.get("calendar.calendar_1") + assert state assert state.name == "Calendar 1" assert state.state == STATE_OFF state = hass.states.get("calendar.calendar_2") + assert state assert state.name == "Calendar 2" assert state.state == STATE_OFF @@ -1103,5 +1091,6 @@ async def test_calendar_components( assert not state state = hass.states.get("calendar.calendar_4") + assert state assert state.name == "Calendar 4" assert state.state == STATE_OFF diff --git a/tests/components/climate/test_device_action.py b/tests/components/climate/test_device_action.py index f56f499c9357b5..8ef73ed4e51440 100644 --- a/tests/components/climate/test_device_action.py +++ b/tests/components/climate/test_device_action.py @@ -143,9 +143,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -168,7 +180,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_hvac_mode", "hvac_mode": HVACMode.OFF, @@ -181,7 +193,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_preset_mode", "preset_mode": const.PRESET_AWAY, @@ -219,10 +231,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -245,7 +267,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "set_hvac_mode", "hvac_mode": HVACMode.OFF, diff --git a/tests/components/climate/test_device_condition.py b/tests/components/climate/test_device_condition.py index 33df78bf3470f2..4dc365e59ee455 100644 --- a/tests/components/climate/test_device_condition.py +++ b/tests/components/climate/test_device_condition.py @@ -147,10 +147,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -163,7 +174,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_hvac_mode", "hvac_mode": "cool", @@ -185,7 +196,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_preset_mode", "preset_mode": "away", @@ -257,10 +268,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -273,7 +295,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_hvac_mode", "hvac_mode": "cool", diff --git a/tests/components/climate/test_device_trigger.py b/tests/components/climate/test_device_trigger.py index c600e4004e8baf..59efb66ff65108 100644 --- a/tests/components/climate/test_device_trigger.py +++ b/tests/components/climate/test_device_trigger.py @@ -147,10 +147,21 @@ async def test_get_triggers_hidden_auxiliary( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -171,7 +182,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "hvac_mode_changed", "to": HVACMode.AUTO, @@ -185,7 +196,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_temperature_changed", "above": 20, @@ -199,7 +210,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_humidity_changed", "below": 10, @@ -257,10 +268,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -281,7 +303,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "hvac_mode_changed", "to": HVACMode.AUTO, diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py index 0cc6716bd3c0df..c476f78702e4e9 100644 --- a/tests/components/cover/test_device_action.py +++ b/tests/components/cover/test_device_action.py @@ -351,11 +351,20 @@ async def test_get_action_capabilities_set_tilt_pos( async def test_action( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: """Test for cover actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -366,7 +375,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_event_open"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "open", }, @@ -375,7 +384,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_event_close"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "close", }, @@ -384,7 +393,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_event_stop"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "stop", }, @@ -429,11 +438,20 @@ async def test_action( async def test_action_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: """Test for cover actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -444,7 +462,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_event_open"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "open", }, @@ -467,11 +485,20 @@ async def test_action_legacy( async def test_action_tilt( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: """Test for cover tilt actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -482,7 +509,7 @@ async def test_action_tilt( "trigger": {"platform": "event", "event_type": "test_event_open"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "open_tilt", }, @@ -491,7 +518,7 @@ async def test_action_tilt( "trigger": {"platform": "event", "event_type": "test_event_close"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "close_tilt", }, @@ -529,11 +556,20 @@ async def test_action_tilt( async def test_action_set_position( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: """Test for cover set position actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -547,7 +583,7 @@ async def test_action_set_position( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_position", "position": 25, @@ -560,7 +596,7 @@ async def test_action_set_position( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_tilt_position", "position": 75, diff --git a/tests/components/cover/test_device_condition.py b/tests/components/cover/test_device_condition.py index bfde3a0b514855..2dcc719f35f331 100644 --- a/tests/components/cover/test_device_condition.py +++ b/tests/components/cover/test_device_condition.py @@ -355,10 +355,21 @@ async def test_get_condition_capabilities_set_tilt_pos( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OPEN) @@ -373,7 +384,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_open", } @@ -395,7 +406,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_closed", } @@ -417,7 +428,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_opening", } @@ -439,7 +450,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_closing", } @@ -487,10 +498,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OPEN) @@ -505,7 +527,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_open", } @@ -533,6 +555,7 @@ async def test_if_state_legacy( async def test_if_position( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, caplog: pytest.LogCaptureFixture, @@ -545,7 +568,14 @@ async def test_if_position( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(ent.entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -559,7 +589,7 @@ async def test_if_position( "conditions": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_position", "above": 45, @@ -593,7 +623,7 @@ async def test_if_position( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_position", "below": 90, @@ -616,7 +646,7 @@ async def test_if_position( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_position", "above": 45, @@ -686,6 +716,7 @@ async def test_if_position( async def test_if_tilt_position( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, caplog: pytest.LogCaptureFixture, @@ -698,7 +729,14 @@ async def test_if_tilt_position( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(ent.entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -712,7 +750,7 @@ async def test_if_tilt_position( "conditions": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_tilt_position", "above": 45, @@ -746,7 +784,7 @@ async def test_if_tilt_position( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_tilt_position", "below": 90, @@ -769,7 +807,7 @@ async def test_if_tilt_position( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_tilt_position", "above": 45, diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index fc82bbd14993b2..e464ff87c3f251 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -378,10 +378,21 @@ async def test_get_trigger_capabilities_set_tilt_pos( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for state triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_CLOSED) @@ -394,7 +405,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "opened", }, @@ -416,7 +427,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "closed", }, @@ -438,7 +449,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "opening", }, @@ -460,7 +471,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "closing", }, @@ -520,10 +531,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for state triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_CLOSED) @@ -536,7 +558,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "opened", }, @@ -569,10 +591,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_CLOSED) @@ -585,7 +618,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "opened", "for": {"seconds": 5}, @@ -627,6 +660,7 @@ async def test_if_fires_on_state_change_with_for( async def test_if_fires_on_position( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -638,7 +672,14 @@ async def test_if_fires_on_position( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(ent.entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -650,7 +691,7 @@ async def test_if_fires_on_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "position", "above": 45, @@ -675,7 +716,7 @@ async def test_if_fires_on_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "position", "below": 90, @@ -700,7 +741,7 @@ async def test_if_fires_on_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "position", "above": 45, @@ -773,6 +814,7 @@ async def test_if_fires_on_position( async def test_if_fires_on_tilt_position( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -784,7 +826,14 @@ async def test_if_fires_on_tilt_position( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(ent.entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -796,7 +845,7 @@ async def test_if_fires_on_tilt_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "tilt_position", "above": 45, @@ -821,7 +870,7 @@ async def test_if_fires_on_tilt_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "tilt_position", "below": 90, @@ -846,7 +895,7 @@ async def test_if_fires_on_tilt_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "tilt_position", "above": 45, diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index f6c4452dac692d..357371e4853b8f 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -185,7 +185,25 @@ async def test_no_lights_or_groups( "entity_id": "light.lidl_xmas_light", "state": STATE_ON, "attributes": { - ATTR_EFFECT_LIST: [EFFECT_COLORLOOP], + ATTR_EFFECT_LIST: [ + EFFECT_COLORLOOP, + "carnival", + "collide", + "fading", + "fireworks", + "flag", + "glow", + "rainbow", + "snake", + "snow", + "sparkles", + "steady", + "strobe", + "twinkle", + "updown", + "vintage", + "waves", + ], ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_COLOR_MODE: ColorMode.HS, ATTR_BRIGHTNESS: 25, diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py index ff6274af1b575f..b1bd77a74a1469 100644 --- a/tests/components/demo/test_media_player.py +++ b/tests/components/demo/test_media_player.py @@ -16,7 +16,7 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import DATA_CLIENTSESSION +from homeassistant.helpers.aiohttp_client import DATA_CLIENTSESSION, _make_key from homeassistant.setup import async_setup_component from tests.typing import ClientSessionGenerator @@ -483,7 +483,7 @@ async def get(self, url): def detach(self): """Test websession detach.""" - hass.data[DATA_CLIENTSESSION] = MockWebsession() + hass.data[DATA_CLIENTSESSION] = {_make_key(): MockWebsession()} state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_PLAYING diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 74150af67aed67..457b7ccbf9b28a 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1,6 +1,7 @@ """The test for light device automation.""" from unittest.mock import AsyncMock, Mock, patch +import attr import pytest from pytest_unordered import unordered import voluptuous as vol @@ -13,7 +14,7 @@ toggle_entity, ) from homeassistant.components.websocket_api.const import TYPE_RESULT -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType @@ -31,6 +32,13 @@ from tests.typing import WebSocketGenerator +@attr.s(frozen=True) +class MockDeviceEntry(dr.DeviceEntry): + """Device Registry Entry with fixed UUID.""" + + id: str = attr.ib(default="very_unique") + + @pytest.fixture(autouse=True, name="stub_blueprint_populate") def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" @@ -908,7 +916,11 @@ async def test_automation_with_non_existing_integration( async def test_automation_with_device_action( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, fake_integration + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + fake_integration, ) -> None: """Test automation with a device action.""" @@ -916,6 +928,16 @@ async def test_automation_with_device_action( module = module_cache["fake_integration.device_action"] module.async_call_action_from_config = AsyncMock() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "fake_integration", "test", "5678", device_id=device_entry.id + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -924,9 +946,9 @@ async def test_automation_with_device_action( "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, "action": { - "device_id": "", + "device_id": device_entry.id, "domain": "fake_integration", - "entity_id": "blah.blah", + "entity_id": entity_entry.id, "type": "turn_on", }, } @@ -999,7 +1021,11 @@ async def test_automation_with_integration_without_device_action( async def test_automation_with_device_condition( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, fake_integration + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + fake_integration, ) -> None: """Test automation with a device condition.""" @@ -1007,6 +1033,16 @@ async def test_automation_with_device_condition( module = module_cache["fake_integration.device_condition"] module.async_condition_from_config = Mock() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "fake_integration", "test", "5678", device_id=device_entry.id + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1016,9 +1052,9 @@ async def test_automation_with_device_condition( "trigger": {"platform": "event", "event_type": "test_event1"}, "condition": { "condition": "device", - "device_id": "none", + "device_id": device_entry.id, "domain": "fake_integration", - "entity_id": "blah.blah", + "entity_id": entity_entry.id, "type": "is_on", }, "action": {"service": "test.automation", "entity_id": "hello.world"}, @@ -1098,7 +1134,11 @@ async def test_automation_with_integration_without_device_condition( async def test_automation_with_device_trigger( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, fake_integration + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + fake_integration, ) -> None: """Test automation with a device trigger.""" @@ -1106,6 +1146,16 @@ async def test_automation_with_device_trigger( module = module_cache["fake_integration.device_trigger"] module.async_attach_trigger = AsyncMock() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "fake_integration", "test", "5678", device_id=device_entry.id + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1114,9 +1164,9 @@ async def test_automation_with_device_trigger( "alias": "hello", "trigger": { "platform": "device", - "device_id": "none", + "device_id": device_entry.id, "domain": "fake_integration", - "entity_id": "blah.blah", + "entity_id": entity_entry.id, "type": "turned_off", }, "action": {"service": "test.automation", "entity_id": "hello.world"}, @@ -1198,10 +1248,60 @@ async def test_automation_with_integration_without_device_trigger( ) +BAD_AUTOMATIONS = [ + ( + {"device_id": "very_unique", "domain": "light"}, + "required key not provided @ data['entity_id']", + ), + ( + {"device_id": "wrong", "domain": "light"}, + "Unknown device 'wrong'", + ), + ( + {"device_id": "wrong"}, + "required key not provided @ data{path}['domain']", + ), + ( + {"device_id": "wrong", "domain": "light"}, + "Unknown device 'wrong'", + ), + ( + {"device_id": "very_unique", "domain": "light"}, + "required key not provided @ data['entity_id']", + ), + ( + {"device_id": "very_unique", "domain": "light", "entity_id": "wrong"}, + "Unknown entity 'wrong'", + ), +] + +BAD_TRIGGERS = BAD_CONDITIONS = BAD_AUTOMATIONS + [ + ( + {"domain": "light"}, + "required key not provided @ data{path}['device_id']", + ) +] + + +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("action", "expected_error"), BAD_AUTOMATIONS) async def test_automation_with_bad_action( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + action: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device action.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1209,37 +1309,33 @@ async def test_automation_with_bad_action( automation.DOMAIN: { "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, - "action": {"device_id": "", "domain": "light"}, + "action": action, } }, ) - assert "required key not provided" in caplog.text + assert expected_error.format(path="['action'][0]") in caplog.text +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("condition", "expected_error"), BAD_CONDITIONS) async def test_automation_with_bad_condition_action( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + condition: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device action.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "alias": "hello", - "trigger": {"platform": "event", "event_type": "test_event1"}, - "action": {"condition": "device", "device_id": "", "domain": "light"}, - } - }, + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - assert "required key not provided" in caplog.text - - -async def test_automation_with_bad_condition_missing_domain( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test automation with bad device condition.""" assert await async_setup_component( hass, automation.DOMAIN, @@ -1247,19 +1343,32 @@ async def test_automation_with_bad_condition_missing_domain( automation.DOMAIN: { "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, - "condition": {"condition": "device", "device_id": "hello.device"}, - "action": {"service": "test.automation", "entity_id": "hello.world"}, + "action": {"condition": "device"} | condition, } }, ) - assert "required key not provided @ data['condition'][0]['domain']" in caplog.text + assert expected_error.format(path="['action'][0]") in caplog.text +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("condition", "expected_error"), BAD_CONDITIONS) async def test_automation_with_bad_condition( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + condition: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device condition.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1267,13 +1376,13 @@ async def test_automation_with_bad_condition( automation.DOMAIN: { "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, - "condition": {"condition": "device", "domain": "light"}, + "condition": {"condition": "device"} | condition, "action": {"service": "test.automation", "entity_id": "hello.world"}, } }, ) - assert "required key not provided" in caplog.text + assert expected_error.format(path="['condition'][0]") in caplog.text @pytest.fixture @@ -1283,16 +1392,29 @@ def calls(hass): async def test_automation_with_sub_condition( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + calls, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test automation with device condition under and/or conditions.""" DOMAIN = "light" - platform = getattr(hass.components, f"test.{DOMAIN}") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - ent1, ent2, ent3 = platform.ENTITIES + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry1 = entity_registry.async_get_or_create( + "fake_integration", "test", "0001", device_id=device_entry.id + ) + entity_entry2 = entity_registry.async_get_or_create( + "fake_integration", "test", "0002", device_id=device_entry.id + ) + + hass.states.async_set(entity_entry1.entity_id, STATE_ON) + hass.states.async_set(entity_entry2.entity_id, STATE_OFF) assert await async_setup_component( hass, @@ -1308,15 +1430,15 @@ async def test_automation_with_sub_condition( { "condition": "device", "domain": DOMAIN, - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entity_entry1.id, "type": "is_on", }, { "condition": "device", "domain": DOMAIN, - "device_id": "", - "entity_id": ent2.entity_id, + "device_id": device_entry.id, + "entity_id": entity_entry2.id, "type": "is_on", }, ], @@ -1339,15 +1461,15 @@ async def test_automation_with_sub_condition( { "condition": "device", "domain": DOMAIN, - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entity_entry1.id, "type": "is_on", }, { "condition": "device", "domain": DOMAIN, - "device_id": "", - "entity_id": ent2.entity_id, + "device_id": device_entry.id, + "entity_id": entity_entry2.id, "type": "is_on", }, ], @@ -1365,8 +1487,8 @@ async def test_automation_with_sub_condition( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON - assert hass.states.get(ent2.entity_id).state == STATE_OFF + assert hass.states.get(entity_entry1.entity_id).state == STATE_ON + assert hass.states.get(entity_entry2.entity_id).state == STATE_OFF assert len(calls) == 0 hass.bus.async_fire("test_event1") @@ -1374,18 +1496,18 @@ async def test_automation_with_sub_condition( assert len(calls) == 1 assert calls[0].data["some"] == "or event - test_event1" - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entity_entry1.entity_id, STATE_OFF) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 1 - hass.states.async_set(ent2.entity_id, STATE_ON) + hass.states.async_set(entity_entry2.entity_id, STATE_ON) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 2 assert calls[1].data["some"] == "or event - test_event1" - hass.states.async_set(ent1.entity_id, STATE_ON) + hass.states.async_set(entity_entry1.entity_id, STATE_ON) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 4 @@ -1394,10 +1516,24 @@ async def test_automation_with_sub_condition( ) +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("condition", "expected_error"), BAD_CONDITIONS) async def test_automation_with_bad_sub_condition( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + condition: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device condition under and/or conditions.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1407,33 +1543,48 @@ async def test_automation_with_bad_sub_condition( "trigger": {"platform": "event", "event_type": "test_event1"}, "condition": { "condition": "and", - "conditions": [{"condition": "device", "domain": "light"}], + "conditions": [{"condition": "device"} | condition], }, "action": {"service": "test.automation", "entity_id": "hello.world"}, } }, ) - assert "required key not provided" in caplog.text + path = "['condition'][0]['conditions'][0]" + assert expected_error.format(path=path) in caplog.text +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("trigger", "expected_error"), BAD_TRIGGERS) async def test_automation_with_bad_trigger( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + trigger: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device trigger.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, { automation.DOMAIN: { "alias": "hello", - "trigger": {"platform": "device", "domain": "light"}, + "trigger": {"platform": "device"} | trigger, "action": {"service": "test.automation", "entity_id": "hello.world"}, } }, ) - assert "required key not provided" in caplog.text + assert expected_error.format(path="") in caplog.text async def test_websocket_device_not_found( diff --git a/tests/components/device_automation/test_toggle_entity.py b/tests/components/device_automation/test_toggle_entity.py index f02704cdc1351f..30c9e5b542e195 100644 --- a/tests/components/device_automation/test_toggle_entity.py +++ b/tests/components/device_automation/test_toggle_entity.py @@ -4,12 +4,13 @@ import pytest import homeassistant.components.automation as automation -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, async_mock_service +from tests.common import MockConfigEntry, async_fire_time_changed, async_mock_service @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -24,22 +25,27 @@ def calls(hass): async def test_if_fires_on_state_change( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing. This is a sanity test for the toggle entity device automation helper, this is tested by each integration too. """ - platform = getattr(hass.components, "test.switch") - - platform.init() - assert await async_setup_component( - hass, "switch", {"switch": {CONF_PLATFORM: "test"}} + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + "switch", "test", "5678", device_id=device_entry.id ) - await hass.async_block_till_done() - ent1, ent2, ent3 = platform.ENTITIES + hass.states.async_set(entry.entity_id, STATE_ON) assert await async_setup_component( hass, @@ -50,8 +56,8 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": "switch", - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entry.entity_id, "type": "turned_on", }, "action": { @@ -74,8 +80,8 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": "switch", - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entry.entity_id, "type": "turned_off", }, "action": { @@ -98,8 +104,8 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": "switch", - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entry.entity_id, "type": "changed_states", }, "action": { @@ -122,40 +128,46 @@ async def test_if_fires_on_state_change( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON + assert hass.states.get(entry.entity_id).state == STATE_ON assert len(calls) == 0 - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 2 assert {calls[0].data["some"], calls[1].data["some"]} == { - f"turn_off device - {ent1.entity_id} - on - off - None", - f"turn_on_or_off device - {ent1.entity_id} - on - off - None", + f"turn_off device - {entry.entity_id} - on - off - None", + f"turn_on_or_off device - {entry.entity_id} - on - off - None", } - hass.states.async_set(ent1.entity_id, STATE_ON) + hass.states.async_set(entry.entity_id, STATE_ON) await hass.async_block_till_done() assert len(calls) == 4 assert {calls[2].data["some"], calls[3].data["some"]} == { - f"turn_on device - {ent1.entity_id} - off - on - None", - f"turn_on_or_off device - {ent1.entity_id} - off - on - None", + f"turn_on device - {entry.entity_id} - off - on - None", + f"turn_on_or_off device - {entry.entity_id} - off - on - None", } @pytest.mark.parametrize("trigger", ["turned_off", "changed_states"]) async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, calls, enable_custom_integrations: None, trigger + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, + trigger, ) -> None: """Test for triggers firing with delay.""" - platform = getattr(hass.components, "test.switch") - - platform.init() - assert await async_setup_component( - hass, "switch", {"switch": {CONF_PLATFORM: "test"}} + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + "switch", "test", "5678", device_id=device_entry.id ) - await hass.async_block_till_done() - ent1, ent2, ent3 = platform.ENTITIES + hass.states.async_set(entry.entity_id, STATE_ON) assert await async_setup_component( hass, @@ -166,8 +178,8 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": "switch", - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entry.entity_id, "type": trigger, "for": {"seconds": 5}, }, @@ -191,10 +203,10 @@ async def test_if_fires_on_state_change_with_for( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON + assert hass.states.get(entry.entity_id).state == STATE_ON assert len(calls) == 0 - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) @@ -202,5 +214,5 @@ async def test_if_fires_on_state_change_with_for( assert len(calls) == 1 await hass.async_block_till_done() assert calls[0].data["some"] == "turn_off device - {} - on - off - 0:00:05".format( - ent1.entity_id + entry.entity_id ) diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index 6b563f1cb5fde9..724ae612f0db93 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -182,7 +182,16 @@ async def test_lights_turn_on_when_coming_home_after_sun_set_person( assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() - await group.Group.async_create_group(hass, "person_me", ["person.me"]) + await group.Group.async_create_group( + hass, + "person_me", + created_by_service=False, + entity_ids=["person.me"], + icon=None, + mode=None, + object_id=None, + order=None, + ) assert await async_setup_component( hass, diff --git a/tests/components/device_tracker/test_device_condition.py b/tests/components/device_tracker/test_device_condition.py index 008a7eb75c47cd..f550b803fdac1a 100644 --- a/tests/components/device_tracker/test_device_condition.py +++ b/tests/components/device_tracker/test_device_condition.py @@ -110,10 +110,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_HOME) @@ -128,7 +139,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_home", } @@ -150,7 +161,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_not_home", } @@ -184,10 +195,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_HOME) @@ -202,7 +224,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_home", } diff --git a/tests/components/device_tracker/test_device_trigger.py b/tests/components/device_tracker/test_device_trigger.py index 75209ec607bb64..3e19570ebcb778 100644 --- a/tests/components/device_tracker/test_device_trigger.py +++ b/tests/components/device_tracker/test_device_trigger.py @@ -142,10 +142,21 @@ async def test_get_triggers_hidden_auxiliary( async def test_if_fires_on_zone_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for enter and leave triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -162,7 +173,7 @@ async def test_if_fires_on_zone_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "enters", "zone": "zone.test", @@ -186,7 +197,7 @@ async def test_if_fires_on_zone_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "leaves", "zone": "zone.test", @@ -238,10 +249,21 @@ async def test_if_fires_on_zone_change( async def test_if_fires_on_zone_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for enter and leave triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -258,7 +280,7 @@ async def test_if_fires_on_zone_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "enters", "zone": "zone.test", diff --git a/tests/components/dlna_dmr/conftest.py b/tests/components/dlna_dmr/conftest.py index 81225173d51c5b..9e9bcbf30561f0 100644 --- a/tests/components/dlna_dmr/conftest.py +++ b/tests/components/dlna_dmr/conftest.py @@ -74,8 +74,8 @@ def domain_data_mock(hass: HomeAssistant) -> Iterable[Mock]: seal(upnp_device) domain_data.upnp_factory.async_create_device.return_value = upnp_device - with patch.dict(hass.data, {DLNA_DOMAIN: domain_data}): - yield domain_data + hass.data[DLNA_DOMAIN] = domain_data + return domain_data @pytest.fixture @@ -129,6 +129,7 @@ def dmr_device_mock(domain_data_mock: Mock) -> Iterable[Mock]: device.manufacturer = "device_manufacturer" device.model_name = "device_model_name" device.name = "device_name" + device.preset_names = ["preset1", "preset2"] yield device diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py index be49a6ca257d5d..d9b1d60708bb65 100644 --- a/tests/components/dlna_dmr/test_config_flow.py +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -97,6 +97,15 @@ def mock_get_mac_address() -> Iterable[Mock]: yield gma_mock +@pytest.fixture(autouse=True) +def mock_setup_entry() -> Iterable[Mock]: + """Mock async_setup_entry.""" + with patch( + "homeassistant.components.dlna_dmr.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock + + async def test_user_flow_undiscovered_manual(hass: HomeAssistant) -> None: """Test user-init'd flow, no discovered devices, user entering a valid URL.""" result = await hass.config_entries.flow.async_init( @@ -120,9 +129,6 @@ async def test_user_flow_undiscovered_manual(hass: HomeAssistant) -> None: } assert result["options"] == {CONF_POLL_AVAILABILITY: True} - # Wait for platform to be fully setup - await hass.async_block_till_done() - async def test_user_flow_discovered_manual( hass: HomeAssistant, ssdp_scanner_mock: Mock @@ -163,9 +169,6 @@ async def test_user_flow_discovered_manual( } assert result["options"] == {CONF_POLL_AVAILABILITY: True} - # Wait for platform to be fully setup - await hass.async_block_till_done() - async def test_user_flow_selected(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> None: """Test user-init'd flow, user selects discovered device.""" @@ -196,8 +199,6 @@ async def test_user_flow_selected(hass: HomeAssistant, ssdp_scanner_mock: Mock) } assert result["options"] == {} - await hass.async_block_till_done() - async def test_user_flow_uncontactable( hass: HomeAssistant, domain_data_mock: Mock @@ -260,9 +261,6 @@ async def test_user_flow_embedded_st( } assert result["options"] == {CONF_POLL_AVAILABILITY: True} - # Wait for platform to be fully setup - await hass.async_block_till_done() - async def test_user_flow_wrong_st(hass: HomeAssistant, domain_data_mock: Mock) -> None: """Test user-init'd config flow with user entering a URL for the wrong device.""" @@ -717,9 +715,6 @@ async def test_unignore_flow(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> No } assert result["options"] == {} - # Wait for platform to be fully setup - await hass.async_block_till_done() - async def test_unignore_flow_offline( hass: HomeAssistant, ssdp_scanner_mock: Mock diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index f8413e8f6200cc..51128b161fb05f 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -26,7 +26,6 @@ CONF_CALLBACK_URL_OVERRIDE, CONF_LISTEN_PORT, CONF_POLL_AVAILABILITY, - DOMAIN as DLNA_DOMAIN, ) from homeassistant.components.dlna_dmr.data import EventListenAddr from homeassistant.components.dlna_dmr.media_player import DlnaDmrEntity @@ -81,7 +80,7 @@ async def setup_mock_component(hass: HomeAssistant, mock_entry: MockConfigEntry) -> str: """Set up a mock DlnaDmrEntity with the given configuration.""" mock_entry.add_to_hass(hass) - assert await async_setup_component(hass, DLNA_DOMAIN, {}) is True + assert await hass.config_entries.async_setup(mock_entry.entry_id) is True await hass.async_block_till_done() entries = async_entries_for_config_entry(async_get_er(hass), mock_entry.entry_id) diff --git a/tests/components/eight_sleep/conftest.py b/tests/components/eight_sleep/conftest.py deleted file mode 100644 index 753fe1e30d5f59..00000000000000 --- a/tests/components/eight_sleep/conftest.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Fixtures for Eight Sleep.""" -from unittest.mock import patch - -from pyeight.exceptions import RequestError -import pytest - - -@pytest.fixture(name="bypass", autouse=True) -def bypass_fixture(): - """Bypasses things that slow te tests down or block them from testing the behavior.""" - with patch( - "homeassistant.components.eight_sleep.config_flow.EightSleep.fetch_token", - ), patch( - "homeassistant.components.eight_sleep.config_flow.EightSleep.at_exit", - ), patch( - "homeassistant.components.eight_sleep.async_setup_entry", - return_value=True, - ): - yield - - -@pytest.fixture(name="token_error") -def token_error_fixture(): - """Simulate error when fetching token.""" - with patch( - "homeassistant.components.eight_sleep.config_flow.EightSleep.fetch_token", - side_effect=RequestError, - ): - yield diff --git a/tests/components/eight_sleep/test_config_flow.py b/tests/components/eight_sleep/test_config_flow.py deleted file mode 100644 index 6a64f6a5731c70..00000000000000 --- a/tests/components/eight_sleep/test_config_flow.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Test the Eight Sleep config flow.""" -from homeassistant import config_entries -from homeassistant.components.eight_sleep.const import DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - - -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" - 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 - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-username" - assert result2["data"] == { - "username": "test-username", - "password": "test-password", - } - - -async def test_form_invalid_auth(hass: HomeAssistant, token_error) -> None: - """Test we handle invalid auth.""" - 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 - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "bad-username", - "password": "bad-password", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_import(hass: HomeAssistant) -> None: - """Test import works.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "username": "test-username", - "password": "test-password", - }, - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "test-username" - assert result["data"] == { - "username": "test-username", - "password": "test-password", - } - - -async def test_import_invalid_auth(hass: HomeAssistant, token_error) -> None: - """Test we handle invalid auth on import.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "username": "bad-username", - "password": "bad-password", - }, - ) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "cannot_connect" diff --git a/tests/components/eight_sleep/test_init.py b/tests/components/eight_sleep/test_init.py new file mode 100644 index 00000000000000..6b94ff31139dfd --- /dev/null +++ b/tests/components/eight_sleep/test_init.py @@ -0,0 +1,50 @@ +"""Tests for the Eight Sleep integration.""" + +from homeassistant.components.eight_sleep import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_mazda_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the Eight Sleep configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, + ) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED + + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, + ) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None diff --git a/tests/components/esphome/test_text.py b/tests/components/esphome/test_text.py new file mode 100644 index 00000000000000..07157d98ac60cd --- /dev/null +++ b/tests/components/esphome/test_text.py @@ -0,0 +1,115 @@ +"""Test ESPHome texts.""" + +from unittest.mock import call + +from aioesphomeapi import APIClient, TextInfo, TextMode as ESPHomeTextMode, TextState + +from homeassistant.components.text import ( + ATTR_VALUE, + DOMAIN as TEXT_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + + +async def test_generic_text_entity( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic text entity.""" + entity_info = [ + TextInfo( + object_id="mytext", + key=1, + name="my text", + unique_id="my_text", + max_length=100, + min_length=0, + pattern=None, + mode=ESPHomeTextMode.TEXT, + ) + ] + states = [TextState(key=1, state="hello world")] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("text.test_mytext") + assert state is not None + assert state.state == "hello world" + + await hass.services.async_call( + TEXT_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "text.test_mytext", ATTR_VALUE: "goodbye"}, + blocking=True, + ) + mock_client.text_command.assert_has_calls([call(1, "goodbye")]) + mock_client.text_command.reset_mock() + + +async def test_generic_text_entity_no_state( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic text entity that has no state.""" + entity_info = [ + TextInfo( + object_id="mytext", + key=1, + name="my text", + unique_id="my_text", + max_length=100, + min_length=0, + pattern=None, + mode=ESPHomeTextMode.TEXT, + ) + ] + states = [] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("text.test_mytext") + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_generic_text_entity_missing_state( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic text entity that has no state.""" + entity_info = [ + TextInfo( + object_id="mytext", + key=1, + name="my text", + unique_id="my_text", + max_length=100, + min_length=0, + pattern=None, + mode=ESPHomeTextMode.TEXT, + ) + ] + states = [TextState(key=1, state="", missing_state=True)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("text.test_mytext") + assert state is not None + assert state.state == STATE_UNKNOWN diff --git a/tests/components/fan/test_device_action.py b/tests/components/fan/test_device_action.py index 5404c80340e8e3..b8756d9ace5e5e 100644 --- a/tests/components/fan/test_device_action.py +++ b/tests/components/fan/test_device_action.py @@ -103,9 +103,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -119,7 +131,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -131,7 +143,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -143,7 +155,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "toggle", }, @@ -159,6 +171,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - hass.bus.async_fire("test_event_turn_off") await hass.async_block_till_done() assert len(turn_off_calls) == 1 + assert turn_off_calls[0].data["entity_id"] == entry.entity_id assert len(turn_on_calls) == 0 assert len(toggle_calls) == 0 @@ -166,6 +179,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - await hass.async_block_till_done() assert len(turn_off_calls) == 1 assert len(turn_on_calls) == 1 + assert turn_on_calls[0].data["entity_id"] == entry.entity_id assert len(toggle_calls) == 0 hass.bus.async_fire("test_event_toggle") @@ -173,13 +187,24 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - assert len(turn_off_calls) == 1 assert len(turn_on_calls) == 1 assert len(toggle_calls) == 1 + assert toggle_calls[0].data["entity_id"] == entry.entity_id async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -193,7 +218,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turn_off", }, diff --git a/tests/components/fan/test_device_condition.py b/tests/components/fan/test_device_condition.py index 20c84eb1436ab0..1ee168f28ab684 100644 --- a/tests/components/fan/test_device_condition.py +++ b/tests/components/fan/test_device_condition.py @@ -110,10 +110,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -128,7 +139,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -150,7 +161,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -184,10 +195,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -202,7 +224,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_on", } diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py index f1de07a9e977db..8ac5e79ba5b0c2 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -176,10 +176,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OFF) @@ -192,7 +203,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -214,7 +225,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", }, @@ -236,7 +247,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "changed_states", }, @@ -278,10 +289,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OFF) @@ -294,7 +316,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -327,10 +349,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -343,7 +376,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index 155e54995434c7..682fb0edd3b092 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -41,10 +41,11 @@ # These constants differ from values in the config entry or fitbit.conf SERVER_ACCESS_TOKEN = { - "refresh_token": "server-access-token", - "access_token": "server-refresh-token", + "refresh_token": "server-refresh-token", + "access_token": "server-access-token", "type": "Bearer", "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), } diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index e6ab39aff59b19..cf2d5d17f221cf 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus +import time from typing import Any from unittest.mock import patch @@ -16,9 +17,7 @@ from .conftest import ( CLIENT_ID, - FAKE_ACCESS_TOKEN, FAKE_AUTH_IMPL, - FAKE_REFRESH_TOKEN, PROFILE_API_URL, PROFILE_USER_ID, SERVER_ACCESS_TOKEN, @@ -204,14 +203,27 @@ async def test_config_entry_already_exists( assert result.get("reason") == "already_configured" +@pytest.mark.parametrize( + "token_expiration_time", + [time.time() + 86400, time.time() - 86400], + ids=("token_active", "token_expired"), +) async def test_import_fitbit_config( hass: HomeAssistant, fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], issue_registry: ir.IssueRegistry, + requests_mock: Mocker, ) -> None: """Test that platform configuration is imported successfully.""" + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) + with patch( "homeassistant.components.fitbit.async_setup_entry", return_value=True ) as mock_setup: @@ -227,16 +239,20 @@ async def test_import_fitbit_config( assert config_entry.unique_id == PROFILE_USER_ID data = dict(config_entry.data) + # Verify imported values from fitbit.conf and configuration.yaml and + # that the token is updated. assert "token" in data + expires_at = data["token"]["expires_at"] + assert expires_at > time.time() del data["token"]["expires_at"] - # Verify imported values from fitbit.conf and configuration.yaml assert dict(config_entry.data) == { "auth_implementation": DOMAIN, "clock_format": "24H", "monitored_resources": ["activities/steps"], "token": { - "access_token": FAKE_ACCESS_TOKEN, - "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": "server-access-token", + "refresh_token": "server-refresh-token", + "scope": "activity heartrate nutrition profile settings sleep weight", }, "unit_system": "default", } @@ -256,6 +272,12 @@ async def test_import_fitbit_config_failure_cannot_connect( ) -> None: """Test platform configuration fails to import successfully.""" + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) requests_mock.register_uri( "GET", PROFILE_API_URL, status_code=HTTPStatus.INTERNAL_SERVER_ERROR ) @@ -273,6 +295,43 @@ async def test_import_fitbit_config_failure_cannot_connect( assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" +@pytest.mark.parametrize( + "status_code", + [ + (HTTPStatus.UNAUTHORIZED), + (HTTPStatus.INTERNAL_SERVER_ERROR), + ], +) +async def test_import_fitbit_config_cannot_refresh( + hass: HomeAssistant, + fitbit_config_setup: None, + sensor_platform_setup: Callable[[], Awaitable[bool]], + issue_registry: ir.IssueRegistry, + requests_mock: Mocker, + status_code: HTTPStatus, +) -> None: + """Test platform configuration import fails when refreshing the token.""" + + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=status_code, + json="", + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await sensor_platform_setup() + + assert len(mock_setup.mock_calls) == 0 + + # Verify an issue is raised that we were unable to import configuration + issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) + assert issue + assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" + + async def test_import_fitbit_config_already_exists( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -281,9 +340,17 @@ async def test_import_fitbit_config_already_exists( fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], issue_registry: ir.IssueRegistry, + requests_mock: Mocker, ) -> None: """Test that platform configuration is not imported if it already exists.""" + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) + # Verify existing config entry entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index b54f154d406c62..5421a65212579c 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -9,7 +9,7 @@ from requests_mock.mocker import Mocker from syrupy.assertion import SnapshotAssertion -from homeassistant.components.fitbit.const import DOMAIN +from homeassistant.components.fitbit.const import DOMAIN, OAUTH2_TOKEN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -23,6 +23,7 @@ from .conftest import ( DEVICES_API_URL, PROFILE_USER_ID, + SERVER_ACCESS_TOKEN, TIMESERIES_API_URL_FORMAT, timeseries_response, ) @@ -55,6 +56,18 @@ def platforms() -> list[str]: return [Platform.SENSOR] +@pytest.fixture(autouse=True) +def mock_token_refresh(requests_mock: Mocker) -> None: + """Test that platform configuration is imported successfully.""" + + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) + + @pytest.mark.parametrize( ( "monitored_resources", diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index 63bc1d76d1a191..5d1b6fab0c8d33 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -10,7 +10,7 @@ DATA_CALL_GET_CALLS_LOG, DATA_CONNECTION_GET_STATUS, DATA_HOME_GET_NODES, - DATA_HOME_GET_VALUES, + DATA_HOME_PIR_GET_VALUES, DATA_LAN_GET_HOSTS_LIST, DATA_STORAGE_GET_DISKS, DATA_STORAGE_GET_RAIDS, @@ -81,7 +81,7 @@ def mock_router(mock_device_registry_devices): # home devices instance.home.get_home_nodes = AsyncMock(return_value=DATA_HOME_GET_NODES) instance.home.get_home_endpoint_value = AsyncMock( - return_value=DATA_HOME_GET_VALUES + return_value=DATA_HOME_PIR_GET_VALUES ) instance.close = AsyncMock() yield service_mock diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index 788310bdbc0ac2..0cd854b22bf806 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -515,7 +515,7 @@ # Home # PIR node id 26, endpoint id 6 -DATA_HOME_GET_VALUES = { +DATA_HOME_PIR_GET_VALUES = { "category": "", "ep_type": "signal", "id": 6, @@ -527,6 +527,15 @@ "visibility": "normal", } +# Home +# ALARM node id 7, endpoint id 11 +DATA_HOME_ALARM_GET_VALUES = { + "refresh": 2000, + "value": "alarm2_armed", + "value_type": "string", +} + + # Home # ALL DATA_HOME_GET_NODES = [ @@ -2526,4 +2535,354 @@ "inherit": "node::ios", }, }, + { + "adapter": 5, + "category": "alarm", + "group": {"label": ""}, + "id": 7, + "label": "Système d'alarme", + "name": "node_7", + "props": { + "Address": 3, + "Challenge": "447599f5cab8620122b913e55faf8e1d", + "FwVersion": 47396239, + "Gateway": 1, + "ItemId": "e515a55b04f32e6d", + }, + "show_endpoints": [ + { + "category": "", + "ep_type": "slot", + "id": 5, + "label": "Code PIN", + "name": "pin", + "ui": {...}, + "value": "", + "value_type": "string", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 6, + "label": "Puissance des bips", + "name": "sound", + "ui": {...}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "slot", + "id": 7, + "label": "Puissance de la sirène", + "name": "volume", + "ui": {...}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "alarm", + "ep_type": "slot", + "id": 8, + "label": "Délai avant armement", + "name": "timeout1", + "ui": {...}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "alarm", + "ep_type": "slot", + "id": 9, + "label": "Délai avant sirène", + "name": "timeout2", + "ui": {...}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "alarm", + "ep_type": "slot", + "id": 10, + "label": "Durée de la sirène", + "name": "timeout3", + "ui": {...}, + "value": 0, + "value_type": "int", + "visibility": "normal", + }, + { + "category": "", + "ep_type": "signal", + "id": 12, + "label": "Code PIN", + "name": "pin", + "refresh": 2000, + "ui": {...}, + "value": "0000", + "value_type": "string", + }, + { + "category": "", + "ep_type": "signal", + "id": 14, + "label": "Puissance des bips", + "name": "sound", + "refresh": 2000, + "ui": {...}, + "value": 1, + "value_type": "int", + }, + { + "category": "", + "ep_type": "signal", + "id": 15, + "label": "Puissance de la sirène", + "name": "volume", + "refresh": 2000, + "ui": {...}, + "value": 100, + "value_type": "int", + }, + { + "category": "alarm", + "ep_type": "signal", + "id": 16, + "label": "Délai avant armement", + "name": "timeout1", + "refresh": 2000, + "ui": {...}, + "value": 15, + "value_type": "int", + }, + { + "category": "alarm", + "ep_type": "signal", + "id": 17, + "label": "Délai avant sirène", + "name": "timeout2", + "refresh": 2000, + "ui": {...}, + "value": 15, + "value_type": "int", + }, + { + "category": "alarm", + "ep_type": "signal", + "id": 18, + "label": "Durée de la sirène", + "name": "timeout3", + "refresh": 2000, + "ui": {...}, + "value": 300, + "value_type": "int", + }, + { + "category": "", + "ep_type": "signal", + "id": 19, + "label": "Niveau de Batterie", + "name": "battery", + "refresh": 2000, + "ui": {...}, + "value": 85, + "value_type": "int", + }, + ], + "type": { + "abstract": False, + "endpoints": [ + { + "ep_type": "slot", + "id": 0, + "label": "Trigger", + "name": "trigger", + "value_type": "void", + "visibility": "internal", + }, + { + "ep_type": "slot", + "id": 1, + "label": "Alarme principale", + "name": "alarm1", + "value_type": "void", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 2, + "label": "Alarme secondaire", + "name": "alarm2", + "value_type": "void", + "visibility": "internal", + }, + { + "ep_type": "slot", + "id": 3, + "label": "Passer le délai", + "name": "skip", + "value_type": "void", + "visibility": "internal", + }, + { + "ep_type": "slot", + "id": 4, + "label": "Désactiver l'alarme", + "name": "off", + "value_type": "void", + "visibility": "internal", + }, + { + "ep_type": "slot", + "id": 5, + "label": "Code PIN", + "name": "pin", + "value_type": "string", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 6, + "label": "Puissance des bips", + "name": "sound", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 7, + "label": "Puissance de la sirène", + "name": "volume", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 8, + "label": "Délai avant armement", + "name": "timeout1", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 9, + "label": "Délai avant sirène", + "name": "timeout2", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "slot", + "id": 10, + "label": "Durée de la sirène", + "name": "timeout3", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 11, + "label": "État de l'alarme", + "name": "state", + "param_type": "void", + "value_type": "string", + "visibility": "internal", + }, + { + "ep_type": "signal", + "id": 12, + "label": "Code PIN", + "name": "pin", + "param_type": "void", + "value_type": "string", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 13, + "label": "Erreur", + "name": "error", + "param_type": "void", + "value_type": "string", + "visibility": "internal", + }, + { + "ep_type": "signal", + "id": 14, + "label": "Puissance des bips", + "name": "sound", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 15, + "label": "Puissance de la sirène", + "name": "volume", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 16, + "label": "Délai avant armement", + "name": "timeout1", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 17, + "label": "Délai avant sirène", + "name": "timeout2", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 18, + "label": "Durée de la sirène", + "name": "timeout3", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 19, + "label": "Niveau de Batterie", + "name": "battery", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + { + "ep_type": "signal", + "id": 20, + "label": "Batterie faible", + "name": "battery_warning", + "param_type": "void", + "value_type": "int", + "visibility": "normal", + }, + ], + "generic": False, + "icon": "/resources/images/home/pictos/alarm_system.png", + "inherit": "node::domus", + "label": "Système d'alarme", + "name": "node::domus::freebox::secmod", + "params": {}, + "physical": True, + }, + }, ] diff --git a/tests/components/freebox/test_alarm_control_panel.py b/tests/components/freebox/test_alarm_control_panel.py new file mode 100644 index 00000000000000..d24c747f2a3fbb --- /dev/null +++ b/tests/components/freebox/test_alarm_control_panel.py @@ -0,0 +1,123 @@ +"""Tests for the Freebox sensors.""" +from copy import deepcopy +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL, + AlarmControlPanelEntityFeature, +) +from homeassistant.components.freebox import SCAN_INTERVAL +from homeassistant.const import ( + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, + SERVICE_ALARM_DISARM, + SERVICE_ALARM_TRIGGER, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_CUSTOM_BYPASS, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.state import async_reproduce_state + +from .common import setup_platform +from .const import DATA_HOME_ALARM_GET_VALUES + +from tests.common import async_fire_time_changed, async_mock_service + + +async def test_panel( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test home binary sensors.""" + await setup_platform(hass, ALARM_CONTROL_PANEL) + + # Initial state + assert hass.states.get("alarm_control_panel.systeme_d_alarme").state == "unknown" + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").attributes[ + "supported_features" + ] + == AlarmControlPanelEntityFeature.ARM_AWAY + ) + + # Now simulate a changed status + data_get_home_endpoint_value = deepcopy(DATA_HOME_ALARM_GET_VALUES) + router().home.get_home_endpoint_value.return_value = data_get_home_endpoint_value + + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("alarm_control_panel.systeme_d_alarme").state == "armed_night" + ) + # Fake that the entity is triggered. + hass.states.async_set("alarm_control_panel.systeme_d_alarme", STATE_ALARM_DISARMED) + assert hass.states.get("alarm_control_panel.systeme_d_alarme").state == "disarmed" + + +async def test_reproducing_states( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test reproducing Alarm control panel states.""" + hass.states.async_set( + "alarm_control_panel.entity_armed_away", STATE_ALARM_ARMED_AWAY, {} + ) + hass.states.async_set( + "alarm_control_panel.entity_armed_custom_bypass", + STATE_ALARM_ARMED_CUSTOM_BYPASS, + {}, + ) + hass.states.async_set( + "alarm_control_panel.entity_armed_home", STATE_ALARM_ARMED_HOME, {} + ) + hass.states.async_set( + "alarm_control_panel.entity_armed_night", STATE_ALARM_ARMED_NIGHT, {} + ) + hass.states.async_set( + "alarm_control_panel.entity_armed_vacation", STATE_ALARM_ARMED_VACATION, {} + ) + hass.states.async_set( + "alarm_control_panel.entity_disarmed", STATE_ALARM_DISARMED, {} + ) + hass.states.async_set( + "alarm_control_panel.entity_triggered", STATE_ALARM_TRIGGERED, {} + ) + + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_AWAY) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_CUSTOM_BYPASS) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_HOME) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_NIGHT) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_ARM_VACATION) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_DISARM) + async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_TRIGGER) + + # These calls should do nothing as entities already in desired state + await async_reproduce_state( + hass, + [ + State("alarm_control_panel.entity_armed_away", STATE_ALARM_ARMED_AWAY), + State( + "alarm_control_panel.entity_armed_custom_bypass", + STATE_ALARM_ARMED_CUSTOM_BYPASS, + ), + State("alarm_control_panel.entity_armed_home", STATE_ALARM_ARMED_HOME), + State("alarm_control_panel.entity_armed_night", STATE_ALARM_ARMED_NIGHT), + State( + "alarm_control_panel.entity_armed_vacation", STATE_ALARM_ARMED_VACATION + ), + State("alarm_control_panel.entity_disarmed", STATE_ALARM_DISARMED), + State("alarm_control_panel.entity_triggered", STATE_ALARM_TRIGGERED), + ], + ) diff --git a/tests/components/freebox/test_binary_sensor.py b/tests/components/freebox/test_binary_sensor.py index b37d6a3c72c1b4..2fd308ea667c49 100644 --- a/tests/components/freebox/test_binary_sensor.py +++ b/tests/components/freebox/test_binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from .common import setup_platform -from .const import DATA_HOME_GET_VALUES, DATA_STORAGE_GET_RAIDS +from .const import DATA_HOME_PIR_GET_VALUES, DATA_STORAGE_GET_RAIDS from tests.common import async_fire_time_changed @@ -73,7 +73,7 @@ async def test_home( assert hass.states.get("binary_sensor.ouverture_porte_couvercle").state == "off" # Now simulate a changed status - data_home_get_values_changed = deepcopy(DATA_HOME_GET_VALUES) + data_home_get_values_changed = deepcopy(DATA_HOME_PIR_GET_VALUES) data_home_get_values_changed["value"] = True router().home.get_home_endpoint_value.return_value = data_home_get_values_changed diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index 5a757da1e9c303..c64972b7904950 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -1,6 +1,10 @@ """Tests for the Fronius integration.""" from __future__ import annotations +from collections.abc import Callable +import json +from typing import Any + from homeassistant.components.fronius.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST @@ -32,55 +36,78 @@ async def setup_fronius_integration( return entry +def _load_and_patch_fixture( + override_data: dict[str, list[tuple[list[str], Any]]] +) -> Callable[[str, str | None], str]: + """Return a fixture loader that patches values at nested keys for a given filename.""" + + def load_and_patch(filename: str, integration: str): + """Load a fixture and patch given values.""" + text = load_fixture(filename, integration) + if filename not in override_data: + return text + + _loaded = json.loads(text) + for keys, value in override_data[filename]: + _dic = _loaded + for key in keys[:-1]: + _dic = _dic[key] + _dic[keys[-1]] = value + return json.dumps(_loaded) + + return load_and_patch + + def mock_responses( aioclient_mock: AiohttpClientMocker, host: str = MOCK_HOST, fixture_set: str = "symo", inverter_ids: list[str | int] = [1], night: bool = False, + override_data: dict[str, list[tuple[list[str], Any]]] + | None = None, # {filename: [([list of nested keys], patch_value)]} ) -> None: """Mock responses for Fronius devices.""" aioclient_mock.clear_requests() _night = "_night" if night else "" + _load = _load_and_patch_fixture(override_data) if override_data else load_fixture aioclient_mock.get( f"{host}/solar_api/GetAPIVersion.cgi", - text=load_fixture(f"{fixture_set}/GetAPIVersion.json", "fronius"), + text=_load(f"{fixture_set}/GetAPIVersion.json", "fronius"), ) for inverter_id in inverter_ids: aioclient_mock.get( f"{host}/solar_api/v1/GetInverterRealtimeData.cgi?Scope=Device&" f"DeviceId={inverter_id}&DataCollection=CommonInverterData", - text=load_fixture( + text=_load( f"{fixture_set}/GetInverterRealtimeData_Device_{inverter_id}{_night}.json", "fronius", ), ) aioclient_mock.get( f"{host}/solar_api/v1/GetInverterInfo.cgi", - text=load_fixture(f"{fixture_set}/GetInverterInfo{_night}.json", "fronius"), + text=_load(f"{fixture_set}/GetInverterInfo{_night}.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetLoggerInfo.cgi", - text=load_fixture(f"{fixture_set}/GetLoggerInfo.json", "fronius"), + text=_load(f"{fixture_set}/GetLoggerInfo.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetMeterRealtimeData.cgi?Scope=System", - text=load_fixture(f"{fixture_set}/GetMeterRealtimeData.json", "fronius"), + text=_load(f"{fixture_set}/GetMeterRealtimeData.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetPowerFlowRealtimeData.fcgi", - text=load_fixture( - f"{fixture_set}/GetPowerFlowRealtimeData{_night}.json", "fronius" - ), + text=_load(f"{fixture_set}/GetPowerFlowRealtimeData{_night}.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetStorageRealtimeData.cgi?Scope=System", - text=load_fixture(f"{fixture_set}/GetStorageRealtimeData.json", "fronius"), + text=_load(f"{fixture_set}/GetStorageRealtimeData.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetOhmPilotRealtimeData.cgi?Scope=System", - text=load_fixture(f"{fixture_set}/GetOhmPilotRealtimeData.json", "fronius"), + text=_load(f"{fixture_set}/GetOhmPilotRealtimeData.json", "fronius"), ) diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index c2e0c4ad969de8..f94b0f3a55c1bd 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -302,6 +302,22 @@ def assert_state(entity_id, expected_state): assert_state("sensor.solarnet_relative_autonomy", 5.3592) assert_state("sensor.solarnet_total_energy", 1530193.42) + # Gen24 devices may report 0 for total energy while doing firmware updates. + # This should yield "unknown" state instead of 0. + mock_responses( + aioclient_mock, + fixture_set="gen24", + override_data={ + "gen24/GetInverterRealtimeData_Device_1.json": [ + (["Body", "Data", "TOTAL_ENERGY", "Value"], 0), + ], + }, + ) + freezer.tick(FroniusInverterUpdateCoordinator.default_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert_state("sensor.inverter_name_total_energy", "unknown") + async def test_gen24_storage( hass: HomeAssistant, diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index f0c433f2e96684..e3f0d7f35d540d 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -8,9 +8,8 @@ import pytest from homeassistant.components.frontend import ( - CONF_EXTRA_HTML_URL, - CONF_EXTRA_HTML_URL_ES5, - CONF_JS_VERSION, + CONF_EXTRA_JS_URL_ES5, + CONF_EXTRA_MODULE_URL, CONF_THEMES, DEFAULT_THEME_COLOR, DOMAIN, @@ -107,16 +106,15 @@ async def ws_client(hass, hass_ws_client, frontend): @pytest.fixture -async def mock_http_client_with_urls(hass, aiohttp_client, ignore_frontend_deps): +async def mock_http_client_with_extra_js(hass, aiohttp_client, ignore_frontend_deps): """Start the Home Assistant HTTP component.""" assert await async_setup_component( hass, "frontend", { DOMAIN: { - CONF_JS_VERSION: "auto", - CONF_EXTRA_HTML_URL: ["https://domain.com/my_extra_url.html"], - CONF_EXTRA_HTML_URL_ES5: ["https://domain.com/my_extra_url_es5.html"], + CONF_EXTRA_MODULE_URL: ["/local/my_module.js"], + CONF_EXTRA_JS_URL_ES5: ["/local/my_es5.js"], } }, ) @@ -182,10 +180,17 @@ async def test_themes_api(hass: HomeAssistant, themes_ws_client) -> None: await themes_ws_client.send_json({"id": 6, "type": "frontend/get_themes"}) msg = await themes_ws_client.receive_json() - assert msg["result"]["default_theme"] == "recovery_mode" - assert msg["result"]["themes"] == { - "recovery_mode": {"primary-color": "#db4437", "accent-color": "#ffca28"} - } + assert msg["result"]["default_theme"] == "default" + assert msg["result"]["themes"] == {} + + # safe mode + hass.config.recovery_mode = False + hass.config.safe_mode = True + await themes_ws_client.send_json({"id": 7, "type": "frontend/get_themes"}) + msg = await themes_ws_client.receive_json() + + assert msg["result"]["default_theme"] == "default" + assert msg["result"]["themes"] == {} async def test_themes_persist( @@ -376,6 +381,29 @@ async def test_missing_themes(hass: HomeAssistant, ws_client) -> None: assert msg["result"]["themes"] == {} +async def test_extra_js( + hass: HomeAssistant, mock_http_client_with_extra_js, mock_onboarded +): + """Test that extra javascript is loaded.""" + resp = await mock_http_client_with_extra_js.get("") + assert resp.status == 200 + assert "cache-control" not in resp.headers + + text = await resp.text() + assert '"/local/my_module.js"' in text + assert '"/local/my_es5.js"' in text + + # safe mode + hass.config.safe_mode = True + resp = await mock_http_client_with_extra_js.get("") + assert resp.status == 200 + assert "cache-control" not in resp.headers + + text = await resp.text() + assert '"/local/my_module.js"' not in text + assert '"/local/my_es5.js"' not in text + + async def test_get_panels( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_http_client ) -> None: diff --git a/tests/components/google_tasks/__init__.py b/tests/components/google_tasks/__init__.py new file mode 100644 index 00000000000000..6a6872a350a7ad --- /dev/null +++ b/tests/components/google_tasks/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Tasks integration.""" diff --git a/tests/components/google_tasks/conftest.py b/tests/components/google_tasks/conftest.py new file mode 100644 index 00000000000000..60387889aadfbf --- /dev/null +++ b/tests/components/google_tasks/conftest.py @@ -0,0 +1,91 @@ +"""Test fixtures for Google Tasks.""" + + +from collections.abc import Awaitable, Callable +import time +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.google_tasks.const import DOMAIN, OAUTH2_SCOPES +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +FAKE_ACCESS_TOKEN = "some-access-token" +FAKE_REFRESH_TOKEN = "some-refresh-token" +FAKE_AUTH_IMPL = "conftest-imported-cred" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(name="token_entry") +def mock_token_entry(expires_at: int) -> dict[str, Any]: + """Fixture for OAuth 'token' data for a ConfigEntry.""" + return { + "access_token": FAKE_ACCESS_TOKEN, + "refresh_token": FAKE_REFRESH_TOKEN, + "scope": " ".join(OAUTH2_SCOPES), + "token_type": "Bearer", + "expires_at": expires_at, + } + + +@pytest.fixture(name="config_entry") +def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: + """Fixture for a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": token_entry, + }, + ) + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="integration_setup") +async def mock_integration_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + platforms: list[str], +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + config_entry.add_to_hass(hass) + + async def run() -> bool: + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run diff --git a/tests/components/google_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr new file mode 100644 index 00000000000000..f24d17a60d163b --- /dev/null +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -0,0 +1,37 @@ +# serializer version: 1 +# name: test_create_todo_list_item[api_responses0] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', + 'POST', + ) +# --- +# name: test_create_todo_list_item[api_responses0].1 + '{"title": "Soda", "status": "needsAction"}' +# --- +# name: test_partial_update_status[api_responses0] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update_status[api_responses0].1 + '{"status": "needsAction"}' +# --- +# name: test_partial_update_title[api_responses0] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update_title[api_responses0].1 + '{"title": "Soda"}' +# --- +# name: test_update_todo_list_item[api_responses0] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_update_todo_list_item[api_responses0].1 + '{"title": "Soda", "status": "completed"}' +# --- diff --git a/tests/components/google_tasks/test_config_flow.py b/tests/components/google_tasks/test_config_flow.py new file mode 100644 index 00000000000000..b05e1eb108d2c0 --- /dev/null +++ b/tests/components/google_tasks/test_config_flow.py @@ -0,0 +1,66 @@ +"""Test the Google Tasks config flow.""" + +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.google_tasks.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + "google_tasks", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/tasks" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_tasks.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/google_tasks/test_init.py b/tests/components/google_tasks/test_init.py new file mode 100644 index 00000000000000..b486942f70a743 --- /dev/null +++ b/tests/components/google_tasks/test_init.py @@ -0,0 +1,99 @@ +"""Tests for Google Tasks.""" +from collections.abc import Awaitable, Callable +import http +import time + +import pytest + +from homeassistant.components.google_tasks import DOMAIN +from homeassistant.components.google_tasks.const import OAUTH2_TOKEN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + setup_credentials: None, +) -> None: + """Test successful setup and unload.""" + assert config_entry.state is ConfigEntryState.NOT_LOADED + + await integration_setup() + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert not hass.services.async_services().get(DOMAIN) + + +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_success( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + setup_credentials: None, +) -> None: + """Test expired token is refreshed.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "access_token": "updated-access-token", + "refresh_token": "updated-refresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + }, + ) + + await integration_setup() + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.data["token"]["access_token"] == "updated-access-token" + assert config_entry.data["token"]["expires_in"] == 3600 + + +@pytest.mark.parametrize( + ("expires_at", "status", "expected_state"), + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_RETRY, # Will trigger reauth in the future + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["unauthorized", "internal_server_error"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + setup_credentials: None, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + status=status, + ) + + await integration_setup() + + assert config_entry.state is expected_state diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py new file mode 100644 index 00000000000000..5dc7f10fea0611 --- /dev/null +++ b/tests/components/google_tasks/test_todo.py @@ -0,0 +1,330 @@ +"""Tests for Google Tasks todo platform.""" + + +from collections.abc import Awaitable, Callable +import json +from typing import Any +from unittest.mock import Mock, patch + +from httplib2 import Response +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.typing import WebSocketGenerator + +ENTITY_ID = "todo.my_tasks" +LIST_TASK_LIST_RESPONSE = { + "items": [ + { + "id": "task-list-id-1", + "title": "My tasks", + }, + ] +} +EMPTY_RESPONSE = {} +LIST_TASKS_RESPONSE = { + "items": [], +} + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.TODO] + + +@pytest.fixture +def ws_req_id() -> Callable[[], int]: + """Fixture for incremental websocket requests.""" + + id = 0 + + def next_id() -> int: + nonlocal id + id += 1 + return id + + return next_id + + +@pytest.fixture +async def ws_get_items( + hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] +) -> Callable[[], Awaitable[dict[str, str]]]: + """Fixture to fetch items from the todo websocket.""" + + async def get() -> list[dict[str, str]]: + # Fetch items using To-do platform + client = await hass_ws_client() + id = ws_req_id() + await client.send_json( + { + "id": id, + "type": "todo/item/list", + "entity_id": ENTITY_ID, + } + ) + resp = await client.receive_json() + assert resp.get("id") == id + assert resp.get("success") + return resp.get("result", {}).get("items", []) + + return get + + +@pytest.fixture(name="api_responses") +def mock_api_responses() -> list[dict | list]: + """Fixture for API responses to return during test.""" + return [] + + +@pytest.fixture(autouse=True) +def mock_http_response(api_responses: list[dict | list]) -> Mock: + """Fixture to fake out http2lib responses.""" + responses = [ + (Response({}), bytes(json.dumps(api_response), encoding="utf-8")) + for api_response in api_responses + ] + with patch("httplib2.Http.request", side_effect=responses) as mock_response: + yield mock_response + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + { + "items": [ + {"id": "task-1", "title": "Task 1", "status": "needsAction"}, + {"id": "task-2", "title": "Task 2", "status": "completed"}, + ], + }, + ] + ], +) +async def test_get_items( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + hass_ws_client: WebSocketGenerator, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test getting todo list items.""" + + assert await integration_setup() + + await hass_ws_client(hass) + + items = await ws_get_items() + assert items == [ + { + "uid": "task-1", + "summary": "Task 1", + "status": "needs_action", + }, + { + "uid": "task-2", + "summary": "Task 2", + "status": "completed", + }, + ] + + # State reflect that one task needs action + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "1" + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE, + ] + ], +) +async def test_empty_todo_list( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + hass_ws_client: WebSocketGenerator, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test getting todo list items.""" + + assert await integration_setup() + + await hass_ws_client(hass) + + items = await ws_get_items() + assert items == [] + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "0" + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE, + EMPTY_RESPONSE, # create + LIST_TASKS_RESPONSE, # refresh after create + ] + ], +) +async def test_create_todo_list_item( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Mock, + snapshot: SnapshotAssertion, +) -> None: + """Test for creating a To-do Item.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "0" + + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "Soda"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + assert call.kwargs.get("body") == snapshot + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE, + EMPTY_RESPONSE, # update + LIST_TASKS_RESPONSE, # refresh after update + ] + ], +) +async def test_update_todo_list_item( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test for updating a To-do Item.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "0" + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"uid": "some-task-id", "summary": "Soda", "status": "completed"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + assert call.kwargs.get("body") == snapshot + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE, + EMPTY_RESPONSE, # update + LIST_TASKS_RESPONSE, # refresh after update + ] + ], +) +async def test_partial_update_title( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test for partial update with title only.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "0" + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"uid": "some-task-id", "summary": "Soda"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + assert call.kwargs.get("body") == snapshot + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE, + EMPTY_RESPONSE, # update + LIST_TASKS_RESPONSE, # refresh after update + ] + ], +) +async def test_partial_update_status( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test for partial update with status only.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "0" + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"uid": "some-task-id", "status": "needs_action"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + assert call.kwargs.get("body") == snapshot diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 3ea75fbce06f55..c439506b52a846 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -39,7 +39,14 @@ async def test_setup_group_with_mixed_groupable_states(hass: HomeAssistant) -> N assert await async_setup_component(hass, "group", {}) await group.Group.async_create_group( - hass, "person_and_light", ["light.Bowl", "device_tracker.Paulus"] + hass, + "person_and_light", + created_by_service=False, + entity_ids=["light.Bowl", "device_tracker.Paulus"], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.async_block_till_done() @@ -54,7 +61,14 @@ async def test_setup_group_with_a_non_existing_state(hass: HomeAssistant) -> Non assert await async_setup_component(hass, "group", {}) grp = await group.Group.async_create_group( - hass, "light_and_nothing", ["light.Bowl", "non.existing"] + hass, + "light_and_nothing", + created_by_service=False, + entity_ids=["light.Bowl", "non.existing"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert grp.state == STATE_ON @@ -68,7 +82,14 @@ async def test_setup_group_with_non_groupable_states(hass: HomeAssistant) -> Non assert await async_setup_component(hass, "group", {}) grp = await group.Group.async_create_group( - hass, "chromecasts", ["cast.living_room", "cast.bedroom"] + hass, + "chromecasts", + created_by_service=False, + entity_ids=["cast.living_room", "cast.bedroom"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert grp.state is None @@ -76,7 +97,16 @@ async def test_setup_group_with_non_groupable_states(hass: HomeAssistant) -> Non async def test_setup_empty_group(hass: HomeAssistant) -> None: """Try to set up an empty group.""" - grp = await group.Group.async_create_group(hass, "nothing", []) + grp = await group.Group.async_create_group( + hass, + "nothing", + created_by_service=False, + entity_ids=[], + icon=None, + mode=None, + object_id=None, + order=None, + ) assert grp.state is None @@ -89,7 +119,14 @@ async def test_monitor_group(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) # Test if group setup in our init mode is ok @@ -108,7 +145,14 @@ async def test_group_turns_off_if_all_off(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.async_block_till_done() @@ -127,7 +171,14 @@ async def test_group_turns_on_if_all_are_off_and_one_turns_on( assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) # Turn one on @@ -148,7 +199,14 @@ async def test_allgroup_stays_off_if_all_are_off_and_one_turns_on( assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False, mode=True + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=True, + object_id=None, + order=None, ) # Turn one on @@ -167,7 +225,14 @@ async def test_allgroup_turn_on_if_last_turns_on(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False, mode=True + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=True, + object_id=None, + order=None, ) # Turn one on @@ -186,7 +251,14 @@ async def test_expand_entity_ids(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert sorted(["light.ceiling", "light.bowl"]) == sorted( @@ -204,7 +276,14 @@ async def test_expand_entity_ids_does_not_return_duplicates( assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert ["light.bowl", "light.ceiling"] == sorted( @@ -226,8 +305,12 @@ async def test_expand_entity_ids_recursive(hass: HomeAssistant) -> None: test_group = await group.Group.async_create_group( hass, "init_group", - ["light.Bowl", "light.Ceiling", "group.init_group"], - False, + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling", "group.init_group"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert sorted(["light.ceiling", "light.bowl"]) == sorted( @@ -248,7 +331,14 @@ async def test_get_entity_ids(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert ["light.bowl", "light.ceiling"] == sorted( @@ -263,7 +353,14 @@ async def test_get_entity_ids_with_domain_filter(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) mixed_group = await group.Group.async_create_group( - hass, "mixed_group", ["light.Bowl", "switch.AC"], False + hass, + "mixed_group", + created_by_service=True, + entity_ids=["light.Bowl", "switch.AC"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert ["switch.ac"] == group.get_entity_ids( @@ -293,7 +390,14 @@ async def test_group_being_init_before_first_tracked_state_is_set_to_on( assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "test group", ["light.not_there_1"] + hass, + "test group", + created_by_service=False, + entity_ids=["light.not_there_1"], + icon=None, + mode=None, + object_id=None, + order=None, ) hass.states.async_set("light.not_there_1", STATE_ON) @@ -314,7 +418,14 @@ async def test_group_being_init_before_first_tracked_state_is_set_to_off( """ assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "test group", ["light.not_there_1"] + hass, + "test group", + created_by_service=False, + entity_ids=["light.not_there_1"], + icon=None, + mode=None, + object_id=None, + order=None, ) hass.states.async_set("light.not_there_1", STATE_OFF) @@ -330,8 +441,26 @@ async def test_groups_get_unique_names(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) - grp1 = await group.Group.async_create_group(hass, "Je suis Charlie") - grp2 = await group.Group.async_create_group(hass, "Je suis Charlie") + grp1 = await group.Group.async_create_group( + hass, + "Je suis Charlie", + created_by_service=False, + entity_ids=None, + icon=None, + mode=None, + object_id=None, + order=None, + ) + grp2 = await group.Group.async_create_group( + hass, + "Je suis Charlie", + created_by_service=False, + entity_ids=None, + icon=None, + mode=None, + object_id=None, + order=None, + ) assert grp1.entity_id != grp2.entity_id @@ -342,13 +471,34 @@ async def test_expand_entity_ids_expands_nested_groups(hass: HomeAssistant) -> N assert await async_setup_component(hass, "group", {}) await group.Group.async_create_group( - hass, "light", ["light.test_1", "light.test_2"] + hass, + "light", + created_by_service=False, + entity_ids=["light.test_1", "light.test_2"], + icon=None, + mode=None, + object_id=None, + order=None, ) await group.Group.async_create_group( - hass, "switch", ["switch.test_1", "switch.test_2"] + hass, + "switch", + created_by_service=False, + entity_ids=["switch.test_1", "switch.test_2"], + icon=None, + mode=None, + object_id=None, + order=None, ) await group.Group.async_create_group( - hass, "group_of_groups", ["group.light", "group.switch"] + hass, + "group_of_groups", + created_by_service=False, + entity_ids=["group.light", "group.switch"], + icon=None, + mode=None, + object_id=None, + order=None, ) assert [ @@ -367,7 +517,14 @@ async def test_set_assumed_state_based_on_tracked(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling", "sensor.no_exist"] + hass, + "init_group", + created_by_service=False, + entity_ids=["light.Bowl", "light.Ceiling", "sensor.no_exist"], + icon=None, + mode=None, + object_id=None, + order=None, ) state = hass.states.get(test_group.entity_id) @@ -398,7 +555,14 @@ async def test_group_updated_after_device_tracker_zone_change( assert await async_setup_component(hass, "device_tracker", {}) await group.Group.async_create_group( - hass, "peeps", ["device_tracker.Adam", "device_tracker.Eve"] + hass, + "peeps", + created_by_service=False, + entity_ids=["device_tracker.Adam", "device_tracker.Eve"], + icon=None, + mode=None, + object_id=None, + order=None, ) hass.states.async_set("device_tracker.Adam", "cool_state_not_home") @@ -417,7 +581,14 @@ async def test_is_on(hass: HomeAssistant) -> None: await hass.async_block_till_done() test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.async_block_till_done() @@ -446,7 +617,14 @@ async def test_reloading_groups(hass: HomeAssistant) -> None: await hass.async_block_till_done() await group.Group.async_create_group( - hass, "all tests", ["test.one", "test.two"], user_defined=False + hass, + "all tests", + created_by_service=True, + entity_ids=["test.one", "test.two"], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.async_block_till_done() @@ -523,14 +701,24 @@ async def test_setup(hass: HomeAssistant) -> None: await hass.async_block_till_done() test_group = await group.Group.async_create_group( - hass, "init_group", ["light.Bowl", "light.Ceiling"], False + hass, + "init_group", + created_by_service=True, + entity_ids=["light.Bowl", "light.Ceiling"], + icon=None, + mode=None, + object_id=None, + order=None, ) await group.Group.async_create_group( hass, "created_group", - ["light.Bowl", f"{test_group.entity_id}"], - True, - "mdi:work", + created_by_service=False, + entity_ids=["light.Bowl", f"{test_group.entity_id}"], + icon="mdi:work", + mode=None, + object_id=None, + order=None, ) await hass.async_block_till_done() diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 99e1de6e763350..4bf3e29154e3a5 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -17,6 +17,7 @@ async_get_addon_store_info, hostname_from_addon_slug, ) +from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -244,7 +245,7 @@ async def test_setup_api_ping( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert hass.components.hassio.get_core_info()["version_latest"] == "1.0.0" assert hass.components.hassio.is_hassio() @@ -289,7 +290,7 @@ async def test_setup_api_push_api_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert aioclient_mock.mock_calls[1][2]["watchdog"] @@ -308,7 +309,7 @@ async def test_setup_api_push_api_data_server_host( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert not aioclient_mock.mock_calls[1][2]["watchdog"] @@ -325,7 +326,7 @@ async def test_setup_api_push_api_data_default( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 refresh_token = aioclient_mock.mock_calls[1][2]["refresh_token"] @@ -405,7 +406,7 @@ async def test_setup_api_existing_hassio_user( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 assert aioclient_mock.mock_calls[1][2]["refresh_token"] == token.token @@ -422,7 +423,7 @@ async def test_setup_core_push_timezone( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert aioclient_mock.mock_calls[2][2]["timezone"] == "testzone" with patch("homeassistant.util.dt.set_default_time_zone"): @@ -442,7 +443,7 @@ async def test_setup_hassio_no_additional_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" @@ -524,14 +525,14 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 26 + assert aioclient_mock.call_count == 24 assert aioclient_mock.mock_calls[-1][2] == "test" await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count == 28 + assert aioclient_mock.call_count == 26 await hass.services.async_call("hassio", "backup_full", {}) await hass.services.async_call( @@ -546,7 +547,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 30 + assert aioclient_mock.call_count == 28 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "homeassistant": True, @@ -571,7 +572,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 32 + assert aioclient_mock.call_count == 30 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -590,7 +591,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 33 + assert aioclient_mock.call_count == 31 assert aioclient_mock.mock_calls[-1][2] == { "name": "backup_name", "location": "backup_share", @@ -606,7 +607,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 34 + assert aioclient_mock.call_count == 32 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "location": None, @@ -624,7 +625,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 36 + assert aioclient_mock.call_count == 34 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "location": None, @@ -896,6 +897,7 @@ async def test_coordinator_updates( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + # Initial refresh without stats assert refresh_updates_mock.call_count == 1 with patch( @@ -919,10 +921,89 @@ async def test_coordinator_updates( }, blocking=True, ) + assert refresh_updates_mock.call_count == 0 + + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.hassio.HassIO.refresh_updates", + side_effect=HassioAPIError("Unknown"), + ) as refresh_updates_mock: + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + assert refresh_updates_mock.call_count == 1 + assert "Error on Supervisor API: Unknown" in caplog.text + + +async def test_coordinator_updates_stats_entities_enabled( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + entity_registry_enabled_by_default: None, +) -> None: + """Test coordinator updates with stats entities enabled.""" + await async_setup_component(hass, "homeassistant", {}) + with patch.dict(os.environ, MOCK_ENVIRON), patch( + "homeassistant.components.hassio.HassIO.refresh_updates" + ) as refresh_updates_mock: + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Initial refresh without stats assert refresh_updates_mock.call_count == 1 - # There is a 10s cooldown on the debouncer - async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) + # Refresh with stats once we know which ones are needed + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + assert refresh_updates_mock.call_count == 2 + + with patch( + "homeassistant.components.hassio.HassIO.refresh_updates", + ) as refresh_updates_mock: + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) + await hass.async_block_till_done() + assert refresh_updates_mock.call_count == 0 + + with patch( + "homeassistant.components.hassio.HassIO.refresh_updates", + ) as refresh_updates_mock: + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + assert refresh_updates_mock.call_count == 0 + + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) await hass.async_block_till_done() with patch( @@ -940,6 +1021,11 @@ async def test_coordinator_updates( }, blocking=True, ) + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() assert refresh_updates_mock.call_count == 1 assert "Error on Supervisor API: Unknown" in caplog.text @@ -973,7 +1059,7 @@ async def test_setup_hardware_integration( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 817bf871fef1bd..fbc6f08a1f51f0 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -10,6 +10,7 @@ HASSIO_UPDATE_INTERVAL, HassioAPIError, ) +from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -245,6 +246,12 @@ async def test_sensor( await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + # Verify that the entity have the expected state. state = hass.states.get(entity_id) assert state.state == expected @@ -306,6 +313,12 @@ async def test_stats_addon_sensor( await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + # Verify that the entity have the expected state. state = hass.states.get(entity_id) assert state.state == expected diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 3f12874ef52eb1..42918b02266de4 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -1,16 +1,18 @@ """The tests for the hassio update entities.""" +from datetime import timedelta import os from unittest.mock import patch import pytest -from homeassistant.components.hassio import DOMAIN -from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.components.hassio import DOMAIN, HassioAPIError +from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -609,8 +611,13 @@ async def test_setting_up_core_update_when_addon_fails( await hass.async_block_till_done() assert result + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + # Verify that the core update entity does exist state = hass.states.get("update.home_assistant_core_update") assert state assert state.state == "on" - assert "Could not fetch stats for test: add-on is not running" in caplog.text diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 9048e03ea703f7..22b380a3249ca2 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -11,6 +11,7 @@ import homeassistant.components as comps from homeassistant.components.homeassistant import ( ATTR_ENTRY_ID, + ATTR_SAFE_MODE, SERVICE_CHECK_CONFIG, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, @@ -536,22 +537,32 @@ async def test_raises_when_config_is_invalid( assert mock_async_check_ha_config_file.called -async def test_restart_homeassistant(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("service_data", "safe_mode_enabled"), + [({}, False), ({ATTR_SAFE_MODE: False}, False), ({ATTR_SAFE_MODE: True}, True)], +) +async def test_restart_homeassistant( + hass: HomeAssistant, service_data: dict, safe_mode_enabled: bool +) -> None: """Test we can restart when there is no configuration error.""" await async_setup_component(hass, "homeassistant", {}) with patch( "homeassistant.config.async_check_ha_config_file", return_value=None ) as mock_check, patch( + "homeassistant.config.async_enable_safe_mode" + ) as mock_safe_mode, patch( "homeassistant.core.HomeAssistant.async_stop", return_value=None ) as mock_restart: await hass.services.async_call( "homeassistant", SERVICE_HOMEASSISTANT_RESTART, + service_data, blocking=True, ) assert mock_check.called await hass.async_block_till_done() assert mock_restart.called + assert mock_safe_mode.called == safe_mode_enabled async def test_stop_homeassistant(hass: HomeAssistant) -> None: diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..5e1025a8d318ed --- /dev/null +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -0,0 +1,71 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'data': dict({ + 'active_current_l1_a': -4, + 'active_current_l2_a': 2, + 'active_current_l3_a': 0, + 'active_frequency_hz': 50, + 'active_liter_lpm': 12.345, + 'active_power_average_w': 123.0, + 'active_power_l1_w': -123, + 'active_power_l2_w': 456, + 'active_power_l3_w': 123.456, + 'active_power_w': -123, + 'active_tariff': 2, + 'active_voltage_l1_v': 230.111, + 'active_voltage_l2_v': 230.222, + 'active_voltage_l3_v': 230.333, + 'any_power_fail_count': 4, + 'external_devices': None, + 'gas_timestamp': '2021-03-14T11:22:33', + 'gas_unique_id': '**REDACTED**', + 'long_power_fail_count': 5, + 'meter_model': 'ISKRA 2M550T-101', + 'monthly_power_peak_timestamp': '2023-01-01T08:00:10', + 'monthly_power_peak_w': 1111.0, + 'smr_version': 50, + 'total_gas_m3': 1122.333, + 'total_liter_m3': 1234.567, + 'total_power_export_kwh': 13086.777, + 'total_power_export_t1_kwh': 4321.333, + 'total_power_export_t2_kwh': 8765.444, + 'total_power_export_t3_kwh': None, + 'total_power_export_t4_kwh': None, + 'total_power_import_kwh': 13779.338, + 'total_power_import_t1_kwh': 10830.511, + 'total_power_import_t2_kwh': 2948.827, + 'total_power_import_t3_kwh': None, + 'total_power_import_t4_kwh': None, + 'unique_meter_id': '**REDACTED**', + 'voltage_sag_l1_count': 1, + 'voltage_sag_l2_count': 2, + 'voltage_sag_l3_count': 3, + 'voltage_swell_l1_count': 4, + 'voltage_swell_l2_count': 5, + 'voltage_swell_l3_count': 6, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 100, + }), + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '2.11', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-SKT', + 'serial': '**REDACTED**', + }), + 'state': dict({ + 'brightness': 255, + 'power_on': True, + 'switch_lock': False, + }), + 'system': dict({ + 'cloud_enabled': True, + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 7c6fb0bdb0d7f8..770496b5612c75 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -43,7 +43,7 @@ async def test_manual_flow_works( ) assert result["type"] == "create_entry" - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "P1 meter" assert result["data"][CONF_IP_ADDRESS] == "2.2.2.2" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -68,8 +68,8 @@ async def test_discovery_flow_works( properties={ "api_enabled": "1", "path": "/api/v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", + "product_name": "Energy Socket", + "product_type": "HWE-SKT", "serial": "aabbccddeeff", }, ) @@ -109,11 +109,11 @@ async def test_discovery_flow_works( ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "Energy Socket" assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" assert result["result"] - assert result["result"].unique_id == "HWE-P1_aabbccddeeff" + assert result["result"].unique_id == "HWE-SKT_aabbccddeeff" async def test_discovery_flow_during_onboarding( @@ -149,7 +149,7 @@ async def test_discovery_flow_during_onboarding( ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "P1 meter" assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" assert result["result"] @@ -214,7 +214,7 @@ def mock_initialize(): ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "P1 meter" assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" assert result["result"] diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index 64e8b0c6dfd4f0..9e9797439b332e 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -1,6 +1,7 @@ """Tests for diagnostics data.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy.assertion import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -12,67 +13,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "entry": {"ip_address": REDACTED}, - "data": { - "device": { - "product_name": "P1 Meter", - "product_type": "HWE-SKT", - "serial": REDACTED, - "api_version": "v1", - "firmware_version": "2.11", - }, - "data": { - "wifi_ssid": REDACTED, - "wifi_strength": 100, - "smr_version": 50, - "meter_model": "ISKRA 2M550T-101", - "unique_meter_id": REDACTED, - "active_tariff": 2, - "total_power_import_kwh": 13779.338, - "total_power_import_t1_kwh": 10830.511, - "total_power_import_t2_kwh": 2948.827, - "total_power_import_t3_kwh": None, - "total_power_import_t4_kwh": None, - "total_power_export_kwh": 13086.777, - "total_power_export_t1_kwh": 4321.333, - "total_power_export_t2_kwh": 8765.444, - "total_power_export_t3_kwh": None, - "total_power_export_t4_kwh": None, - "active_power_w": -123, - "active_power_l1_w": -123, - "active_power_l2_w": 456, - "active_power_l3_w": 123.456, - "active_voltage_l1_v": 230.111, - "active_voltage_l2_v": 230.222, - "active_voltage_l3_v": 230.333, - "active_current_l1_a": -4, - "active_current_l2_a": 2, - "active_current_l3_a": 0, - "active_frequency_hz": 50, - "voltage_sag_l1_count": 1, - "voltage_sag_l2_count": 2, - "voltage_sag_l3_count": 3, - "voltage_swell_l1_count": 4, - "voltage_swell_l2_count": 5, - "voltage_swell_l3_count": 6, - "any_power_fail_count": 4, - "long_power_fail_count": 5, - "active_power_average_w": 123.0, - "monthly_power_peak_w": 1111.0, - "monthly_power_peak_timestamp": "2023-01-01T08:00:10", - "total_gas_m3": 1122.333, - "gas_timestamp": "2021-03-14T11:22:33", - "gas_unique_id": REDACTED, - "active_liter_lpm": 12.345, - "total_liter_m3": 1234.567, - "external_devices": None, - }, - "state": {"power_on": True, "switch_lock": False, "brightness": 255}, - "system": {"cloud_enabled": True}, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 1dd20bc1350946..c32abecbd0b1a5 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -217,6 +217,7 @@ async def test_light_turn_off_service( # verify the light is on before we start assert hass.states.get(test_light_id).state == "on" + brightness_pct = hass.states.get(test_light_id).attributes["brightness"] / 255 * 100 # now call the HA turn_off service await hass.services.async_call( @@ -256,6 +257,23 @@ async def test_light_turn_off_service( assert mock_bridge_v2.mock_requests[1]["json"]["on"]["on"] is False assert mock_bridge_v2.mock_requests[1]["json"]["dynamics"]["duration"] == 200 + # test turn_on resets brightness + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 3 + assert mock_bridge_v2.mock_requests[2]["json"]["on"]["on"] is True + assert ( + round( + mock_bridge_v2.mock_requests[2]["json"]["dimming"]["brightness"] + - brightness_pct + ) + == 0 + ) + # test again with sending long flash await hass.services.async_call( "light", @@ -263,8 +281,8 @@ async def test_light_turn_off_service( {"entity_id": test_light_id, "flash": "long"}, blocking=True, ) - assert len(mock_bridge_v2.mock_requests) == 3 - assert mock_bridge_v2.mock_requests[2]["json"]["alert"]["action"] == "breathe" + assert len(mock_bridge_v2.mock_requests) == 4 + assert mock_bridge_v2.mock_requests[3]["json"]["alert"]["action"] == "breathe" # test again with sending short flash await hass.services.async_call( @@ -273,8 +291,8 @@ async def test_light_turn_off_service( {"entity_id": test_light_id, "flash": "short"}, blocking=True, ) - assert len(mock_bridge_v2.mock_requests) == 4 - assert mock_bridge_v2.mock_requests[3]["json"]["identify"]["action"] == "identify" + assert len(mock_bridge_v2.mock_requests) == 5 + assert mock_bridge_v2.mock_requests[4]["json"]["identify"]["action"] == "identify" async def test_light_added(hass: HomeAssistant, mock_bridge_v2) -> None: @@ -481,6 +499,17 @@ async def test_grouped_lights( assert mock_bridge_v2.mock_requests[0]["json"]["on"]["on"] is False assert mock_bridge_v2.mock_requests[0]["json"]["dynamics"]["duration"] == 200 + # Test turn_on resets brightness + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 2 + assert mock_bridge_v2.mock_requests[1]["json"]["on"]["on"] is True + assert mock_bridge_v2.mock_requests[1]["json"]["dimming"]["brightness"] == 100 + # Test sending short flash effect to a grouped light mock_bridge_v2.mock_requests.clear() test_light_id = "light.test_zone" diff --git a/tests/components/huisbaasje/test_sensor.py b/tests/components/huisbaasje/test_sensor.py index 484dc8bac488a4..3f0bdae8e53884 100644 --- a/tests/components/huisbaasje/test_sensor.py +++ b/tests/components/huisbaasje/test_sensor.py @@ -222,10 +222,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: energy_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY ) assert energy_today.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" - assert ( - energy_today.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert energy_today.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( energy_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR @@ -239,8 +236,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) assert energy_this_week.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - energy_this_week.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING + energy_this_week.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL ) assert ( energy_this_week.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -255,8 +251,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) assert energy_this_month.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - energy_this_month.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING + energy_this_month.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL ) assert ( energy_this_month.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -271,8 +266,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) assert energy_this_year.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - energy_this_year.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING + energy_this_year.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL ) assert ( energy_this_year.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -295,10 +289,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_today.state == "1.1" assert gas_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_today.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_today.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_today.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -308,10 +299,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_week.state == "5.6" assert gas_this_week.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_week.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_this_week.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_this_week.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_this_week.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -321,10 +309,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_month.state == "39.1" assert gas_this_month.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_month.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_this_month.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_this_month.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_this_month.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -334,10 +319,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_year.state == "116.7" assert gas_this_year.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_year.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_this_year.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_this_year.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_this_year.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS diff --git a/tests/components/humidifier/test_device_action.py b/tests/components/humidifier/test_device_action.py index 600be154fc7514..ff508bd3a676a7 100644 --- a/tests/components/humidifier/test_device_action.py +++ b/tests/components/humidifier/test_device_action.py @@ -142,9 +142,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -164,7 +176,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -176,7 +188,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -185,7 +197,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_toggle"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "toggle", }, @@ -197,7 +209,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_humidity", "humidity": 35, @@ -210,7 +222,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_mode", "mode": const.MODE_AWAY, @@ -290,10 +302,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -313,7 +335,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "set_mode", "mode": const.MODE_AWAY, diff --git a/tests/components/humidifier/test_device_condition.py b/tests/components/humidifier/test_device_condition.py index bf8eb98f456b43..224c69b9fb5494 100644 --- a/tests/components/humidifier/test_device_condition.py +++ b/tests/components/humidifier/test_device_condition.py @@ -149,10 +149,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON, {ATTR_MODE: const.MODE_AWAY}) @@ -167,7 +178,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -186,7 +197,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -205,7 +216,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_mode", "mode": "away", @@ -254,10 +265,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON, {ATTR_MODE: const.MODE_AWAY}) @@ -272,7 +294,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_mode", "mode": "away", diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index 1953494e0c095a..34067d96ff2695 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -162,10 +162,21 @@ async def test_get_triggers_hidden_auxiliary( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -188,7 +199,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "target_humidity_changed", "below": 20, @@ -202,7 +213,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "target_humidity_changed", "above": 30, @@ -216,7 +227,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "target_humidity_changed", "above": 30, @@ -231,7 +242,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_humidity_changed", "below": 30, @@ -245,7 +256,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_humidity_changed", "above": 40, @@ -259,7 +270,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_humidity_changed", "above": 40, @@ -274,7 +285,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -298,7 +309,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", }, @@ -322,7 +333,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "changed_states", }, @@ -423,10 +434,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -448,7 +470,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "target_humidity_changed", "below": 20, diff --git a/tests/components/improv_ble/__init__.py b/tests/components/improv_ble/__init__.py new file mode 100644 index 00000000000000..f1c83bbc0d74e6 --- /dev/null +++ b/tests/components/improv_ble/__init__.py @@ -0,0 +1,60 @@ +"""Tests for the Improv via BLE integration.""" + +from improv_ble_client import SERVICE_DATA_UUID, SERVICE_UUID + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak + +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device + +IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="00123456", + address="AA:BB:CC:DD:EE:F0", + rssi=-60, + manufacturer_data={}, + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x01\x00\x00\x00\x00\x00"}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:F0", name="00123456"), + advertisement=generate_advertisement_data( + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x01\x00\x00\x00\x00\x00"}, + ), + time=0, + connectable=True, +) + + +PROVISIONED_IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="00123456", + address="AA:BB:CC:DD:EE:F0", + rssi=-60, + manufacturer_data={}, + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x04\x00\x00\x00\x00\x00"}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:F0", name="00123456"), + advertisement=generate_advertisement_data( + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x04\x00\x00\x00\x00\x00"}, + ), + time=0, + connectable=True, +) + + +NOT_IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="Not", + address="AA:BB:CC:DD:EE:F2", + rssi=-60, + manufacturer_data={ + 33: b"\x00\x00\xd1\xf0b;\xd8\x1dE\xd6\xba\xeeL\xdd]\xf5\xb2\xe9", + 21: b"\x061\x00Z\x8f\x93\xb2\xec\x85\x06\x00i\x00\x02\x02Q\xed\x1d\xf0", + }, + service_uuids=[], + service_data={}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:F2", name="Aug"), + advertisement=generate_advertisement_data(), + time=0, + connectable=True, +) diff --git a/tests/components/improv_ble/conftest.py b/tests/components/improv_ble/conftest.py new file mode 100644 index 00000000000000..ea548efeb15154 --- /dev/null +++ b/tests/components/improv_ble/conftest.py @@ -0,0 +1,8 @@ +"""Improv via BLE test fixtures.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py new file mode 100644 index 00000000000000..f0c77c9bce3a06 --- /dev/null +++ b/tests/components/improv_ble/test_config_flow.py @@ -0,0 +1,647 @@ +"""Test the Improv via BLE config flow.""" +from collections.abc import Callable +from unittest.mock import patch + +from bleak.exc import BleakError +from improv_ble_client import Error, State, errors as improv_ble_errors +import pytest + +from homeassistant import config_entries +from homeassistant.components.bluetooth import BluetoothChange +from homeassistant.components.improv_ble.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType + +from . import ( + IMPROV_BLE_DISCOVERY_INFO, + NOT_IMPROV_BLE_DISCOVERY_INFO, + PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, +) + +IMPROV_BLE = "homeassistant.components.improv_ble" + + +@pytest.mark.parametrize( + ("url", "abort_reason", "placeholders"), + [ + ("http://bla.local", "provision_successful_url", {"url": "http://bla.local"}), + (None, "provision_successful", None), + ], +) +async def test_user_step_success( + hass: HomeAssistant, + url: str | None, + abort_reason: str | None, + placeholders: dict[str, str] | None, +) -> None: + """Test user step success path.""" + with patch( + f"{IMPROV_BLE}.config_flow.bluetooth.async_discovered_service_info", + return_value=[NOT_IMPROV_BLE_DISCOVERY_INFO, IMPROV_BLE_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + await _test_common_success_wo_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address, url, abort_reason, placeholders + ) + + +async def test_user_step_success_authorize(hass: HomeAssistant) -> None: + """Test user step success path.""" + with patch( + f"{IMPROV_BLE}.config_flow.bluetooth.async_discovered_service_info", + return_value=[NOT_IMPROV_BLE_DISCOVERY_INFO, IMPROV_BLE_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + await _test_common_success_wo_identify_w_authorize( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) + + +async def test_user_step_no_devices_found(hass: HomeAssistant) -> None: + """Test user step with no devices found.""" + with patch( + f"{IMPROV_BLE}.config_flow.bluetooth.async_discovered_service_info", + return_value=[ + PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, + NOT_IMPROV_BLE_DISCOVERY_INFO, + ], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_async_step_user_takes_precedence_over_discovery( + hass: HomeAssistant, +) -> None: + """Test manual setup takes precedence over discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + with patch( + f"{IMPROV_BLE}.config_flow.bluetooth.async_discovered_service_info", + return_value=[IMPROV_BLE_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + + await _test_common_success_wo_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) + + # Verify the discovery flow was aborted + assert not hass.config_entries.flow.async_progress(DOMAIN) + + +async def test_bluetooth_step_provisioned_device(hass: HomeAssistant) -> None: + """Test bluetooth step when device is already provisioned.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_provisioned" + + +async def test_bluetooth_step_provisioned_device_2(hass: HomeAssistant) -> None: + """Test bluetooth step when device changes to provisioned.""" + with patch( + f"{IMPROV_BLE}.config_flow.bluetooth.async_register_callback", + ) as mock_async_register_callback: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + assert len(hass.config_entries.flow.async_progress_by_handler("improv_ble")) == 1 + + callback = mock_async_register_callback.call_args.args[1] + callback(PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, BluetoothChange.ADVERTISEMENT) + + assert len(hass.config_entries.flow.async_progress_by_handler("improv_ble")) == 0 + + +async def test_bluetooth_step_success(hass: HomeAssistant) -> None: + """Test bluetooth step success path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + await _test_common_success_wo_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) + + +async def test_bluetooth_step_success_identify(hass: HomeAssistant) -> None: + """Test bluetooth step success path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + await _test_common_success_with_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) + + +async def _test_common_success_with_identify( + hass: HomeAssistant, result: FlowResult, address: str +) -> None: + """Test bluetooth and user flow success paths.""" + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: address}, + ) + assert result["type"] == FlowResultType.MENU + assert result["menu_options"] == ["identify", "provision"] + assert result["step_id"] == "main_menu" + + with patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.identify"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "identify"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "identify" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.MENU + assert result["menu_options"] == ["identify", "provision"] + assert result["step_id"] == "main_menu" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "provision"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + assert result["errors"] is None + + await _test_common_success(hass, result) + + +async def _test_common_success_wo_identify( + hass: HomeAssistant, + result: FlowResult, + address: str, + url: str | None = None, + abort_reason: str = "provision_successful", + placeholders: dict[str, str] | None = None, +) -> None: + """Test bluetooth and user flow success paths.""" + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: address}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + assert result["errors"] is None + + await _test_common_success(hass, result) + + +async def _test_common_success( + hass: HomeAssistant, + result: FlowResult, + url: str | None = None, + abort_reason: str = "provision_successful", + placeholders: dict[str, str] | None = None, +) -> None: + """Test bluetooth and user flow success paths.""" + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=False, + ), patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", + return_value=url, + ) as mock_provision: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} + ) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "provisioning" + assert result["step_id"] == "do_provision" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "provision_done" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("description_placeholders") == placeholders + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == abort_reason + + mock_provision.assert_awaited_once_with("MyWIFI", "secret", None) + + +async def _test_common_success_wo_identify_w_authorize( + hass: HomeAssistant, result: FlowResult, address: str +) -> None: + """Test bluetooth and user flow success paths.""" + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: address}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + assert result["errors"] is None + + await _test_common_success_w_authorize(hass, result) + + +async def _test_common_success_w_authorize( + hass: HomeAssistant, result: FlowResult +) -> None: + """Test bluetooth and user flow success paths.""" + + async def subscribe_state_updates( + state_callback: Callable[[State], None] + ) -> Callable[[], None]: + state_callback(State.AUTHORIZED) + return lambda: None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=True, + ), patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.subscribe_state_updates", + side_effect=subscribe_state_updates, + ) as mock_subscribe_state_updates: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} + ) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "authorize" + assert result["step_id"] == "authorize" + mock_subscribe_state_updates.assert_awaited_once() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "provision" + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=False, + ), patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", + return_value="http://blabla.local", + ) as mock_provision: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "provisioning" + assert result["step_id"] == "do_provision" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "provision_done" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["description_placeholders"] == {"url": "http://blabla.local"} + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "provision_successful_url" + + mock_provision.assert_awaited_once_with("MyWIFI", "secret", None) + + +async def test_bluetooth_step_already_in_progress(hass: HomeAssistant) -> None: + """Test we can't start a flow for the same device twice.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + (BleakError, "cannot_connect"), + (Exception, "unknown"), + (improv_ble_errors.CharacteristicMissingError, "characteristic_missing"), + ), +) +async def test_can_identify_fails(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", side_effect=exc + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + (BleakError, "cannot_connect"), + (Exception, "unknown"), + (improv_ble_errors.CharacteristicMissingError, "characteristic_missing"), + ), +) +async def test_identify_fails(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "main_menu" + + with patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.identify", side_effect=exc): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "identify"}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + (BleakError, "cannot_connect"), + (Exception, "unknown"), + (improv_ble_errors.CharacteristicMissingError, "characteristic_missing"), + ), +) +async def test_need_authorization_fails(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", side_effect=exc + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + (BleakError, "cannot_connect"), + (Exception, "unknown"), + (improv_ble_errors.CharacteristicMissingError, "characteristic_missing"), + ), +) +async def test_authorize_fails(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=True, + ), patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.subscribe_state_updates", + side_effect=exc, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == error + + +async def _test_provision_error(hass: HomeAssistant, exc) -> None: + """Test bluetooth flow with error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=False, + ), patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", + side_effect=exc, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} + ) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "provisioning" + assert result["step_id"] == "do_provision" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "provision_done" + + return result["flow_id"] + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + (BleakError, "cannot_connect"), + (Exception, "unknown"), + (improv_ble_errors.CharacteristicMissingError, "characteristic_missing"), + (improv_ble_errors.ProvisioningFailed(Error.UNKNOWN_ERROR), "unknown"), + ), +) +async def test_provision_fails(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + flow_id = await _test_provision_error(hass, exc) + + result = await hass.config_entries.flow.async_configure(flow_id) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.parametrize( + ("exc", "error"), + ((improv_ble_errors.ProvisioningFailed(Error.NOT_AUTHORIZED), "unknown"),), +) +async def test_provision_not_authorized(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + + async def subscribe_state_updates( + state_callback: Callable[[State], None] + ) -> Callable[[], None]: + state_callback(State.AUTHORIZED) + return lambda: None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.subscribe_state_updates", + side_effect=subscribe_state_updates, + ): + flow_id = await _test_provision_error(hass, exc) + result = await hass.config_entries.flow.async_configure(flow_id) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "authorize" + assert result["step_id"] == "authorize" + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + ( + improv_ble_errors.ProvisioningFailed(Error.UNABLE_TO_CONNECT), + "unable_to_connect", + ), + ), +) +async def test_provision_retry(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + flow_id = await _test_provision_error(hass, exc) + + result = await hass.config_entries.flow.async_configure(flow_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + assert result["errors"] == {"base": error} diff --git a/tests/components/jellyfin/const.py b/tests/components/jellyfin/const.py index 4953824a1c536f..157c25b4af45f6 100644 --- a/tests/components/jellyfin/const.py +++ b/tests/components/jellyfin/const.py @@ -2,6 +2,18 @@ from typing import Final +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME + TEST_URL: Final = "https://example.com" TEST_USERNAME: Final = "test-username" TEST_PASSWORD: Final = "test-password" + +USER_INPUT: Final = { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, +} + +REAUTH_INPUT: Final = { + CONF_PASSWORD: TEST_PASSWORD, +} diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index 51aa4bccc92115..c59efd7efb9bf1 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from . import async_load_json_fixture -from .const import TEST_PASSWORD, TEST_URL, TEST_USERNAME +from .const import REAUTH_INPUT, TEST_PASSWORD, TEST_URL, TEST_USERNAME, USER_INPUT from tests.common import MockConfigEntry @@ -44,11 +44,7 @@ async def test_form( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -73,7 +69,7 @@ async def test_form_cannot_connect( mock_client: MagicMock, mock_client_device_id: MagicMock, ) -> None: - """Test we handle an unreachable server.""" + """Test configuration with an unreachable server.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -86,11 +82,7 @@ async def test_form_cannot_connect( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -106,7 +98,7 @@ async def test_form_invalid_auth( mock_client: MagicMock, mock_client_device_id: MagicMock, ) -> None: - """Test that we can handle invalid credentials.""" + """Test configuration with invalid credentials.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -119,11 +111,7 @@ async def test_form_invalid_auth( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -137,7 +125,7 @@ async def test_form_invalid_auth( async def test_form_exception( hass: HomeAssistant, mock_jellyfin: MagicMock, mock_client: MagicMock ) -> None: - """Test we handle an unexpected exception during server setup.""" + """Test configuration with an unexpected exception.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -148,11 +136,7 @@ async def test_form_exception( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -168,7 +152,7 @@ async def test_form_persists_device_id_on_error( mock_client: MagicMock, mock_client_device_id: MagicMock, ) -> None: - """Test that we can handle invalid credentials.""" + """Test persisting the device id on error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -182,11 +166,7 @@ async def test_form_persists_device_id_on_error( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -200,11 +180,7 @@ async def test_form_persists_device_id_on_error( result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -216,3 +192,244 @@ async def test_form_persists_device_id_on_error( CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, } + + +async def test_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test a reauth flow.""" + # Force a reauth + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address.json", + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # Complete the reauth + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login.json", + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_reauth_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test an unreachable server during a reauth flow.""" + # Force a reauth + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address.json", + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # Perform reauth with unreachable server + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, "auth-connect-address-failure.json" + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + + # Complete reauth with reachable server + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, "auth-connect-address.json" + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login.json", + ) + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + + +async def test_reauth_invalid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test invalid credentials during a reauth flow.""" + # Force a reauth + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address.json", + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # Perform reauth with invalid credentials + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + assert len(mock_client.auth.login.mock_calls) == 1 + + # Complete reauth with valid credentials + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login.json", + ) + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + + +async def test_reauth_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test an unexpected exception during a reauth flow.""" + # Force a reauth + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address.json", + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # Perform a reauth with an unknown exception + mock_client.auth.connect_to_address.side_effect = Exception("UnknownException") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + + # Complete the reauth without an exception + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login.json", + ) + mock_client.auth.connect_to_address.side_effect = None + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" diff --git a/tests/components/jellyfin/test_init.py b/tests/components/jellyfin/test_init.py index 9af73391d1855e..eb184592bb82a3 100644 --- a/tests/components/jellyfin/test_init.py +++ b/tests/components/jellyfin/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock from homeassistant.components.jellyfin.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -67,6 +67,10 @@ async def test_invalid_auth( mock_config_entry.add_to_hass(hass) assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + async def test_load_unload_config_entry( hass: HomeAssistant, diff --git a/tests/components/kodi/test_device_trigger.py b/tests/components/kodi/test_device_trigger.py index 3181f9781128e4..4dbfe3abb5742e 100644 --- a/tests/components/kodi/test_device_trigger.py +++ b/tests/components/kodi/test_device_trigger.py @@ -88,7 +88,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": entry.device_id, "entity_id": entry.id, "type": "turn_on", }, @@ -105,7 +105,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": entry.device_id, "entity_id": entry.id, "type": "turn_off", }, @@ -161,7 +161,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": entry.device_id, "entity_id": entry.entity_id, "type": "turn_on", }, diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py index 05483b46d9786e..3b60b886b0210e 100644 --- a/tests/components/light/test_device_action.py +++ b/tests/components/light/test_device_action.py @@ -467,12 +467,21 @@ async def test_get_action_capabilities_features_legacy( async def test_action( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -483,7 +492,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -492,7 +501,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_on"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -501,7 +510,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_toggle"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "toggle", }, @@ -510,7 +519,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_flash_short"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "flash", }, @@ -519,7 +528,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_flash_long"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "flash", "flash": "long", @@ -532,7 +541,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "brightness_increase", }, @@ -544,7 +553,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "brightness_decrease", }, @@ -553,7 +562,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_brightness"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", "brightness_pct": 75, @@ -623,12 +632,21 @@ async def test_action( async def test_action_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -639,7 +657,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turn_off", }, diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py index b38c225347ae5e..000784ce63ce2b 100644 --- a/tests/components/light/test_device_condition.py +++ b/tests/components/light/test_device_condition.py @@ -179,12 +179,21 @@ async def test_get_condition_capabilities_legacy( async def test_if_state( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -199,7 +208,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -218,7 +227,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -253,12 +262,21 @@ async def test_if_state( async def test_if_state_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -273,7 +291,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_on", } @@ -301,12 +319,21 @@ async def test_if_state_legacy( async def test_if_fires_on_for_condition( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for firing if condition is on with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -333,7 +360,7 @@ async def test_if_fires_on_for_condition( "condition": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", "for": {"seconds": 5}, diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py index 085193e3b34c57..5ee6752640edc6 100644 --- a/tests/components/light/test_device_trigger.py +++ b/tests/components/light/test_device_trigger.py @@ -177,12 +177,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -195,7 +204,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -219,7 +228,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", }, @@ -243,7 +252,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "changed_states", }, @@ -288,12 +297,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -306,7 +324,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_off", }, @@ -342,12 +360,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -360,7 +387,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, diff --git a/tests/components/local_todo/__init__.py b/tests/components/local_todo/__init__.py new file mode 100644 index 00000000000000..a96a2e85cbd52c --- /dev/null +++ b/tests/components/local_todo/__init__.py @@ -0,0 +1 @@ +"""Tests for the local_todo integration.""" diff --git a/tests/components/local_todo/conftest.py b/tests/components/local_todo/conftest.py new file mode 100644 index 00000000000000..5afa005dd64df8 --- /dev/null +++ b/tests/components/local_todo/conftest.py @@ -0,0 +1,104 @@ +"""Common fixtures for the local_todo tests.""" +from collections.abc import Generator +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.local_todo import LocalTodoListStore +from homeassistant.components.local_todo.const import ( + CONF_STORAGE_KEY, + CONF_TODO_LIST_NAME, + DOMAIN, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TODO_NAME = "My Tasks" +FRIENDLY_NAME = "My tasks" +STORAGE_KEY = "my_tasks" +TEST_ENTITY = "todo.my_tasks" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.local_todo.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +class FakeStore(LocalTodoListStore): + """Mock storage implementation.""" + + def __init__( + self, + hass: HomeAssistant, + path: Path, + ics_content: str | None, + read_side_effect: Any | None = None, + ) -> None: + """Initialize FakeStore.""" + mock_path = self._mock_path = Mock() + mock_path.exists = self._mock_exists + mock_path.read_text = Mock() + mock_path.read_text.return_value = ics_content + mock_path.read_text.side_effect = read_side_effect + mock_path.write_text = self._mock_write_text + + super().__init__(hass, mock_path) + + def _mock_exists(self) -> bool: + return self._mock_path.read_text.return_value is not None + + def _mock_write_text(self, content: str) -> None: + self._mock_path.read_text.return_value = content + + +@pytest.fixture(name="ics_content") +def mock_ics_content() -> str | None: + """Fixture to set .ics file content.""" + return "" + + +@pytest.fixture(name="store_read_side_effect") +def mock_store_read_side_effect() -> Any | None: + """Fixture to raise errors from the FakeStore.""" + return None + + +@pytest.fixture(name="store", autouse=True) +def mock_store( + ics_content: str, store_read_side_effect: Any | None +) -> Generator[None, None, None]: + """Fixture that sets up a fake local storage object.""" + + stores: dict[Path, FakeStore] = {} + + def new_store(hass: HomeAssistant, path: Path) -> FakeStore: + if path not in stores: + stores[path] = FakeStore(hass, path, ics_content, store_read_side_effect) + return stores[path] + + with patch("homeassistant.components.local_todo.LocalTodoListStore", new=new_store): + yield + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Fixture for mock configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_STORAGE_KEY: STORAGE_KEY, CONF_TODO_LIST_NAME: TODO_NAME}, + ) + + +@pytest.fixture(name="setup_integration") +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the integration.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/local_todo/test_config_flow.py b/tests/components/local_todo/test_config_flow.py new file mode 100644 index 00000000000000..6677a39e54a41c --- /dev/null +++ b/tests/components/local_todo/test_config_flow.py @@ -0,0 +1,64 @@ +"""Test the local_todo config flow.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.local_todo.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import STORAGE_KEY, TODO_NAME + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "todo_list_name": TODO_NAME, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == TODO_NAME + assert result2["data"] == { + "todo_list_name": TODO_NAME, + "storage_key": STORAGE_KEY, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_todo_list_name( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test two todo-lists cannot be added with the same name.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + # Pick a name that has the same slugify value as an existing config entry + "todo_list_name": "my tasks", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/local_todo/test_init.py b/tests/components/local_todo/test_init.py new file mode 100644 index 00000000000000..98da2ef3c122d5 --- /dev/null +++ b/tests/components/local_todo/test_init.py @@ -0,0 +1,60 @@ +"""Tests for init platform of local_todo.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import TEST_ENTITY + +from tests.common import MockConfigEntry + + +async def test_load_unload( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test loading and unloading a config entry.""" + + assert config_entry.state == ConfigEntryState.LOADED + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "unavailable" + + +async def test_remove_config_entry( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test removing a config entry.""" + + with patch("homeassistant.components.local_todo.Path.unlink") as unlink_mock: + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + unlink_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("store_read_side_effect"), + [ + (OSError("read error")), + ], +) +async def test_load_failure( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test failures loading the todo store.""" + + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + state = hass.states.get(TEST_ENTITY) + assert not state diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py new file mode 100644 index 00000000000000..8a7e38c9773dcc --- /dev/null +++ b/tests/components/local_todo/test_todo.py @@ -0,0 +1,420 @@ +"""Tests for todo platform of local_todo.""" + +from collections.abc import Awaitable, Callable +import textwrap + +import pytest + +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN +from homeassistant.core import HomeAssistant + +from .conftest import TEST_ENTITY + +from tests.typing import WebSocketGenerator + + +@pytest.fixture +def ws_req_id() -> Callable[[], int]: + """Fixture for incremental websocket requests.""" + + id = 0 + + def next() -> int: + nonlocal id + id += 1 + return id + + return next + + +@pytest.fixture +async def ws_get_items( + hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] +) -> Callable[[], Awaitable[dict[str, str]]]: + """Fixture to fetch items from the todo websocket.""" + + async def get() -> list[dict[str, str]]: + # Fetch items using To-do platform + client = await hass_ws_client() + id = ws_req_id() + await client.send_json( + { + "id": id, + "type": "todo/item/list", + "entity_id": TEST_ENTITY, + } + ) + resp = await client.receive_json() + assert resp.get("id") == id + assert resp.get("success") + return resp.get("result", {}).get("items", []) + + return get + + +@pytest.fixture +async def ws_move_item( + hass_ws_client: WebSocketGenerator, + ws_req_id: Callable[[], int], +) -> Callable[[str, str | None], Awaitable[None]]: + """Fixture to move an item in the todo list.""" + + async def move(uid: str, previous_uid: str | None) -> None: + # Fetch items using To-do platform + client = await hass_ws_client() + id = ws_req_id() + data = { + "id": id, + "type": "todo/item/move", + "entity_id": TEST_ENTITY, + "uid": uid, + } + if previous_uid is not None: + data["previous_uid"] = previous_uid + await client.send_json(data) + resp = await client.receive_json() + assert resp.get("id") == id + assert resp.get("success") + + return move + + +async def test_create_item( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test creating a todo item.""" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "replace batteries"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["summary"] == "replace batteries" + assert items[0]["status"] == "needs_action" + assert "uid" in items[0] + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + +async def test_delete_item( + hass: HomeAssistant, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test deleting a todo item.""" + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "replace batteries"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["summary"] == "replace batteries" + assert items[0]["status"] == "needs_action" + assert "uid" in items[0] + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + await hass.services.async_call( + TODO_DOMAIN, + "delete_item", + {"uid": [items[0]["uid"]]}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 0 + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + +async def test_bulk_delete( + hass: HomeAssistant, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test deleting multiple todo items.""" + for i in range(0, 5): + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": f"soda #{i}"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 5 + uids = [item["uid"] for item in items] + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "5" + + await hass.services.async_call( + TODO_DOMAIN, + "delete_item", + {"uid": uids}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 0 + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + +async def test_update_item( + hass: HomeAssistant, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test updating a todo item.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "soda"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Fetch item + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "needs_action" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + # Mark item completed + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"uid": item["uid"], "status": "completed"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify item is marked as completed + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "completed" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + +@pytest.mark.parametrize( + ("src_idx", "dst_idx", "expected_items"), + [ + # Move any item to the front of the list + (0, None, ["item 1", "item 2", "item 3", "item 4"]), + (1, None, ["item 2", "item 1", "item 3", "item 4"]), + (2, None, ["item 3", "item 1", "item 2", "item 4"]), + (3, None, ["item 4", "item 1", "item 2", "item 3"]), + # Move items right + (0, 1, ["item 2", "item 1", "item 3", "item 4"]), + (0, 2, ["item 2", "item 3", "item 1", "item 4"]), + (0, 3, ["item 2", "item 3", "item 4", "item 1"]), + (1, 2, ["item 1", "item 3", "item 2", "item 4"]), + (1, 3, ["item 1", "item 3", "item 4", "item 2"]), + # Move items left + (2, 0, ["item 1", "item 3", "item 2", "item 4"]), + (3, 0, ["item 1", "item 4", "item 2", "item 3"]), + (3, 1, ["item 1", "item 2", "item 4", "item 3"]), + # No-ops + (0, 0, ["item 1", "item 2", "item 3", "item 4"]), + (2, 1, ["item 1", "item 2", "item 3", "item 4"]), + (2, 2, ["item 1", "item 2", "item 3", "item 4"]), + (3, 2, ["item 1", "item 2", "item 3", "item 4"]), + (3, 3, ["item 1", "item 2", "item 3", "item 4"]), + ], +) +async def test_move_item( + hass: HomeAssistant, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], + ws_move_item: Callable[[str, str | None], Awaitable[None]], + src_idx: int, + dst_idx: int | None, + expected_items: list[str], +) -> None: + """Test moving a todo item within the list.""" + for i in range(1, 5): + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": f"item {i}"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 4 + uids = [item["uid"] for item in items] + summaries = [item["summary"] for item in items] + assert summaries == ["item 1", "item 2", "item 3", "item 4"] + + # Prepare items for moving + previous_uid = None + if dst_idx is not None: + previous_uid = uids[dst_idx] + await ws_move_item(uids[src_idx], previous_uid) + + items = await ws_get_items() + assert len(items) == 4 + summaries = [item["summary"] for item in items] + assert summaries == expected_items + + +async def test_move_item_unknown( + hass: HomeAssistant, + setup_integration: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test moving a todo item that does not exist.""" + + # Prepare items for moving + client = await hass_ws_client() + data = { + "id": 1, + "type": "todo/item/move", + "entity_id": TEST_ENTITY, + "uid": "unknown", + "previous_uid": "item-2", + } + await client.send_json(data) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert not resp.get("success") + assert resp.get("error", {}).get("code") == "failed" + assert "not found in todo list" in resp["error"]["message"] + + +async def test_move_item_previous_unknown( + hass: HomeAssistant, + setup_integration: None, + hass_ws_client: WebSocketGenerator, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test moving a todo item that does not exist.""" + + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "item 1"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + items = await ws_get_items() + assert len(items) == 1 + + # Prepare items for moving + client = await hass_ws_client() + data = { + "id": 1, + "type": "todo/item/move", + "entity_id": TEST_ENTITY, + "uid": items[0]["uid"], + "previous_uid": "unknown", + } + await client.send_json(data) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert not resp.get("success") + assert resp.get("error", {}).get("code") == "failed" + assert "not found in todo list" in resp["error"]["message"] + + +@pytest.mark.parametrize( + ("ics_content", "expected_state"), + [ + ("", "0"), + (None, "0"), + ( + textwrap.dedent( + """\ + BEGIN:VCALENDAR + PRODID:-//homeassistant.io//local_todo 1.0//EN + VERSION:2.0 + BEGIN:VTODO + DTSTAMP:20231024T014011 + UID:077cb7f2-6c89-11ee-b2a9-0242ac110002 + CREATED:20231017T010348 + LAST-MODIFIED:20231024T014011 + SEQUENCE:1 + STATUS:COMPLETED + SUMMARY:Complete Task + END:VTODO + END:VCALENDAR + """ + ), + "0", + ), + ( + textwrap.dedent( + """\ + BEGIN:VCALENDAR + PRODID:-//homeassistant.io//local_todo 1.0//EN + VERSION:2.0 + BEGIN:VTODO + DTSTAMP:20231024T014011 + UID:077cb7f2-6c89-11ee-b2a9-0242ac110002 + CREATED:20231017T010348 + LAST-MODIFIED:20231024T014011 + SEQUENCE:1 + STATUS:NEEDS-ACTION + SUMMARY:Incomplete Task + END:VTODO + END:VCALENDAR + """ + ), + "1", + ), + ], + ids=("empty", "not_exists", "completed", "needs_action"), +) +async def test_parse_existing_ics( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_integration: None, + expected_state: str, +) -> None: + """Test parsing ics content.""" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == expected_state diff --git a/tests/components/lock/test_device_action.py b/tests/components/lock/test_device_action.py index f87fa4cc178780..1e451920bafbca 100644 --- a/tests/components/lock/test_device_action.py +++ b/tests/components/lock/test_device_action.py @@ -136,9 +136,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for lock actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -149,7 +161,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_lock"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "lock", }, @@ -158,7 +170,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_unlock"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "unlock", }, @@ -167,7 +179,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_open"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "open", }, @@ -211,10 +223,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for lock actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -225,7 +247,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_event_lock"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "lock", }, diff --git a/tests/components/lock/test_device_condition.py b/tests/components/lock/test_device_condition.py index 43513930f2ee06..59dcbcb4629d81 100644 --- a/tests/components/lock/test_device_condition.py +++ b/tests/components/lock/test_device_condition.py @@ -129,10 +129,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_LOCKED) @@ -147,7 +158,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_locked", } @@ -165,7 +176,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_unlocked", } @@ -183,7 +194,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_unlocking", } @@ -201,7 +212,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_locking", } @@ -219,7 +230,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_jammed", } @@ -267,10 +278,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_LOCKED) @@ -285,7 +307,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_locked", } diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index 107e0924440820..9c1594760c9223 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -185,10 +185,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNLOCKED) @@ -201,7 +212,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "locked", }, @@ -220,7 +231,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "unlocked", }, @@ -259,10 +270,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNLOCKED) @@ -275,7 +297,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "locked", }, @@ -305,10 +327,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNLOCKED) @@ -321,7 +354,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "locked", "for": {"seconds": 5}, @@ -346,7 +379,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "unlocking", "for": {"seconds": 5}, @@ -371,7 +404,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "jammed", "for": {"seconds": 5}, @@ -396,7 +429,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "locking", "for": {"seconds": 5}, diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py index 1e2a121d6fb4c0..f7830f03ed6512 100644 --- a/tests/components/lovelace/test_resources.py +++ b/tests/components/lovelace/test_resources.py @@ -185,3 +185,26 @@ async def test_storage_resources_import_invalid( "resources" in hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"]["config"] ) + + +async def test_storage_resources_safe_mode( + hass: HomeAssistant, hass_ws_client, hass_storage: dict[str, Any] +) -> None: + """Test defining resources in storage config.""" + + resource_config = [{**item, "id": uuid.uuid4().hex} for item in RESOURCE_EXAMPLES] + hass_storage[resources.RESOURCE_STORAGE_KEY] = { + "key": resources.RESOURCE_STORAGE_KEY, + "version": 1, + "data": {"items": resource_config}, + } + assert await async_setup_component(hass, "lovelace", {}) + + client = await hass_ws_client(hass) + hass.config.safe_mode = True + + # Fetch data + await client.send_json({"id": 5, "type": "lovelace/resources"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [] diff --git a/tests/components/media_player/test_device_condition.py b/tests/components/media_player/test_device_condition.py index b89993dec65beb..ea1f65eab9576c 100644 --- a/tests/components/media_player/test_device_condition.py +++ b/tests/components/media_player/test_device_condition.py @@ -132,10 +132,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -150,7 +161,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -168,7 +179,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -186,7 +197,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_idle", } @@ -204,7 +215,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_paused", } @@ -222,7 +233,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_playing", } @@ -240,7 +251,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_buffering", } @@ -322,10 +333,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -340,7 +362,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index 42608eacb097f4..afc46c87cff1f1 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -205,10 +205,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OFF) @@ -236,7 +247,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": trigger, }, @@ -306,10 +317,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OFF) @@ -328,7 +350,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_on", }, @@ -354,10 +376,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OFF) @@ -370,7 +403,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", "for": {"seconds": 5}, diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 863a79fce703d3..ed01b70e660b4f 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1523,18 +1523,20 @@ async def async_step_mqtt(self, discovery_info: MqttServiceInfo) -> FlowResult: return self.async_abort(reason="already_configured") with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): - await asyncio.sleep(0.1) + await asyncio.sleep(0) assert ("comp/discovery/#", 0) in help_all_subscribe_calls(mqtt_client_mock) assert not mqtt_client_mock.unsubscribe.called async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") - await asyncio.sleep(0.1) + await asyncio.sleep(0) + await hass.async_block_till_done() await hass.async_block_till_done() mqtt_client_mock.unsubscribe.assert_called_once_with(["comp/discovery/#"]) mqtt_client_mock.unsubscribe.reset_mock() async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") - await asyncio.sleep(0.1) + await asyncio.sleep(0) + await hass.async_block_till_done() await hass.async_block_till_done() assert not mqtt_client_mock.unsubscribe.called diff --git a/tests/components/nest/test_device_trigger.py b/tests/components/nest/test_device_trigger.py index 852075c65271ce..381cddb281748b 100644 --- a/tests/components/nest/test_device_trigger.py +++ b/tests/components/nest/test_device_trigger.py @@ -230,53 +230,164 @@ async def test_no_triggers( assert triggers == [] -async def test_fires_on_camera_motion(hass: HomeAssistant, calls) -> None: +async def test_fires_on_camera_motion( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: """Test camera_motion triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "camera_motion") + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) + ) + await setup_platform() + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) + + assert await setup_automation(hass, device_entry.id, "camera_motion") - message = {"device_id": DEVICE_ID, "type": "camera_motion", "timestamp": utcnow()} + message = { + "device_id": device_entry.id, + "type": "camera_motion", + "timestamp": utcnow(), + } hass.bus.async_fire(NEST_EVENT, message) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data == DATA_MESSAGE -async def test_fires_on_camera_person(hass: HomeAssistant, calls) -> None: +async def test_fires_on_camera_person( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: """Test camera_person triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "camera_person") + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) + ) + await setup_platform() + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) - message = {"device_id": DEVICE_ID, "type": "camera_person", "timestamp": utcnow()} + assert await setup_automation(hass, device_entry.id, "camera_person") + + message = { + "device_id": device_entry.id, + "type": "camera_person", + "timestamp": utcnow(), + } hass.bus.async_fire(NEST_EVENT, message) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data == DATA_MESSAGE -async def test_fires_on_camera_sound(hass: HomeAssistant, calls) -> None: - """Test camera_person triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "camera_sound") +async def test_fires_on_camera_sound( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: + """Test camera_sound triggers firing.""" + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraSound": {}, + }, + ) + ) + await setup_platform() + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) - message = {"device_id": DEVICE_ID, "type": "camera_sound", "timestamp": utcnow()} + assert await setup_automation(hass, device_entry.id, "camera_sound") + + message = { + "device_id": device_entry.id, + "type": "camera_sound", + "timestamp": utcnow(), + } hass.bus.async_fire(NEST_EVENT, message) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data == DATA_MESSAGE -async def test_fires_on_doorbell_chime(hass: HomeAssistant, calls) -> None: +async def test_fires_on_doorbell_chime( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: """Test doorbell_chime triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "doorbell_chime") + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.DoorbellChime": {}, + }, + ) + ) + await setup_platform() + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) - message = {"device_id": DEVICE_ID, "type": "doorbell_chime", "timestamp": utcnow()} + assert await setup_automation(hass, device_entry.id, "doorbell_chime") + + message = { + "device_id": device_entry.id, + "type": "doorbell_chime", + "timestamp": utcnow(), + } hass.bus.async_fire(NEST_EVENT, message) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data == DATA_MESSAGE -async def test_trigger_for_wrong_device_id(hass: HomeAssistant, calls) -> None: - """Test for turn_on and turn_off triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "camera_motion") +async def test_trigger_for_wrong_device_id( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: + """Test messages for the wrong device are ignored.""" + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) + ) + await setup_platform() + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) + + assert await setup_automation(hass, device_entry.id, "camera_motion") message = { "device_id": "wrong-device-id", @@ -288,12 +399,31 @@ async def test_trigger_for_wrong_device_id(hass: HomeAssistant, calls) -> None: assert len(calls) == 0 -async def test_trigger_for_wrong_event_type(hass: HomeAssistant, calls) -> None: - """Test for turn_on and turn_off triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "camera_motion") +async def test_trigger_for_wrong_event_type( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: + """Test that messages for the wrong event type are ignored.""" + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) + ) + await setup_platform() + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) + + assert await setup_automation(hass, device_entry.id, "camera_motion") message = { - "device_id": DEVICE_ID, + "device_id": device_entry.id, "type": "wrong-event-type", "timestamp": utcnow(), } diff --git a/tests/components/number/test_device_action.py b/tests/components/number/test_device_action.py index 1e0cfd5b391624..17c63dd33944c4 100644 --- a/tests/components/number/test_device_action.py +++ b/tests/components/number/test_device_action.py @@ -137,9 +137,21 @@ async def test_get_action_no_state( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, 0.5, {"min_value": 0.0, "max_value": 1.0}) @@ -155,7 +167,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_value", "value": 0.3, @@ -178,10 +190,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, 0.5, {"min_value": 0.0, "max_value": 1.0}) @@ -197,7 +219,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "set_value", "value": 0.3, diff --git a/tests/components/plugwise/fixtures/adam_jip/all_data.json b/tests/components/plugwise/fixtures/adam_jip/all_data.json index ba00e3928d70bd..bc1bc9c8c0cf5d 100644 --- a/tests/components/plugwise/fixtures/adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/adam_jip/all_data.json @@ -7,7 +7,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": null, "location": "06aecb3d00354375924f50c47af36bd2", "mode": "heat", "model": "Lisa", @@ -103,7 +102,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": null, "location": "d27aede973b54be484f6842d1b2802ad", "mode": "heat", "model": "Lisa", @@ -160,7 +158,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": null, "location": "d58fec52899f4f1c92e4f8fad6d8c48c", "mode": "heat", "model": "Lisa", @@ -271,7 +268,6 @@ "dev_class": "zone_thermometer", "firmware": "2020-09-01T02:00:00+02:00", "hardware": "1", - "last_used": null, "location": "13228dab8ce04617af318a2888b3c548", "mode": "heat", "model": "Jip", diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json index 0cc28731ff4a8a..6e6da1aa272d3a 100644 --- a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json @@ -117,7 +117,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": "CV Jessie", "location": "82fa13f017d240daa0d0ea1775420f24", "mode": "auto", "model": "Lisa", @@ -257,7 +256,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-08-02T02:00:00+02:00", "hardware": "255", - "last_used": "GF7 Woonkamer", "location": "c50f167537524366a5af7aa3942feb1e", "mode": "auto", "model": "Lisa", @@ -341,7 +339,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": "Badkamer Schema", "location": "12493538af164a409c6a1c79e38afe1c", "mode": "heat", "model": "Lisa", @@ -381,7 +378,6 @@ "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", - "last_used": "Badkamer Schema", "location": "446ac08dd04d4eff8ac57489757b7314", "mode": "heat", "model": "Tom/Floor", @@ -423,7 +419,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": "Badkamer Schema", "location": "08963fec7c53423ca5680aa4cb502c63", "mode": "auto", "model": "Lisa", diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json index cdddfdb3439c03..e7e13e17357d5f 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json @@ -62,7 +62,6 @@ "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", - "last_used": "standaard", "location": "c784ee9fdab44e1395b8dee7d7a497d5", "mode": "auto", "model": "ThermoTouch", diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index af8c012cae36f4..126852e945d88d 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -54,7 +54,6 @@ "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], "dev_class": "thermostat", - "last_used": "Weekschema", "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "heat_cool", "model": "ThermoTouch", @@ -108,7 +107,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", - "last_used": "Badkamer", "location": "f871b8c4d63549319221e294e4f88074", "mode": "auto", "model": "Lisa", diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index efefa95d45c8fb..e8a72c9b3fbfd9 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -59,7 +59,6 @@ "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], "dev_class": "thermostat", - "last_used": "Weekschema", "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "heat", "model": "ThermoTouch", @@ -105,7 +104,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", - "last_used": "Badkamer", "location": "f871b8c4d63549319221e294e4f88074", "mode": "auto", "model": "Lisa", diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json index f98f253e9389f2..40364e620c3f7c 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json @@ -63,7 +63,6 @@ "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", - "last_used": "standaard", "location": "c784ee9fdab44e1395b8dee7d7a497d5", "mode": "auto", "model": "ThermoTouch", diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json index 56d26f67acb423..3a84a59deea7a0 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json @@ -63,7 +63,6 @@ "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", - "last_used": "standaard", "location": "c784ee9fdab44e1395b8dee7d7a497d5", "mode": "auto", "model": "ThermoTouch", diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index da6e896442164b..597b9710ec5e1a 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -119,7 +119,6 @@ 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', - 'last_used': 'CV Jessie', 'location': '82fa13f017d240daa0d0ea1775420f24', 'mode': 'auto', 'model': 'Lisa', @@ -265,7 +264,6 @@ 'dev_class': 'zone_thermostat', 'firmware': '2016-08-02T02:00:00+02:00', 'hardware': '255', - 'last_used': 'GF7 Woonkamer', 'location': 'c50f167537524366a5af7aa3942feb1e', 'mode': 'auto', 'model': 'Lisa', @@ -355,7 +353,6 @@ 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', - 'last_used': 'Badkamer Schema', 'location': '12493538af164a409c6a1c79e38afe1c', 'mode': 'heat', 'model': 'Lisa', @@ -401,7 +398,6 @@ 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', - 'last_used': 'Badkamer Schema', 'location': '446ac08dd04d4eff8ac57489757b7314', 'mode': 'heat', 'model': 'Tom/Floor', @@ -449,7 +445,6 @@ 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', - 'last_used': 'Badkamer Schema', 'location': '08963fec7c53423ca5680aa4cb502c63', 'mode': 'auto', 'model': 'Lisa', diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 496eeaae084019..d8ce2785f2a5e1 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -1,14 +1,17 @@ """Tests for the Plugwise Climate integration.""" -from unittest.mock import MagicMock + +from datetime import timedelta +from unittest.mock import MagicMock, patch from plugwise.exceptions import PlugwiseError import pytest -from homeassistant.components.climate import HVACMode +from homeassistant.components.climate.const import HVACMode from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.dt import utcnow -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_adam_climate_entity_attributes( @@ -87,8 +90,6 @@ async def test_adam_climate_adjust_negative_testing( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: """Test exceptions of climate entities.""" - mock_smile_adam.set_preset.side_effect = PlugwiseError - mock_smile_adam.set_schedule_state.side_effect = PlugwiseError mock_smile_adam.set_temperature.side_effect = PlugwiseError with pytest.raises(HomeAssistantError): @@ -99,25 +100,6 @@ async def test_adam_climate_adjust_negative_testing( blocking=True, ) - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - "climate", - "set_preset_mode", - {"entity_id": "climate.zone_thermostat_jessie", "preset_mode": "home"}, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - "climate", - "set_hvac_mode", - { - "entity_id": "climate.zone_thermostat_jessie", - "hvac_mode": HVACMode.AUTO, - }, - blocking=True, - ) - async def test_adam_climate_entity_climate_changes( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry @@ -129,7 +111,6 @@ async def test_adam_climate_entity_climate_changes( {"entity_id": "climate.zone_lisa_wk", "temperature": 25}, blocking=True, ) - assert mock_smile_adam.set_temperature.call_count == 1 mock_smile_adam.set_temperature.assert_called_with( "c50f167537524366a5af7aa3942feb1e", {"setpoint": 25.0} @@ -145,7 +126,6 @@ async def test_adam_climate_entity_climate_changes( }, blocking=True, ) - assert mock_smile_adam.set_temperature.call_count == 2 mock_smile_adam.set_temperature.assert_called_with( "c50f167537524366a5af7aa3942feb1e", {"setpoint": 25.0} @@ -165,7 +145,6 @@ async def test_adam_climate_entity_climate_changes( {"entity_id": "climate.zone_lisa_wk", "preset_mode": "away"}, blocking=True, ) - assert mock_smile_adam.set_preset.call_count == 1 mock_smile_adam.set_preset.assert_called_with( "c50f167537524366a5af7aa3942feb1e", "away" @@ -173,26 +152,13 @@ async def test_adam_climate_entity_climate_changes( await hass.services.async_call( "climate", - "set_temperature", - {"entity_id": "climate.zone_thermostat_jessie", "temperature": 25}, - blocking=True, - ) - - assert mock_smile_adam.set_temperature.call_count == 3 - mock_smile_adam.set_temperature.assert_called_with( - "82fa13f017d240daa0d0ea1775420f24", {"setpoint": 25.0} - ) - - await hass.services.async_call( - "climate", - "set_preset_mode", - {"entity_id": "climate.zone_thermostat_jessie", "preset_mode": "home"}, + "set_hvac_mode", + {"entity_id": "climate.zone_lisa_wk", "hvac_mode": "heat"}, blocking=True, ) - - assert mock_smile_adam.set_preset.call_count == 2 - mock_smile_adam.set_preset.assert_called_with( - "82fa13f017d240daa0d0ea1775420f24", "home" + assert mock_smile_adam.set_schedule_state.call_count == 2 + mock_smile_adam.set_schedule_state.assert_called_with( + "c50f167537524366a5af7aa3942feb1e", "off" ) with pytest.raises(HomeAssistantError): @@ -270,7 +236,9 @@ async def test_anna_3_climate_entity_attributes( async def test_anna_climate_entity_climate_changes( - hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_anna: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test handling of user requests in anna climate device environment.""" await hass.services.async_call( @@ -279,7 +247,6 @@ async def test_anna_climate_entity_climate_changes( {"entity_id": "climate.anna", "target_temp_high": 25, "target_temp_low": 20}, blocking=True, ) - assert mock_smile_anna.set_temperature.call_count == 1 mock_smile_anna.set_temperature.assert_called_with( "c784ee9fdab44e1395b8dee7d7a497d5", @@ -292,7 +259,6 @@ async def test_anna_climate_entity_climate_changes( {"entity_id": "climate.anna", "preset_mode": "away"}, blocking=True, ) - assert mock_smile_anna.set_preset.call_count == 1 mock_smile_anna.set_preset.assert_called_with( "c784ee9fdab44e1395b8dee7d7a497d5", "away" @@ -301,24 +267,32 @@ async def test_anna_climate_entity_climate_changes( await hass.services.async_call( "climate", "set_hvac_mode", - {"entity_id": "climate.anna", "hvac_mode": "heat"}, + {"entity_id": "climate.anna", "hvac_mode": "auto"}, blocking=True, ) - - assert mock_smile_anna.set_temperature.call_count == 1 assert mock_smile_anna.set_schedule_state.call_count == 1 mock_smile_anna.set_schedule_state.assert_called_with( - "c784ee9fdab44e1395b8dee7d7a497d5", "standaard", "off" + "c784ee9fdab44e1395b8dee7d7a497d5", "on" ) await hass.services.async_call( "climate", "set_hvac_mode", - {"entity_id": "climate.anna", "hvac_mode": "auto"}, + {"entity_id": "climate.anna", "hvac_mode": "heat"}, blocking=True, ) - assert mock_smile_anna.set_schedule_state.call_count == 2 mock_smile_anna.set_schedule_state.assert_called_with( - "c784ee9fdab44e1395b8dee7d7a497d5", "standaard", "on" + "c784ee9fdab44e1395b8dee7d7a497d5", "off" ) + data = mock_smile_anna.async_update.return_value + data.devices["3cb70739631c4d17a86b8b12e8a5161b"]["available_schedules"] = ["None"] + with patch( + "homeassistant.components.plugwise.coordinator.Smile.async_update", + return_value=data, + ): + async_fire_time_changed(hass, utcnow() + timedelta(minutes=1)) + await hass.async_block_till_done() + state = hass.states.get("climate.anna") + assert state.state == HVACMode.HEAT + assert state.attributes["hvac_modes"] == [HVACMode.HEAT] diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index 34f87b5c261577..cd96d0d7b81db3 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -13,13 +13,22 @@ async def test_proximities(hass: HomeAssistant) -> None: "devices": ["device_tracker.test1", "device_tracker.test2"], "tolerance": "1", }, - "work": {"devices": ["device_tracker.test1"], "tolerance": "1"}, + "home_test2": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1", "device_tracker.test2"], + "tolerance": "1", + }, + "work": { + "devices": ["device_tracker.test1"], + "tolerance": "1", + "zone": "work", + }, } } assert await async_setup_component(hass, DOMAIN, config) - proximities = ["home", "work"] + proximities = ["home", "home_test2", "work"] for prox in proximities: state = hass.states.get(f"proximity.{prox}") @@ -42,7 +51,7 @@ async def test_proximities_setup(hass: HomeAssistant) -> None: "devices": ["device_tracker.test1", "device_tracker.test2"], "tolerance": "1", }, - "work": {"tolerance": "1"}, + "work": {"tolerance": "1", "zone": "work"}, } } diff --git a/tests/components/random/test_config_flow.py b/tests/components/random/test_config_flow.py new file mode 100644 index 00000000000000..909e866ea92e5a --- /dev/null +++ b/tests/components/random/test_config_flow.py @@ -0,0 +1,201 @@ +"""Test the Random config flow.""" +from typing import Any +from unittest.mock import patch + +import pytest +from voluptuous import Invalid + +from homeassistant import config_entries +from homeassistant.components.random import async_setup_entry +from homeassistant.components.random.const import DOMAIN +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ( + "entity_type", + "extra_input", + "extra_options", + ), + ( + ( + "binary_sensor", + {}, + {}, + ), + ( + "sensor", + { + "device_class": SensorDeviceClass.POWER, + "unit_of_measurement": UnitOfPower.WATT, + }, + { + "device_class": SensorDeviceClass.POWER, + "unit_of_measurement": UnitOfPower.WATT, + "minimum": 0, + "maximum": 20, + }, + ), + ( + "sensor", + {}, + {"minimum": 0, "maximum": 20}, + ), + ), +) +async def test_config_flow( + hass: HomeAssistant, + entity_type: str, + extra_input: dict[str, Any], + extra_options: dict[str, Any], +) -> None: + """Test the config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": entity_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == entity_type + + with patch( + "homeassistant.components.random.async_setup_entry", wraps=async_setup_entry + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "My random entity", + **extra_input, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "My random entity" + assert result["data"] == {} + assert result["options"] == { + "name": "My random entity", + "entity_type": entity_type, + **extra_options, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("device_class", "unit_of_measurement"), + [ + (SensorDeviceClass.POWER, UnitOfEnergy.WATT_HOUR), + (SensorDeviceClass.ILLUMINANCE, UnitOfEnergy.WATT_HOUR), + ], +) +async def test_wrong_uom( + hass: HomeAssistant, device_class: SensorDeviceClass, unit_of_measurement: str +) -> None: + """Test entering a wrong unit of measurement.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "sensor"}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "sensor" + + with pytest.raises(Invalid, match="is not a valid unit for device class"): + await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "My random entity", + "device_class": device_class, + "unit_of_measurement": unit_of_measurement, + }, + ) + + +@pytest.mark.parametrize( + ( + "entity_type", + "extra_options", + "options_options", + ), + ( + ( + "sensor", + { + "device_class": SensorDeviceClass.ENERGY, + "unit_of_measurement": UnitOfEnergy.WATT_HOUR, + "minimum": 0, + "maximum": 20, + }, + { + "minimum": 10, + "maximum": 20, + "device_class": SensorDeviceClass.POWER, + "unit_of_measurement": UnitOfPower.WATT, + }, + ), + ), +) +async def test_options( + hass: HomeAssistant, + entity_type: str, + extra_options, + options_options, +) -> None: + """Test reconfiguring.""" + + random_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My random", + "entity_type": entity_type, + **extra_options, + }, + title="My random", + ) + random_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(random_config_entry.entry_id) + await hass.async_block_till_done() + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == entity_type + assert "name" not in result["data_schema"].schema + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=options_options, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "name": "My random", + "entity_type": entity_type, + **options_options, + } + assert config_entry.data == {} + assert config_entry.options == { + "name": "My random", + "entity_type": entity_type, + **options_options, + } + assert config_entry.title == "My random" diff --git a/tests/components/remote/test_device_action.py b/tests/components/remote/test_device_action.py index 97ff2fd58a0085..76e6075a18f665 100644 --- a/tests/components/remote/test_device_action.py +++ b/tests/components/remote/test_device_action.py @@ -110,12 +110,21 @@ async def test_get_actions_hidden_auxiliary( async def test_action( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -126,7 +135,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -135,7 +144,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_on"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -144,7 +153,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_toggle"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "toggle", }, @@ -176,12 +185,21 @@ async def test_action( async def test_action_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -192,7 +210,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turn_off", }, diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py index b07747771d9d7f..1048aa1b081374 100644 --- a/tests/components/remote/test_device_condition.py +++ b/tests/components/remote/test_device_condition.py @@ -179,12 +179,21 @@ async def test_get_condition_capabilities_legacy( async def test_if_state( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -199,7 +208,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -218,7 +227,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -253,12 +262,21 @@ async def test_if_state( async def test_if_state_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -273,7 +291,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_on", } @@ -301,6 +319,7 @@ async def test_if_state_legacy( async def test_if_fires_on_for_condition( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -310,7 +329,15 @@ async def test_if_fires_on_for_condition( point2 = point1 + timedelta(seconds=10) point3 = point2 + timedelta(seconds=10) - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -325,7 +352,7 @@ async def test_if_fires_on_for_condition( "condition": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", "for": {"seconds": 5}, diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py index b5dcca3dc4c9f2..711b9672aa0b5d 100644 --- a/tests/components/remote/test_device_trigger.py +++ b/tests/components/remote/test_device_trigger.py @@ -177,12 +177,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -195,7 +204,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -219,7 +228,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", }, @@ -243,7 +252,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "changed_states", }, @@ -287,12 +296,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -305,7 +323,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_off", }, @@ -341,12 +359,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -359,7 +386,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index 4edf8ff4710001..e70dac5ffc9182 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -9,7 +9,7 @@ async def test_binary_sensors( hass: HomeAssistant, setup_entry: MockConfigEntry ) -> None: """Test binary sensors and check test values are correctly set.""" - assert len(hass.states.async_all("binary_sensor")) == 6 + assert len(hass.states.async_all("binary_sensor")) == 8 assert hass.states.get("binary_sensor.roborock_s7_maxv_mop_attached").state == "on" assert ( hass.states.get("binary_sensor.roborock_s7_maxv_water_box_attached").state @@ -18,3 +18,4 @@ async def test_binary_sensors( assert ( hass.states.get("binary_sensor.roborock_s7_maxv_water_shortage").state == "off" ) + assert hass.states.get("binary_sensor.roborock_s7_maxv_cleaning").state == "off" diff --git a/tests/components/script/test_blueprint.py b/tests/components/script/test_blueprint.py index 8368eb06140f80..b248a3d76504bb 100644 --- a/tests/components/script/test_blueprint.py +++ b/tests/components/script/test_blueprint.py @@ -7,14 +7,15 @@ import pytest +from homeassistant import config_entries from homeassistant.components import script from homeassistant.components.blueprint.models import Blueprint, DomainBlueprints from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.helpers import template +from homeassistant.helpers import device_registry as dr, template from homeassistant.setup import async_setup_component from homeassistant.util import yaml -from tests.common import async_mock_service +from tests.common import MockConfigEntry, async_mock_service BUILTIN_BLUEPRINT_FOLDER = pathlib.Path(script.__file__).parent / "blueprints" @@ -41,8 +42,19 @@ def mock_load_blueprint(self, path: str) -> Blueprint: yield -async def test_confirmable_notification(hass: HomeAssistant) -> None: +async def test_confirmable_notification( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test confirmable notification blueprint.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + + frodo = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:01")}, + ) + with patch_blueprint( "confirmable_notification.yaml", BUILTIN_BLUEPRINT_FOLDER / "confirmable_notification.yaml", @@ -56,7 +68,7 @@ async def test_confirmable_notification(hass: HomeAssistant) -> None: "use_blueprint": { "path": "confirmable_notification.yaml", "input": { - "notify_device": "frodo", + "notify_device": frodo.id, "title": "Lord of the things", "message": "Throw ring in mountain?", "confirm_action": [ @@ -105,7 +117,7 @@ async def test_confirmable_notification(hass: HomeAssistant) -> None: "alias": "Send notification", "domain": "mobile_app", "type": "notify", - "device_id": "frodo", + "device_id": frodo.id, "data": { "actions": [ {"action": "CONFIRM_" + _context.id, "title": "Confirm"}, diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index cddefc8d3dcc65..83abd37137e560 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -6,6 +6,7 @@ import pytest +from homeassistant import config_entries from homeassistant.components import script from homeassistant.components.script import DOMAIN, EVENT_SCRIPT_STARTED, ScriptEntity from homeassistant.const import ( @@ -27,7 +28,7 @@ split_entity_id, ) from homeassistant.exceptions import ServiceNotFound -from homeassistant.helpers import entity_registry as er, template +from homeassistant.helpers import device_registry as dr, entity_registry as er, template from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.script import ( SCRIPT_MODE_CHOICES, @@ -42,7 +43,12 @@ from homeassistant.util import yaml import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, async_mock_service, mock_restore_cache +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_mock_service, + mock_restore_cache, +) from tests.components.logbook.common import MockRow, mock_humanify from tests.typing import WebSocketGenerator @@ -707,8 +713,23 @@ async def test_extraction_functions_unavailable_script(hass: HomeAssistant) -> N assert script.entities_in_script(hass, entity_id) == [] -async def test_extraction_functions(hass: HomeAssistant) -> None: +async def test_extraction_functions( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test extraction functions.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + + device_in_both = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:02")}, + ) + device_in_last = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:03")}, + ) + assert await async_setup_component( hass, DOMAIN, @@ -728,7 +749,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "entity_id": "light.device_in_both", "domain": "light", "type": "turn_on", - "device_id": "device-in-both", + "device_id": device_in_both.id, }, { "service": "test.test", @@ -752,13 +773,13 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "entity_id": "light.device_in_both", "domain": "light", "type": "turn_on", - "device_id": "device-in-both", + "device_id": device_in_both.id, }, { "entity_id": "light.device_in_last", "domain": "light", "type": "turn_on", - "device_id": "device-in-last", + "device_id": device_in_last.id, }, ], }, @@ -797,13 +818,13 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "light.in_both", "light.in_first", } - assert set(script.scripts_with_device(hass, "device-in-both")) == { + assert set(script.scripts_with_device(hass, device_in_both.id)) == { "script.test1", "script.test2", } assert set(script.devices_in_script(hass, "script.test2")) == { - "device-in-both", - "device-in-last", + device_in_both.id, + device_in_last.id, } assert set(script.scripts_with_area(hass, "area-in-both")) == { "script.test1", diff --git a/tests/components/select/test_device_action.py b/tests/components/select/test_device_action.py index ce5d48bb358af9..121b41fcb2b4f6 100644 --- a/tests/components/select/test_device_action.py +++ b/tests/components/select/test_device_action.py @@ -116,10 +116,21 @@ async def test_get_actions_hidden_auxiliary( @pytest.mark.parametrize("action_type", ("select_first", "select_last")) async def test_action_select_first_last( - hass: HomeAssistant, entity_registry: er.EntityRegistry, action_type: str + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + action_type: str, ) -> None: """Test for select_first and select_last actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -133,7 +144,7 @@ async def test_action_select_first_last( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": action_type, }, @@ -154,10 +165,21 @@ async def test_action_select_first_last( @pytest.mark.parametrize("action_type", ("select_first", "select_last")) async def test_action_select_first_last_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, action_type: str + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + action_type: str, ) -> None: """Test for select_first and select_last actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -171,7 +193,7 @@ async def test_action_select_first_last_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": action_type, }, @@ -191,10 +213,20 @@ async def test_action_select_first_last_legacy( async def test_action_select_option( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for select_option action.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -208,7 +240,7 @@ async def test_action_select_option( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "select_option", "option": "option1", @@ -230,10 +262,21 @@ async def test_action_select_option( @pytest.mark.parametrize("action_type", ["select_next", "select_previous"]) async def test_action_select_next_previous( - hass: HomeAssistant, entity_registry: er.EntityRegistry, action_type: str + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + action_type: str, ) -> None: """Test for select_next and select_previous actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -247,7 +290,7 @@ async def test_action_select_next_previous( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": action_type, "cycle": False, diff --git a/tests/components/select/test_device_condition.py b/tests/components/select/test_device_condition.py index 18ebd428891eeb..3e0ecd6e54718e 100644 --- a/tests/components/select/test_device_condition.py +++ b/tests/components/select/test_device_condition.py @@ -115,10 +115,19 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_selected_option( hass: HomeAssistant, calls: list[ServiceCall], + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for selected_option conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -131,7 +140,7 @@ async def test_if_selected_option( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "selected_option", "option": "option1", @@ -150,7 +159,7 @@ async def test_if_selected_option( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "selected_option", "option": "option2", @@ -195,10 +204,19 @@ async def test_if_selected_option( async def test_if_selected_option_legacy( hass: HomeAssistant, calls: list[ServiceCall], + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for selected_option conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -211,7 +229,7 @@ async def test_if_selected_option_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "selected_option", "option": "option1", diff --git a/tests/components/select/test_device_trigger.py b/tests/components/select/test_device_trigger.py index 8a6ccd43abed72..0be5c605dc1bfe 100644 --- a/tests/components/select/test_device_trigger.py +++ b/tests/components/select/test_device_trigger.py @@ -113,10 +113,21 @@ async def test_get_triggers_hidden_auxiliary( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, "option1", {"options": ["option1", "option2", "option3"]} @@ -131,7 +142,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_option_changed", "to": "option2", @@ -152,7 +163,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_option_changed", "from": "option2", @@ -173,7 +184,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_option_changed", "from": "option3", @@ -224,10 +235,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, "option1", {"options": ["option1", "option2", "option3"]} @@ -242,7 +264,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "current_option_changed", "to": "option2", diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 301baf0fc497c4..e0a8bebf5fc2d4 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -461,13 +461,22 @@ async def test_get_condition_capabilities_none( async def test_if_state_not_above_below( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, ) -> None: """Test for bad value conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -480,7 +489,7 @@ async def test_if_state_not_above_below( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_battery_level", } @@ -495,12 +504,21 @@ async def test_if_state_not_above_below( async def test_if_state_above( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -515,7 +533,7 @@ async def test_if_state_above( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_battery_level", "above": 10, @@ -553,12 +571,21 @@ async def test_if_state_above( async def test_if_state_above_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -573,7 +600,7 @@ async def test_if_state_above_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_battery_level", "above": 10, @@ -611,12 +638,21 @@ async def test_if_state_above_legacy( async def test_if_state_below( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -631,7 +667,7 @@ async def test_if_state_below( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_battery_level", "below": 10, @@ -669,12 +705,21 @@ async def test_if_state_below( async def test_if_state_between( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -689,7 +734,7 @@ async def test_if_state_between( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_battery_level", "above": 10, diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 7045d71fb789ec..bbc59cca3223ff 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -418,13 +418,22 @@ async def test_get_trigger_capabilities_none( async def test_if_fires_not_on_above_below( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -435,7 +444,7 @@ async def test_if_fires_not_on_above_below( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "battery_level", }, @@ -449,12 +458,21 @@ async def test_if_fires_not_on_above_below( async def test_if_fires_on_state_above( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -467,7 +485,7 @@ async def test_if_fires_on_state_above( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "battery_level", "above": 10, @@ -508,12 +526,21 @@ async def test_if_fires_on_state_above( async def test_if_fires_on_state_below( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -526,7 +553,7 @@ async def test_if_fires_on_state_below( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "battery_level", "below": 10, @@ -567,12 +594,21 @@ async def test_if_fires_on_state_below( async def test_if_fires_on_state_between( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -585,7 +621,7 @@ async def test_if_fires_on_state_between( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "battery_level", "above": 10, @@ -638,12 +674,21 @@ async def test_if_fires_on_state_between( async def test_if_fires_on_state_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -656,7 +701,7 @@ async def test_if_fires_on_state_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "battery_level", "above": 10, @@ -697,12 +742,21 @@ async def test_if_fires_on_state_legacy( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -715,7 +769,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "battery_level", "above": 10, diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index a738113f18ff86..380f4f5999e73f 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -1,9 +1,15 @@ """Tests for Shelly sensor platform.""" from freezegun.api import FrozenDateTimeFactory +import pytest +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.shelly.const import DOMAIN from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_UNAVAILABLE, @@ -12,6 +18,7 @@ ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers.entity_registry import async_get +from homeassistant.setup import async_setup_component from . import ( init_integration, @@ -448,3 +455,76 @@ async def test_rpc_em1_sensors( entry = registry.async_get("sensor.test_name_em1_total_active_energy") assert entry assert entry.unique_id == "123456789ABC-em1data:1-total_act_energy" + + +async def test_rpc_sleeping_update_entity_service( + hass: HomeAssistant, mock_rpc_device, caplog: pytest.LogCaptureFixture +) -> None: + """Test RPC sleeping device when the update_entity service is used.""" + await async_setup_component(hass, "homeassistant", {}) + + entity_id = f"{SENSOR_DOMAIN}.test_name_temperature" + await init_integration(hass, 2, sleep_period=1000) + + # Entity should be created when device is online + assert hass.states.get(entity_id) is None + + # Make device online + mock_rpc_device.mock_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "22.9" + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + service_data={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + # Entity should be available after update_entity service call + state = hass.states.get(entity_id) + assert state.state == "22.9" + + assert ( + "Entity sensor.test_name_temperature comes from a sleeping device" + in caplog.text + ) + + +async def test_block_sleeping_update_entity_service( + hass: HomeAssistant, mock_block_device, caplog: pytest.LogCaptureFixture +) -> None: + """Test block sleeping device when the update_entity service is used.""" + await async_setup_component(hass, "homeassistant", {}) + + entity_id = f"{SENSOR_DOMAIN}.test_name_temperature" + await init_integration(hass, 1, sleep_period=1000) + + # Sensor should be created when device is online + assert hass.states.get(entity_id) is None + + # Make device online + mock_block_device.mock_update() + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "22.1" + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + service_data={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + # Entity should be available after update_entity service call + state = hass.states.get(entity_id) + assert state.state == "22.1" + + assert ( + "Entity sensor.test_name_temperature comes from a sleeping device" + in caplog.text + ) diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py index 15f1e50bdb959c..ab28c6cbe6daae 100644 --- a/tests/components/shopping_list/test_todo.py +++ b/tests/components/shopping_list/test_todo.py @@ -57,10 +57,10 @@ async def get() -> list[dict[str, str]]: async def ws_move_item( hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int], -) -> Callable[[str, int | None], Awaitable[None]]: +) -> Callable[[str, str | None], Awaitable[None]]: """Fixture to move an item in the todo list.""" - async def move(uid: str, pos: int | None) -> dict[str, Any]: + async def move(uid: str, previous_uid: str | None) -> dict[str, Any]: # Fetch items using To-do platform client = await hass_ws_client() id = ws_req_id() @@ -70,8 +70,8 @@ async def move(uid: str, pos: int | None) -> dict[str, Any]: "entity_id": TEST_ENTITY, "uid": uid, } - if pos is not None: - data["pos"] = pos + if previous_uid is not None: + data["previous_uid"] = previous_uid await client.send_json(data) resp = await client.receive_json() assert resp.get("id") == id @@ -406,10 +406,10 @@ async def test_update_invalid_item( ("src_idx", "dst_idx", "expected_items"), [ # Move any item to the front of the list - (0, 0, ["item 1", "item 2", "item 3", "item 4"]), - (1, 0, ["item 2", "item 1", "item 3", "item 4"]), - (2, 0, ["item 3", "item 1", "item 2", "item 4"]), - (3, 0, ["item 4", "item 1", "item 2", "item 3"]), + (0, None, ["item 1", "item 2", "item 3", "item 4"]), + (1, None, ["item 2", "item 1", "item 3", "item 4"]), + (2, None, ["item 3", "item 1", "item 2", "item 4"]), + (3, None, ["item 4", "item 1", "item 2", "item 3"]), # Move items right (0, 1, ["item 2", "item 1", "item 3", "item 4"]), (0, 2, ["item 2", "item 3", "item 1", "item 4"]), @@ -417,15 +417,15 @@ async def test_update_invalid_item( (1, 2, ["item 1", "item 3", "item 2", "item 4"]), (1, 3, ["item 1", "item 3", "item 4", "item 2"]), # Move items left - (2, 1, ["item 1", "item 3", "item 2", "item 4"]), - (3, 1, ["item 1", "item 4", "item 2", "item 3"]), - (3, 2, ["item 1", "item 2", "item 4", "item 3"]), + (2, 0, ["item 1", "item 3", "item 2", "item 4"]), + (3, 0, ["item 1", "item 4", "item 2", "item 3"]), + (3, 1, ["item 1", "item 2", "item 4", "item 3"]), # No-ops (0, 0, ["item 1", "item 2", "item 3", "item 4"]), - (1, 1, ["item 1", "item 2", "item 3", "item 4"]), + (2, 1, ["item 1", "item 2", "item 3", "item 4"]), (2, 2, ["item 1", "item 2", "item 3", "item 4"]), + (3, 2, ["item 1", "item 2", "item 3", "item 4"]), (3, 3, ["item 1", "item 2", "item 3", "item 4"]), - (3, 4, ["item 1", "item 2", "item 3", "item 4"]), ], ) async def test_move_item( @@ -433,7 +433,7 @@ async def test_move_item( sl_setup: None, ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], - ws_move_item: Callable[[str, int | None], Awaitable[dict[str, Any]]], + ws_move_item: Callable[[str, str | None], Awaitable[dict[str, Any]]], src_idx: int, dst_idx: int | None, expected_items: list[str], @@ -457,7 +457,12 @@ async def test_move_item( summaries = [item["summary"] for item in items] assert summaries == ["item 1", "item 2", "item 3", "item 4"] - resp = await ws_move_item(uids[src_idx], dst_idx) + # Prepare items for moving + previous_uid: str | None = None + if dst_idx is not None: + previous_uid = uids[dst_idx] + + resp = await ws_move_item(uids[src_idx], previous_uid) assert resp.get("success") items = await ws_get_items() diff --git a/tests/components/switch/test_device_action.py b/tests/components/switch/test_device_action.py index 85799a49a349d1..e86c32c1e32415 100644 --- a/tests/components/switch/test_device_action.py +++ b/tests/components/switch/test_device_action.py @@ -111,12 +111,21 @@ async def test_get_actions_hidden_auxiliary( async def test_action( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -127,7 +136,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -136,7 +145,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_on"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -145,7 +154,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_toggle"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "toggle", }, @@ -177,12 +186,21 @@ async def test_action( async def test_action_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -193,7 +211,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py index c60954e335f74a..c9521930a736d2 100644 --- a/tests/components/switch/test_device_condition.py +++ b/tests/components/switch/test_device_condition.py @@ -179,12 +179,21 @@ async def test_get_condition_capabilities_legacy( async def test_if_state( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -199,7 +208,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -218,7 +227,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -253,12 +262,21 @@ async def test_if_state( async def test_if_state_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -273,7 +291,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_on", } @@ -300,6 +318,7 @@ async def test_if_state_legacy( async def test_if_fires_on_for_condition( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -309,7 +328,15 @@ async def test_if_fires_on_for_condition( point2 = point1 + timedelta(seconds=10) point3 = point2 + timedelta(seconds=10) - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -324,7 +351,7 @@ async def test_if_fires_on_for_condition( "condition": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", "for": {"seconds": 5}, diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py index 32f8f65b114b56..03f7e8fbb8ed87 100644 --- a/tests/components/switch/test_device_trigger.py +++ b/tests/components/switch/test_device_trigger.py @@ -177,12 +177,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -195,7 +204,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -219,7 +228,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", }, @@ -243,7 +252,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "changed_states", }, @@ -288,12 +297,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -306,7 +324,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_off", }, @@ -343,12 +361,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -361,7 +388,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index 48f0021bdb46d2..e9f0a0a475d5fd 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from switchbot_api import CannotConnect, Device, InvalidAuth, PowerState +from switchbot_api import CannotConnect, Device, InvalidAuth, PowerState, Remote from homeassistant.components.switchbot_cloud import SwitchBotAPI from homeassistant.config_entries import ConfigEntryState @@ -32,12 +32,24 @@ async def test_setup_entry_success( ) -> None: """Test successful setup of entry.""" mock_list_devices.return_value = [ + Remote( + deviceId="air-conditonner-id-1", + deviceName="air-conditonner-name-1", + remoteType="Air Conditioner", + hubDeviceId="test-hub-id", + ), Device( - deviceId="test-id", - deviceName="test-name", + deviceId="plug-id-1", + deviceName="plug-name-1", deviceType="Plug", hubDeviceId="test-hub-id", - ) + ), + Remote( + deviceId="plug-id-2", + deviceName="plug-name-2", + remoteType="DIY Plug", + hubDeviceId="test-hub-id", + ), ] mock_get_status.return_value = {"power": PowerState.ON.value} entry = configure_integration(hass) diff --git a/tests/components/text/test_device_action.py b/tests/components/text/test_device_action.py index 88bf692b711bb8..59b77ecfa06b65 100644 --- a/tests/components/text/test_device_action.py +++ b/tests/components/text/test_device_action.py @@ -137,9 +137,21 @@ async def test_get_action_no_state( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, 0.5, {"min_value": 0.0, "max_value": 1.0}) assert await async_setup_component( @@ -154,7 +166,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_value", "value": 0.3, @@ -175,10 +187,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, 0.5, {"min_value": 0.0, "max_value": 1.0}) assert await async_setup_component( @@ -193,7 +215,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "set_value", "value": 0.3, diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 833a4ea266bc82..f4d671ad352a24 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -571,7 +571,7 @@ async def test_move_todo_item_service_by_id( "type": "todo/item/move", "entity_id": "todo.entity1", "uid": "item-1", - "pos": "1", + "previous_uid": "item-2", } ) resp = await client.receive_json() @@ -581,7 +581,7 @@ async def test_move_todo_item_service_by_id( args = test_entity.async_move_todo_item.call_args assert args assert args.kwargs.get("uid") == "item-1" - assert args.kwargs.get("pos") == 1 + assert args.kwargs.get("previous_uid") == "item-2" async def test_move_todo_item_service_raises( @@ -601,7 +601,7 @@ async def test_move_todo_item_service_raises( "type": "todo/item/move", "entity_id": "todo.entity1", "uid": "item-1", - "pos": "1", + "previous_uid": "item-2", } ) resp = await client.receive_json() @@ -620,15 +620,10 @@ async def test_move_todo_item_service_raises( ), ({"entity_id": "todo.entity1"}, "invalid_format", "required key not provided"), ( - {"entity_id": "todo.entity1", "pos": "2"}, + {"entity_id": "todo.entity1", "previous_uid": "item-2"}, "invalid_format", "required key not provided", ), - ( - {"entity_id": "todo.entity1", "uid": "item-1", "pos": "-2"}, - "invalid_format", - "value must be at least 0", - ), ], ) async def test_move_todo_item_service_invalid_input( @@ -722,7 +717,7 @@ async def test_move_item_unsupported( "type": "todo/item/move", "entity_id": "todo.entity1", "uid": "item-1", - "pos": "1", + "previous_uid": "item-2", } ) resp = await client.receive_json() diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 6543e5b678f42b..28f22e1061a181 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -9,15 +9,17 @@ from todoist_api_python.models import Collaborator, Due, Label, Project, Task from homeassistant.components.todoist import DOMAIN -from homeassistant.const import CONF_TOKEN +from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry +PROJECT_ID = "project-id-1" SUMMARY = "A task" TOKEN = "some-token" +TODAY = dt_util.now().strftime("%Y-%m-%d") @pytest.fixture @@ -37,38 +39,49 @@ def mock_due() -> Due: ) -@pytest.fixture(name="task") -def mock_task(due: Due) -> Task: +def make_api_task( + id: str | None = None, + content: str | None = None, + is_completed: bool = False, + due: Due | None = None, + project_id: str | None = None, +) -> Task: """Mock a todoist Task instance.""" return Task( assignee_id="1", assigner_id="1", comment_count=0, - is_completed=False, - content=SUMMARY, + is_completed=is_completed, + content=content or SUMMARY, created_at="2021-10-01T00:00:00", creator_id="1", description="A task", - due=due, - id="1", + due=due or Due(is_recurring=False, date=TODAY, string="today"), + id=id or "1", labels=["Label1"], order=1, parent_id=None, priority=1, - project_id="12345", + project_id=project_id or PROJECT_ID, section_id=None, url="https://todoist.com", sync_id=None, ) +@pytest.fixture(name="tasks") +def mock_tasks(due: Due) -> list[Task]: + """Mock a todoist Task instance.""" + return [make_api_task(due=due)] + + @pytest.fixture(name="api") -def mock_api(task) -> AsyncMock: +def mock_api(tasks: list[Task]) -> AsyncMock: """Mock the api state.""" api = AsyncMock() api.get_projects.return_value = [ Project( - id="12345", + id=PROJECT_ID, color="blue", comment_count=0, is_favorite=False, @@ -88,7 +101,7 @@ def mock_api(task) -> AsyncMock: api.get_collaborators.return_value = [ Collaborator(email="user@gmail.com", id="1", name="user") ] - api.get_tasks.return_value = [task] + api.get_tasks.return_value = tasks return api @@ -121,15 +134,25 @@ def mock_todoist_domain() -> str: return DOMAIN +@pytest.fixture(autouse=True) +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + @pytest.fixture(name="setup_integration") async def mock_setup_integration( hass: HomeAssistant, + platforms: list[Platform], api: AsyncMock, todoist_config_entry: MockConfigEntry | None, ) -> None: """Mock setup of the todoist integration.""" if todoist_config_entry is not None: todoist_config_entry.add_to_hass(hass) - with patch("homeassistant.components.todoist.TodoistAPIAsync", return_value=api): + with patch( + "homeassistant.components.todoist.TodoistAPIAsync", return_value=api + ), patch("homeassistant.components.todoist.PLATFORMS", platforms): assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() yield diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 45300e2e66cc5b..761eeb07c6191f 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -18,13 +18,13 @@ PROJECT_NAME, SERVICE_NEW_TASK, ) -from homeassistant.const import CONF_TOKEN +from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.util import dt as dt_util -from .conftest import SUMMARY +from .conftest import PROJECT_ID, SUMMARY from tests.typing import ClientSessionGenerator @@ -34,6 +34,12 @@ TIMEZONE = zoneinfo.ZoneInfo(TZ_NAME) +@pytest.fixture(autouse=True) +def platforms() -> list[Platform]: + """Override platforms.""" + return [Platform.CALENDAR] + + @pytest.fixture(autouse=True) def set_time_zone(hass: HomeAssistant): """Set the time zone for the tests.""" @@ -97,7 +103,7 @@ async def test_calendar_entity_unique_id( ) -> None: """Test unique id is set to project id.""" entity = entity_registry.async_get("calendar.name") - assert entity.unique_id == "12345" + assert entity.unique_id == PROJECT_ID @pytest.mark.parametrize( @@ -256,7 +262,7 @@ async def test_create_task_service_call(hass: HomeAssistant, api: AsyncMock) -> await hass.async_block_till_done() api.add_task.assert_called_with( - "task", project_id="12345", labels=["Label1"], assignee_id="1" + "task", project_id=PROJECT_ID, labels=["Label1"], assignee_id="1" ) diff --git a/tests/components/todoist/test_config_flow.py b/tests/components/todoist/test_config_flow.py index 4175902da3131b..141f12269de02f 100644 --- a/tests/components/todoist/test_config_flow.py +++ b/tests/components/todoist/test_config_flow.py @@ -69,7 +69,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: ) assert result2.get("type") == FlowResultType.FORM - assert result2.get("errors") == {"base": "invalid_access_token"} + assert result2.get("errors") == {"base": "invalid_api_key"} @pytest.mark.parametrize("todoist_api_status", [HTTPStatus.INTERNAL_SERVER_ERROR]) diff --git a/tests/components/todoist/test_init.py b/tests/components/todoist/test_init.py index cc64464df1d0d7..0e80be5410f8a2 100644 --- a/tests/components/todoist/test_init.py +++ b/tests/components/todoist/test_init.py @@ -1,7 +1,6 @@ """Unit tests for the Todoist integration.""" -from collections.abc import Generator from http import HTTPStatus -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest @@ -12,15 +11,6 @@ from tests.common import MockConfigEntry -@pytest.fixture(autouse=True) -def mock_platforms() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.todoist.PLATFORMS", return_value=[] - ) as mock_setup_entry: - yield mock_setup_entry - - async def test_load_unload( hass: HomeAssistant, setup_integration: None, diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py new file mode 100644 index 00000000000000..bbfaf6c493bae3 --- /dev/null +++ b/tests/components/todoist/test_todo.py @@ -0,0 +1,256 @@ +"""Unit tests for the Todoist todo platform.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_component import async_update_entity + +from .conftest import PROJECT_ID, make_api_task + + +@pytest.fixture(autouse=True) +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.TODO] + + +@pytest.mark.parametrize( + ("tasks", "expected_state"), + [ + ([], "0"), + ([make_api_task(id="12345", content="Soda", is_completed=False)], "1"), + ([make_api_task(id="12345", content="Soda", is_completed=True)], "0"), + ( + [ + make_api_task(id="12345", content="Milk", is_completed=False), + make_api_task(id="54321", content="Soda", is_completed=False), + ], + "2", + ), + ( + [ + make_api_task( + id="12345", + content="Soda", + is_completed=False, + project_id="other-project-id", + ) + ], + "0", + ), + ], +) +async def test_todo_item_state( + hass: HomeAssistant, + setup_integration: None, + expected_state: str, +) -> None: + """Test for a To-do List entity state.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == expected_state + + +@pytest.mark.parametrize(("tasks"), [[]]) +async def test_create_todo_list_item( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for creating a To-do Item.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "0" + + api.add_task = AsyncMock() + # Fake API response when state is refreshed after create + api.get_tasks.return_value = [ + make_api_task(id="task-id-1", content="Soda", is_completed=False) + ] + + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "Soda"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + + args = api.add_task.call_args + assert args + assert args.kwargs.get("content") == "Soda" + assert args.kwargs.get("project_id") == PROJECT_ID + + # Verify state is refreshed + state = hass.states.get("todo.name") + assert state + assert state.state == "1" + + +@pytest.mark.parametrize(("tasks"), [[]]) +async def test_create_completed_item_unsupported( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for creating a To-do Item that is already completed.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "0" + + api.add_task = AsyncMock() + + with pytest.raises(ValueError, match="Only active tasks"): + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "Soda", "status": "completed"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] +) +async def test_update_todo_item_status( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for updating a To-do Item that changes the status.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "1" + + api.close_task = AsyncMock() + api.reopen_task = AsyncMock() + + # Fake API response when state is refreshed after close + api.get_tasks.return_value = [ + make_api_task(id="task-id-1", content="Soda", is_completed=True) + ] + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"uid": "task-id-1", "status": "completed"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + assert api.close_task.called + args = api.close_task.call_args + assert args + assert args.kwargs.get("task_id") == "task-id-1" + assert not api.reopen_task.called + + # Verify state is refreshed + state = hass.states.get("todo.name") + assert state + assert state.state == "0" + + # Fake API response when state is refreshed after reopen + api.get_tasks.return_value = [ + make_api_task(id="task-id-1", content="Soda", is_completed=False) + ] + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"uid": "task-id-1", "status": "needs_action"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + assert api.reopen_task.called + args = api.reopen_task.call_args + assert args + assert args.kwargs.get("task_id") == "task-id-1" + + # Verify state is refreshed + state = hass.states.get("todo.name") + assert state + assert state.state == "1" + + +@pytest.mark.parametrize( + ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] +) +async def test_update_todo_item_summary( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for updating a To-do Item that changes the summary.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "1" + + api.update_task = AsyncMock() + + # Fake API response when state is refreshed after close + api.get_tasks.return_value = [ + make_api_task(id="task-id-1", content="Soda", is_completed=True) + ] + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"uid": "task-id-1", "summary": "Milk"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + assert api.update_task.called + args = api.update_task.call_args + assert args + assert args.kwargs.get("task_id") == "task-id-1" + assert args.kwargs.get("content") == "Milk" + + +@pytest.mark.parametrize( + ("tasks"), + [ + [ + make_api_task(id="task-id-1", content="Soda", is_completed=False), + make_api_task(id="task-id-2", content="Milk", is_completed=False), + ] + ], +) +async def test_delete_todo_item( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for deleting a To-do Item.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "2" + + api.delete_task = AsyncMock() + # Fake API response when state is refreshed after close + api.get_tasks.return_value = [] + + await hass.services.async_call( + TODO_DOMAIN, + "delete_item", + {"uid": ["task-id-1", "task-id-2"]}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + assert api.delete_task.call_count == 2 + args = api.delete_task.call_args_list + assert args[0].kwargs.get("task_id") == "task-id-1" + assert args[1].kwargs.get("task_id") == "task-id-2" + + await async_update_entity(hass, "todo.name") + state = hass.states.get("todo.name") + assert state + assert state.state == "0" diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py index 77335769383e98..53e455ffb8df3b 100644 --- a/tests/components/tomorrowio/test_sensor.py +++ b/tests/components/tomorrowio/test_sensor.py @@ -37,8 +37,8 @@ CO = "carbon_monoxide" NO2 = "nitrogen_dioxide" SO2 = "sulphur_dioxide" -PM25 = "particulate_matter_2_5_mm" -PM10 = "particulate_matter_10_mm" +PM25 = "pm2_5" +PM10 = "pm10" MEP_AQI = "china_mep_air_quality_index" MEP_HEALTH_CONCERN = "china_mep_health_concern" MEP_PRIMARY_POLLUTANT = "china_mep_primary_pollutant" @@ -51,10 +51,10 @@ TREE_POLLEN = "tree_pollen_index" FEELS_LIKE = "feels_like" DEW_POINT = "dew_point" -PRESSURE_SURFACE_LEVEL = "pressure_surface_level" +PRESSURE_SURFACE_LEVEL = "pressure" SNOW_ACCUMULATION = "snow_accumulation" ICE_ACCUMULATION = "ice_accumulation" -GHI = "global_horizontal_irradiance" +GHI = "irradiance" CLOUD_BASE = "cloud_base" CLOUD_COVER = "cloud_cover" CLOUD_CEILING = "cloud_ceiling" @@ -121,6 +121,7 @@ async def _setup( data = _get_config_schema(hass, SOURCE_USER)(config) data[CONF_NAME] = DEFAULT_NAME config_entry = MockConfigEntry( + title=DEFAULT_NAME, domain=DOMAIN, data=data, options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index 229e62065a69da..863623ee524c73 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -78,6 +78,7 @@ async def _setup_config_entry(hass: HomeAssistant, config: dict[str, Any]) -> St data = _get_config_schema(hass, SOURCE_USER)(config) data[CONF_NAME] = DEFAULT_NAME config_entry = MockConfigEntry( + title=DEFAULT_NAME, domain=DOMAIN, data=data, options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, @@ -228,7 +229,7 @@ async def test_v4_weather(hass: HomeAssistant, tomorrowio_config_entry_update) - ATTR_FORECAST_WIND_BEARING: 239.6, ATTR_FORECAST_WIND_SPEED: 34.16, # 9.49 m/s -> km/h } - assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io - Daily" + assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io Daily" assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 23 assert weather_state.attributes[ATTR_WEATHER_OZONE] == 46.53 assert weather_state.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] == "mm" @@ -261,7 +262,7 @@ async def test_v4_weather_legacy_entities(hass: HomeAssistant) -> None: ATTR_FORECAST_WIND_BEARING: 239.6, ATTR_FORECAST_WIND_SPEED: 34.16, # 9.49 m/s -> km/h } - assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io - Daily" + assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io Daily" assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 23 assert weather_state.attributes[ATTR_WEATHER_OZONE] == 46.53 assert weather_state.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] == "mm" diff --git a/tests/components/transmission/test_config_flow.py b/tests/components/transmission/test_config_flow.py index 1bfae98fb71d28..04f44d3b7e78b6 100644 --- a/tests/components/transmission/test_config_flow.py +++ b/tests/components/transmission/test_config_flow.py @@ -76,7 +76,7 @@ async def test_options(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=transmission.DOMAIN, data=MOCK_CONFIG_DATA, - options={"scan_interval": 120}, + options={"limit": 10, "order": "oldest_first"}, ) entry.add_to_hass(hass) @@ -93,11 +93,12 @@ async def test_options(hass: HomeAssistant) -> None: assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={"scan_interval": 10} + result["flow_id"], user_input={"limit": 20} ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"]["scan_interval"] == 10 + assert result["data"]["limit"] == 20 + assert result["data"]["order"] == "oldest_first" async def test_error_on_wrong_credentials( diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 93b39d2fdf2b9a..9d4bde2d016f03 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -167,6 +167,11 @@ def mock_default_unifi_requests( json={"data": wlans_response or [], "meta": {"rc": "ok"}}, headers={"content-type": CONTENT_TYPE_JSON}, ) + aioclient_mock.get( + f"https://{host}:1234/v2/api/site/{site_id}/trafficrules", + json=[{}], + headers={"content-type": CONTENT_TYPE_JSON}, + ) async def setup_unifi_integration( diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index a08cf0be688128..cfcfbe6c3ed072 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -771,7 +771,7 @@ async def test_no_clients( }, ) - assert aioclient_mock.call_count == 11 + assert aioclient_mock.call_count == 12 assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 @@ -860,8 +860,8 @@ async def test_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 12 - assert aioclient_mock.mock_calls[11][2] == { + assert aioclient_mock.call_count == 13 + assert aioclient_mock.mock_calls[12][2] == { "mac": "00:00:00:00:01:01", "cmd": "block-sta", } @@ -869,8 +869,8 @@ async def test_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 13 - assert aioclient_mock.mock_calls[12][2] == { + assert aioclient_mock.call_count == 14 + assert aioclient_mock.mock_calls[13][2] == { "mac": "00:00:00:00:01:01", "cmd": "unblock-sta", } @@ -887,8 +887,8 @@ async def test_switches( {"entity_id": "switch.block_media_streaming"}, blocking=True, ) - assert aioclient_mock.call_count == 14 - assert aioclient_mock.mock_calls[13][2] == {"enabled": False} + assert aioclient_mock.call_count == 15 + assert aioclient_mock.mock_calls[14][2] == {"enabled": False} await hass.services.async_call( SWITCH_DOMAIN, @@ -896,8 +896,8 @@ async def test_switches( {"entity_id": "switch.block_media_streaming"}, blocking=True, ) - assert aioclient_mock.call_count == 15 - assert aioclient_mock.mock_calls[14][2] == {"enabled": True} + assert aioclient_mock.call_count == 16 + assert aioclient_mock.mock_calls[15][2] == {"enabled": True} async def test_remove_switches( @@ -983,8 +983,8 @@ async def test_block_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 12 - assert aioclient_mock.mock_calls[11][2] == { + assert aioclient_mock.call_count == 13 + assert aioclient_mock.mock_calls[12][2] == { "mac": "00:00:00:00:01:01", "cmd": "block-sta", } @@ -992,8 +992,8 @@ async def test_block_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 13 - assert aioclient_mock.mock_calls[12][2] == { + assert aioclient_mock.call_count == 14 + assert aioclient_mock.mock_calls[13][2] == { "mac": "00:00:00:00:01:01", "cmd": "unblock-sta", } diff --git a/tests/components/update/test_device_trigger.py b/tests/components/update/test_device_trigger.py index b2d06a642a881a..16749167c419df 100644 --- a/tests/components/update/test_device_trigger.py +++ b/tests/components/update/test_device_trigger.py @@ -176,6 +176,7 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls: list[ServiceCall], enable_custom_integrations: None, @@ -187,7 +188,14 @@ async def test_if_fires_on_state_change( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get("update.update_available") + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -198,7 +206,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -222,7 +230,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": "update.update_available", "type": "turned_off", }, @@ -270,6 +278,7 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls: list[ServiceCall], enable_custom_integrations: None, @@ -281,7 +290,14 @@ async def test_if_fires_on_state_change_legacy( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get("update.update_available") + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -292,7 +308,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_off", }, @@ -332,6 +348,7 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls: list[ServiceCall], enable_custom_integrations: None, @@ -343,7 +360,14 @@ async def test_if_fires_on_state_change_with_for( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get("update.update_available") + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -354,7 +378,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, diff --git a/tests/components/vacuum/test_device_action.py b/tests/components/vacuum/test_device_action.py index 617b8d41609967..cf0ab3c20d8fdd 100644 --- a/tests/components/vacuum/test_device_action.py +++ b/tests/components/vacuum/test_device_action.py @@ -102,9 +102,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -115,7 +127,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_dock"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "dock", }, @@ -124,7 +136,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_clean"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "clean", }, @@ -151,10 +163,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -165,7 +187,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_event_dock"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "dock", }, diff --git a/tests/components/vacuum/test_device_condition.py b/tests/components/vacuum/test_device_condition.py index 694f4b644170a4..a2ba75cc7520dd 100644 --- a/tests/components/vacuum/test_device_condition.py +++ b/tests/components/vacuum/test_device_condition.py @@ -115,10 +115,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_DOCKED) @@ -133,7 +144,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_cleaning", } @@ -151,7 +162,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_docked", } @@ -189,10 +200,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_CLEANING) @@ -207,7 +229,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_cleaning", } diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index 2f27d299d7eb97..605dd6e5b9ff7c 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -178,10 +178,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_DOCKED) @@ -194,7 +205,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "cleaning", }, @@ -213,7 +224,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "docked", }, @@ -252,10 +263,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_DOCKED) @@ -268,7 +290,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "cleaning", }, @@ -298,10 +320,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_DOCKED) @@ -314,7 +347,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "cleaning", "for": {"seconds": 5}, diff --git a/tests/components/water_heater/test_device_action.py b/tests/components/water_heater/test_device_action.py index a8ca41905d6827..8254fb77a77f9b 100644 --- a/tests/components/water_heater/test_device_action.py +++ b/tests/components/water_heater/test_device_action.py @@ -102,9 +102,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -118,7 +130,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -130,7 +142,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -157,10 +169,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -174,7 +196,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turn_off", }, diff --git a/tests/components/wemo/test_device_trigger.py b/tests/components/wemo/test_device_trigger.py index 4ae8dcaddb1dbd..9140f5f1e35010 100644 --- a/tests/components/wemo/test_device_trigger.py +++ b/tests/components/wemo/test_device_trigger.py @@ -19,7 +19,6 @@ from tests.common import async_get_device_automations, async_mock_service -MOCK_DEVICE_ID = "some-device-id" DATA_MESSAGE = {"message": "service-called"} @@ -96,12 +95,12 @@ async def test_get_triggers(hass: HomeAssistant, wemo_entity) -> None: assert triggers == unordered(expected_triggers) -async def test_fires_on_long_press(hass: HomeAssistant) -> None: +async def test_fires_on_long_press(hass: HomeAssistant, wemo_entity) -> None: """Test wemo long press trigger firing.""" - assert await setup_automation(hass, MOCK_DEVICE_ID, EVENT_TYPE_LONG_PRESS) + assert await setup_automation(hass, wemo_entity.device_id, EVENT_TYPE_LONG_PRESS) calls = async_mock_service(hass, "test", "automation") - message = {CONF_DEVICE_ID: MOCK_DEVICE_ID, CONF_TYPE: EVENT_TYPE_LONG_PRESS} + message = {CONF_DEVICE_ID: wemo_entity.device_id, CONF_TYPE: EVENT_TYPE_LONG_PRESS} hass.bus.async_fire(WEMO_SUBSCRIPTION_EVENT, message) await hass.async_block_till_done() assert len(calls) == 1 diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index 8d8207cdf9a8d0..cd0e9994f7441c 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -5,7 +5,7 @@ from urllib.parse import urlparse from aiohttp.test_utils import TestClient -from aiowithings import Activity, Goals, MeasurementGroup, SleepSummary +from aiowithings import Activity, Goals, MeasurementGroup, SleepSummary, Workout from freezegun.api import FrozenDateTimeFactory from homeassistant.components.webhook import async_generate_url @@ -89,11 +89,19 @@ def load_measurements_fixture( def load_activity_fixture( fixture: str = "withings/activity.json", ) -> list[Activity]: - """Return measurement from fixture.""" + """Return activities from fixture.""" activity_json = load_json_array_fixture(fixture) return [Activity.from_api(activity) for activity in activity_json] +def load_workout_fixture( + fixture: str = "withings/workouts.json", +) -> list[Workout]: + """Return workouts from fixture.""" + workouts_json = load_json_array_fixture(fixture) + return [Workout.from_api(workout) for workout in workouts_json] + + def load_sleep_fixture( fixture: str = "withings/sleep_summaries.json", ) -> list[SleepSummary]: diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index b040ccd2b58f99..7f15c5e02523b4 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -21,6 +21,7 @@ load_goals_fixture, load_measurements_fixture, load_sleep_fixture, + load_workout_fixture, ) CLIENT_ID = "1234" @@ -144,6 +145,8 @@ def mock_withings(): NotificationConfiguration.from_api(not_conf) for not_conf in notification_json ] + workouts = load_workout_fixture() + activities = load_activity_fixture() mock = AsyncMock(spec=WithingsClient) @@ -155,6 +158,8 @@ def mock_withings(): mock.get_activities_since.return_value = activities mock.get_activities_in_period.return_value = activities mock.list_notification_configurations.return_value = notifications + mock.get_workouts_since.return_value = workouts + mock.get_workouts_in_period.return_value = workouts with patch( "homeassistant.components.withings.WithingsClient", diff --git a/tests/components/withings/fixtures/workouts.json b/tests/components/withings/fixtures/workouts.json new file mode 100644 index 00000000000000..d5edcc75580de1 --- /dev/null +++ b/tests/components/withings/fixtures/workouts.json @@ -0,0 +1,327 @@ +[ + { + "id": 3661300277, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1693336011, + "enddate": 1693336513, + "date": "2023-08-29", + "deviceid": null, + "data": { + "calories": 47, + "intensity": 30, + "manual_distance": 60, + "manual_calories": 70, + "hr_average": 80, + "hr_min": 70, + "hr_max": 80, + "hr_zone_0": 100, + "hr_zone_1": 200, + "hr_zone_2": 300, + "hr_zone_3": 400, + "pause_duration": 80, + "steps": 779, + "distance": 680, + "elevation": 10, + "algo_pause_duration": null, + "spo2_average": 15 + }, + "modified": 1693481873 + }, + { + "id": 3661300290, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1693469307, + "enddate": 1693469924, + "date": "2023-08-31", + "deviceid": null, + "data": { + "algo_pause_duration": null + }, + "modified": 1693481873 + }, + { + "id": 3661300269, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1691164839, + "enddate": 1691165719, + "date": "2023-08-04", + "deviceid": null, + "data": { + "calories": 82, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 1450, + "distance": 1294, + "elevation": 18, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1693481873 + }, + { + "id": 3743596080, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1695425635, + "enddate": 1695426661, + "date": "2023-09-23", + "deviceid": null, + "data": { + "calories": 97, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 1650, + "distance": 1405, + "elevation": 19, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1696672530 + }, + { + "id": 3743596073, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1694715649, + "enddate": 1694716306, + "date": "2023-09-14", + "deviceid": null, + "data": { + "calories": 62, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 1076, + "distance": 917, + "elevation": 15, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1696672530 + }, + { + "id": 3743596085, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1695426953, + "enddate": 1695427093, + "date": "2023-09-23", + "deviceid": null, + "data": { + "calories": 13, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 216, + "distance": 185, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1696672530 + }, + { + "id": 3743596072, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1694713351, + "enddate": 1694715327, + "date": "2023-09-14", + "deviceid": null, + "data": { + "calories": 187, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 3339, + "distance": 2908, + "elevation": 49, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1696672530 + }, + { + "id": 3752609171, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1696835569, + "enddate": 1696835767, + "date": "2023-10-09", + "deviceid": null, + "data": { + "calories": 18, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 291, + "distance": 261, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1697038119 + }, + { + "id": 3752609178, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1696844383, + "enddate": 1696844638, + "date": "2023-10-09", + "deviceid": null, + "data": { + "calories": 24, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 267, + "distance": 232, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1697038119 + }, + { + "id": 3752609174, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1696842803, + "enddate": 1696843032, + "date": "2023-10-09", + "deviceid": null, + "data": { + "calories": 21, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 403, + "distance": 359, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1697038119 + }, + { + "id": 3752609174, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1696842803, + "enddate": 1696843032, + "date": "2023-10-09", + "deviceid": null, + "data": { + "calories": 21, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 403, + "distance": 359, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1697038119 + } +] diff --git a/tests/components/withings/snapshots/test_calendar.ambr b/tests/components/withings/snapshots/test_calendar.ambr new file mode 100644 index 00000000000000..045b4216a2f051 --- /dev/null +++ b/tests/components/withings/snapshots/test_calendar.ambr @@ -0,0 +1,167 @@ +# serializer version: 1 +# name: test_api_calendar + list([ + dict({ + 'entity_id': 'calendar.henk_workouts', + 'name': 'henk Workouts', + }), + ]) +# --- +# name: test_api_events + list([ + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-08-29T12:15:13-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-08-29T12:06:51-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-08-31T01:18:44-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-08-31T01:08:27-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-08-04T09:15:19-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-08-04T09:00:39-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-09-22T16:51:01-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-09-22T16:33:55-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-09-14T11:31:46-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-09-14T11:20:49-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-09-22T16:58:13-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-09-22T16:55:53-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-09-14T11:15:27-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-09-14T10:42:31-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-10-09T00:16:07-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-10-09T00:12:49-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-10-09T02:43:58-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-10-09T02:39:43-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-10-09T02:17:12-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-10-09T02:13:23-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-10-09T02:17:12-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-10-09T02:13:23-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + ]) +# --- diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 75d87a23a9c10c..59d9b470247c7d 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -5,7 +5,7 @@ 'friendly_name': 'henk Active calories burnt today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': 'Calories', + 'unit_of_measurement': 'calories', }), 'context': , 'entity_id': 'sensor.henk_active_calories_burnt_today', @@ -103,6 +103,19 @@ 'state': '9', }) # --- +# name: test_all_entities[sensor.henk_calories_burnt_last_workout] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Calories burnt last workout', + 'unit_of_measurement': 'calories', + }), + 'context': , + 'entity_id': 'sensor.henk_calories_burnt_last_workout', + 'last_changed': , + 'last_updated': , + 'state': '24', + }) +# --- # name: test_all_entities[sensor.henk_deep_sleep] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -133,6 +146,21 @@ 'state': '70', }) # --- +# name: test_all_entities[sensor.henk_distance_travelled_last_workout] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'henk Distance travelled last workout', + 'icon': 'mdi:map-marker-distance', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_distance_travelled_last_workout', + 'last_changed': , + 'last_updated': , + 'state': '232', + }) +# --- # name: test_all_entities[sensor.henk_distance_travelled_today] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -209,6 +237,20 @@ 'state': '0.07', }) # --- +# name: test_all_entities[sensor.henk_floors_climbed_last_workout] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Floors climbed last workout', + 'icon': 'mdi:stairs-up', + 'unit_of_measurement': 'floors', + }), + 'context': , + 'entity_id': 'sensor.henk_floors_climbed_last_workout', + 'last_changed': , + 'last_updated': , + 'state': '4', + }) +# --- # name: test_all_entities[sensor.henk_floors_climbed_today] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -216,7 +258,7 @@ 'icon': 'mdi:stairs-up', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': 'Floors', + 'unit_of_measurement': 'floors', }), 'context': , 'entity_id': 'sensor.henk_floors_climbed_today', @@ -302,6 +344,97 @@ 'state': '100', }) # --- +# name: test_all_entities[sensor.henk_last_workout_duration] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Last workout duration', + 'icon': 'mdi:timer', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_last_workout_duration', + 'last_changed': , + 'last_updated': , + 'state': '255.0', + }) +# --- +# name: test_all_entities[sensor.henk_last_workout_intensity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Last workout intensity', + }), + 'context': , + 'entity_id': 'sensor.henk_last_workout_intensity', + 'last_changed': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_all_entities[sensor.henk_last_workout_type] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'henk Last workout type', + 'options': list([ + 'walk', + 'run', + 'hiking', + 'skating', + 'bmx', + 'bicycling', + 'swimming', + 'surfing', + 'kitesurfing', + 'windsurfing', + 'bodyboard', + 'tennis', + 'table_tennis', + 'squash', + 'badminton', + 'lift_weights', + 'calisthenics', + 'elliptical', + 'pilates', + 'basket_ball', + 'soccer', + 'football', + 'rugby', + 'volley_ball', + 'waterpolo', + 'horse_riding', + 'golf', + 'yoga', + 'dancing', + 'boxing', + 'fencing', + 'wrestling', + 'martial_arts', + 'skiing', + 'snowboarding', + 'other', + 'no_activity', + 'rowing', + 'zumba', + 'baseball', + 'handball', + 'hockey', + 'ice_hockey', + 'climbing', + 'ice_skating', + 'multi_sport', + 'indoor_walk', + 'indoor_running', + 'indoor_cycling', + ]), + }), + 'context': , + 'entity_id': 'sensor.henk_last_workout_type', + 'last_changed': , + 'last_updated': , + 'state': 'walk', + }) +# --- # name: test_all_entities[sensor.henk_light_sleep] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -407,6 +540,21 @@ 'state': '50', }) # --- +# name: test_all_entities[sensor.henk_pause_during_last_workout] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Pause during last workout', + 'icon': 'mdi:timer-pause', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_pause_during_last_workout', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_all_entities[sensor.henk_pulse_wave_velocity] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -546,7 +694,7 @@ 'friendly_name': 'henk Step goal', 'icon': 'mdi:shoe-print', 'state_class': , - 'unit_of_measurement': 'Steps', + 'unit_of_measurement': 'steps', }), 'context': , 'entity_id': 'sensor.henk_step_goal', @@ -562,7 +710,7 @@ 'icon': 'mdi:shoe-print', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': 'Steps', + 'unit_of_measurement': 'steps', }), 'context': , 'entity_id': 'sensor.henk_steps_today', @@ -638,7 +786,7 @@ 'friendly_name': 'henk Total calories burnt today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': 'Calories', + 'unit_of_measurement': 'calories', }), 'context': , 'entity_id': 'sensor.henk_total_calories_burnt_today', diff --git a/tests/components/withings/test_calendar.py b/tests/components/withings/test_calendar.py new file mode 100644 index 00000000000000..227f65473fc93e --- /dev/null +++ b/tests/components/withings/test_calendar.py @@ -0,0 +1,85 @@ +"""Tests for the Withings calendar.""" +from datetime import date, timedelta +from http import HTTPStatus +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import load_workout_fixture + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.withings import setup_integration +from tests.typing import ClientSessionGenerator + + +async def test_api_calendar( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test the API returns the calendar.""" + await setup_integration(hass, polling_config_entry, False) + + client = await hass_client() + response = await client.get("/api/calendars") + assert response.status == HTTPStatus.OK + data = await response.json() + assert data == snapshot + + +async def test_api_events( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test the Withings calendar view.""" + await setup_integration(hass, polling_config_entry, False) + + client = await hass_client() + response = await client.get( + "/api/calendars/calendar.henk_workouts?start=2023-08-01&end=2023-11-01" + ) + assert withings.get_workouts_in_period.called == 1 + assert withings.get_workouts_in_period.call_args_list[1].args == ( + date(2023, 8, 1), + date(2023, 11, 1), + ) + assert response.status == HTTPStatus.OK + events = await response.json() + assert events == snapshot + + +async def test_calendar_created_when_workouts_available( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the calendar is only created when workouts are available.""" + withings.get_workouts_in_period.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("calendar.henk_workouts") is None + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("calendar.henk_workouts") is None + + withings.get_workouts_in_period.return_value = load_workout_fixture() + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("calendar.henk_workouts") diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index d7add6905e55ac..0bf6b32314680d 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -15,6 +15,7 @@ load_goals_fixture, load_measurements_fixture, load_sleep_fixture, + load_workout_fixture, setup_integration, ) @@ -293,3 +294,50 @@ async def test_sleep_sensors_created_when_receive_sleep_data( await hass.async_block_till_done() assert hass.states.get("sensor.henk_deep_sleep") + + +async def test_workout_sensors_created_when_existed( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test workout sensors will be added if they existed before.""" + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_last_workout_type") + assert hass.states.get("sensor.henk_last_workout_type").state != STATE_UNKNOWN + + withings.get_workouts_in_period.return_value = [] + + await hass.config_entries.async_reload(polling_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_last_workout_type").state == STATE_UNKNOWN + + +async def test_workout_sensors_created_when_receive_workout_data( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test workout sensors will be added if we receive workout data.""" + withings.get_workouts_in_period.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_last_workout_type") is None + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_last_workout_type") is None + + withings.get_workouts_in_period.return_value = load_workout_fixture() + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_last_workout_type") diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index 08f84613ff3154..0adb7583d31ad7 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -11,11 +11,18 @@ from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_TILT_POSITION, DOMAIN as COVER_DOMAIN, SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, + SERVICE_TOGGLE_COVER_TILT, ) from homeassistant.components.zha.core.const import ZHA_EVENT from homeassistant.const import ( @@ -27,6 +34,7 @@ ) from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_component import async_update_entity from .common import ( async_enable_traffic, @@ -64,7 +72,7 @@ def zigpy_cover_device(zigpy_device_mock): endpoints = { 1: { SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, - SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.IAS_ZONE, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.WINDOW_COVERING_DEVICE, SIG_EP_INPUT: [closures.WindowCovering.cluster_id], SIG_EP_OUTPUT: [], } @@ -130,10 +138,14 @@ async def test_cover( # load up cover domain cluster = zigpy_cover_device.endpoints.get(1).window_covering - cluster.PLUGGED_ATTR_READS = {"current_position_lift_percentage": 100} + cluster.PLUGGED_ATTR_READS = { + "current_position_lift_percentage": 65, + "current_position_tilt_percentage": 42, + } zha_device = await zha_device_joined_restored(zigpy_cover_device) assert cluster.read_attributes.call_count == 1 assert "current_position_lift_percentage" in cluster.read_attributes.call_args[0][0] + assert "current_position_tilt_percentage" in cluster.read_attributes.call_args[0][0] entity_id = find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None @@ -146,6 +158,16 @@ async def test_cover( await async_enable_traffic(hass, [zha_device]) await hass.async_block_till_done() + # test update + prev_call_count = cluster.read_attributes.call_count + await async_update_entity(hass, entity_id) + assert cluster.read_attributes.call_count == prev_call_count + 2 + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 35 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 58 + # test that the state has changed from unavailable to off await send_attributes_report(hass, cluster, {0: 0, 8: 100, 1: 1}) assert hass.states.get(entity_id).state == STATE_CLOSED @@ -154,6 +176,14 @@ async def test_cover( await send_attributes_report(hass, cluster, {0: 1, 8: 0, 1: 100}) assert hass.states.get(entity_id).state == STATE_OPEN + # test that the state remains after tilting to 100% + await send_attributes_report(hass, cluster, {0: 0, 9: 100, 1: 1}) + assert hass.states.get(entity_id).state == STATE_OPEN + + # test to see the state remains after tilting to 0% + await send_attributes_report(hass, cluster, {0: 1, 9: 0, 1: 100}) + assert hass.states.get(entity_id).state == STATE_OPEN + # close from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -165,6 +195,20 @@ async def test_cover( assert cluster.request.call_args[0][2].command.name == "down_close" assert cluster.request.call_args[1]["expect_reply"] is True + with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x08 + assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert cluster.request.call_args[0][3] == 100 + assert cluster.request.call_args[1]["expect_reply"] is True + # open from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -176,6 +220,20 @@ async def test_cover( assert cluster.request.call_args[0][2].command.name == "up_open" assert cluster.request.call_args[1]["expect_reply"] is True + with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x08 + assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert cluster.request.call_args[0][3] == 0 + assert cluster.request.call_args[1]["expect_reply"] is True + # set position UI with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -191,6 +249,20 @@ async def test_cover( assert cluster.request.call_args[0][3] == 53 assert cluster.request.call_args[1]["expect_reply"] is True + with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {"entity_id": entity_id, ATTR_TILT_POSITION: 47}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x08 + assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert cluster.request.call_args[0][3] == 53 + assert cluster.request.call_args[1]["expect_reply"] is True + # stop from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x2, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -202,11 +274,39 @@ async def test_cover( assert cluster.request.call_args[0][2].command.name == "stop" assert cluster.request.call_args[1]["expect_reply"] is True + with patch("zigpy.zcl.Cluster.request", return_value=[0x2, zcl_f.Status.SUCCESS]): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x02 + assert cluster.request.call_args[0][2].command.name == "stop" + assert cluster.request.call_args[1]["expect_reply"] is True + # test rejoin cluster.PLUGGED_ATTR_READS = {"current_position_lift_percentage": 0} await async_test_rejoin(hass, zigpy_cover_device, [cluster], (1,)) assert hass.states.get(entity_id).state == STATE_OPEN + # test toggle + with patch("zigpy.zcl.Cluster.request", return_value=[0x2, zcl_f.Status.SUCCESS]): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_TOGGLE_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x08 + assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert cluster.request.call_args[0][3] == 100 + assert cluster.request.call_args[1]["expect_reply"] is True + async def test_cover_failures( hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_device @@ -215,7 +315,10 @@ async def test_cover_failures( # load up cover domain cluster = zigpy_cover_device.endpoints.get(1).window_covering - cluster.PLUGGED_ATTR_READS = {"current_position_lift_percentage": 100} + cluster.PLUGGED_ATTR_READS = { + "current_position_lift_percentage": None, + "current_position_tilt_percentage": 42, + } zha_device = await zha_device_joined_restored(zigpy_cover_device) entity_id = find_entity_id(Platform.COVER, zha_device, hass) @@ -225,11 +328,17 @@ async def test_cover_failures( # test that the cover was created and that it is unavailable assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + # test update returned None + prev_call_count = cluster.read_attributes.call_count + await async_update_entity(hass, entity_id) + assert cluster.read_attributes.call_count == prev_call_count + 2 + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + # allow traffic to flow through the gateway and device await async_enable_traffic(hass, [zha_device]) await hass.async_block_till_done() - # test that the state has changed from unavailable to off + # test that the state has changed from unavailable to closed await send_attributes_report(hass, cluster, {0: 0, 8: 100, 1: 1}) assert hass.states.get(entity_id).state == STATE_CLOSED @@ -258,6 +367,26 @@ async def test_cover_failures( == closures.WindowCovering.ServerCommandDefs.down_close.id ) + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(HomeAssistantError, match=r"Failed to close cover tilt"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id + ) + # open from UI with patch( "zigpy.zcl.Cluster.request", @@ -279,6 +408,26 @@ async def test_cover_failures( == closures.WindowCovering.ServerCommandDefs.up_open.id ) + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(HomeAssistantError, match=r"Failed to open cover tilt"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id + ) + # set position UI with patch( "zigpy.zcl.Cluster.request", @@ -301,6 +450,28 @@ async def test_cover_failures( == closures.WindowCovering.ServerCommandDefs.go_to_lift_percentage.id ) + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises( + HomeAssistantError, match=r"Failed to set cover tilt position" + ): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {"entity_id": entity_id, "tilt_position": 42}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id + ) + # stop from UI with patch( "zigpy.zcl.Cluster.request", @@ -499,11 +670,10 @@ async def test_shade( assert cluster_level.request.call_args[0][1] in (0x0003, 0x0007) -async def test_restore_state( +async def test_shade_restore_state( hass: HomeAssistant, zha_device_restored, zigpy_shade_device ) -> None: """Ensure states are restored on startup.""" - mock_restore_cache( hass, ( @@ -521,9 +691,36 @@ async def test_restore_state( entity_id = find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None - # test that the cover was created and that it is unavailable + # test that the cover was created and that it is available + assert hass.states.get(entity_id).state == STATE_OPEN + assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == 50 + + +async def test_cover_restore_state( + hass: HomeAssistant, zha_device_restored, zigpy_cover_device +) -> None: + """Ensure states are restored on startup.""" + mock_restore_cache( + hass, + ( + State( + "cover.fakemanufacturer_fakemodel_cover", + STATE_OPEN, + {ATTR_CURRENT_POSITION: 50, ATTR_CURRENT_TILT_POSITION: 42}, + ), + ), + ) + + hass.state = CoreState.starting + + zha_device = await zha_device_restored(zigpy_cover_device) + entity_id = find_entity_id(Platform.COVER, zha_device, hass) + assert entity_id is not None + + # test that the cover was created and that it is available assert hass.states.get(entity_id).state == STATE_OPEN assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == 50 + assert hass.states.get(entity_id).attributes[ATTR_CURRENT_TILT_POSITION] == 42 async def test_keen_vent( diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 81ab1c2e0f5a32..737604482d881b 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -76,7 +76,7 @@ def fan_platform_only(): @pytest.fixture def zigpy_device(zigpy_device_mock): - """Device tracker zigpy device.""" + """Fan zigpy device.""" endpoints = { 1: { SIG_EP_INPUT: [hvac.Fan.cluster_id], @@ -540,7 +540,7 @@ async def test_fan_update_entity( @pytest.fixture def zigpy_device_ikea(zigpy_device_mock): - """Device tracker zigpy device.""" + """Ikea fan zigpy device.""" endpoints = { 1: { SIG_EP_INPUT: [ @@ -725,3 +725,179 @@ async def test_fan_ikea_update_entity( assert cluster.read_attributes.await_count == 5 else: assert cluster.read_attributes.await_count == 8 + + +@pytest.fixture +def zigpy_device_kof(zigpy_device_mock): + """Fan by King of Fans zigpy device.""" + endpoints = { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.Identify.cluster_id, + general.Groups.cluster_id, + general.Scenes.cluster_id, + 64637, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COMBINED_INTERFACE, + SIG_EP_PROFILE: zha.PROFILE_ID, + }, + } + return zigpy_device_mock( + endpoints, + manufacturer="King Of Fans, Inc.", + model="HBUniversalCFRemote", + quirk=zhaquirks.kof.kof_mr101z.CeilingFan, + node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00", + ) + + +async def test_fan_kof( + hass: HomeAssistant, + zha_device_joined_restored: ZHADevice, + zigpy_device_kof: Device, +) -> None: + """Test ZHA fan platform for King of Fans.""" + zha_device = await zha_device_joined_restored(zigpy_device_kof) + cluster = zigpy_device_kof.endpoints.get(1).fan + entity_id = find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + + assert hass.states.get(entity_id).state == STATE_OFF + await async_enable_traffic(hass, [zha_device], enabled=False) + # test that the fan was created and that it is unavailable + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + # test that the state has changed from unavailable to off + assert hass.states.get(entity_id).state == STATE_OFF + + # turn on at fan + await send_attributes_report(hass, cluster, {1: 2, 0: 1, 2: 3}) + assert hass.states.get(entity_id).state == STATE_ON + + # turn off at fan + await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 2}) + assert hass.states.get(entity_id).state == STATE_OFF + + # turn on from HA + cluster.write_attributes.reset_mock() + await async_turn_on(hass, entity_id) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 2}, manufacturer=None) + ] + + # turn off from HA + cluster.write_attributes.reset_mock() + await async_turn_off(hass, entity_id) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 0}, manufacturer=None) + ] + + # change speed from HA + cluster.write_attributes.reset_mock() + await async_set_percentage(hass, entity_id, percentage=100) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 4}, manufacturer=None) + ] + + # change preset_mode from HA + cluster.write_attributes.reset_mock() + await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_SMART) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 6}, manufacturer=None) + ] + + # set invalid preset_mode from HA + cluster.write_attributes.reset_mock() + with pytest.raises(NotValidPresetModeError): + await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_AUTO) + assert len(cluster.write_attributes.mock_calls) == 0 + + # test adding new fan to the network and HA + await async_test_rejoin(hass, zigpy_device_kof, [cluster], (1,)) + + +@pytest.mark.parametrize( + ("plug_read", "expected_state", "expected_percentage", "expected_preset"), + ( + (None, STATE_OFF, None, None), + ({"fan_mode": 0}, STATE_OFF, 0, None), + ({"fan_mode": 1}, STATE_ON, 25, None), + ({"fan_mode": 2}, STATE_ON, 50, None), + ({"fan_mode": 3}, STATE_ON, 75, None), + ({"fan_mode": 4}, STATE_ON, 100, None), + ({"fan_mode": 6}, STATE_ON, None, PRESET_MODE_SMART), + ), +) +async def test_fan_kof_init( + hass: HomeAssistant, + zha_device_joined_restored, + zigpy_device_kof, + plug_read, + expected_state, + expected_percentage, + expected_preset, +) -> None: + """Test ZHA fan platform for King of Fans.""" + + cluster = zigpy_device_kof.endpoints.get(1).fan + cluster.PLUGGED_ATTR_READS = plug_read + + zha_device = await zha_device_joined_restored(zigpy_device_kof) + entity_id = find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + assert hass.states.get(entity_id).state == expected_state + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == expected_percentage + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] == expected_preset + + +async def test_fan_kof_update_entity( + hass: HomeAssistant, + zha_device_joined_restored, + zigpy_device_kof, +) -> None: + """Test ZHA fan platform for King of Fans.""" + + cluster = zigpy_device_kof.endpoints.get(1).fan + cluster.PLUGGED_ATTR_READS = {"fan_mode": 0} + + zha_device = await zha_device_joined_restored(zigpy_device_kof) + entity_id = find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + assert hass.states.get(entity_id).state == STATE_OFF + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 0 + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 4 + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 2 + else: + assert cluster.read_attributes.await_count == 4 + + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_OFF + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 3 + else: + assert cluster.read_attributes.await_count == 5 + + cluster.PLUGGED_ATTR_READS = {"fan_mode": 1} + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_ON + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 25 + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 4 + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 4 + else: + assert cluster.read_attributes.await_count == 6 diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index 2eb61402a95b20..68ff116adead25 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -1,15 +1,14 @@ """Test ZHA registries.""" from __future__ import annotations -import importlib -import inspect import typing from unittest import mock import pytest -import zhaquirks +import zigpy.quirks as zigpy_quirks from homeassistant.components.zha.binary_sensor import IASZone +from homeassistant.components.zha.core.const import ATTR_QUIRK_ID import homeassistant.components.zha.core.registries as registries from homeassistant.helpers import entity_registry as er @@ -19,7 +18,7 @@ MANUFACTURER = "mock manufacturer" MODEL = "mock model" QUIRK_CLASS = "mock.test.quirk.class" -QUIRK_CLASS_SHORT = "quirk.class" +QUIRK_ID = "quirk_id" @pytest.fixture @@ -29,6 +28,7 @@ def zha_device(): dev.manufacturer = MANUFACTURER dev.model = MODEL dev.quirk_class = QUIRK_CLASS + dev.quirk_id = QUIRK_ID return dev @@ -107,17 +107,17 @@ def cluster_handlers(cluster_handler): ), False, ), - (registries.MatchRule(quirk_classes=QUIRK_CLASS), True), - (registries.MatchRule(quirk_classes="no match"), False), + (registries.MatchRule(quirk_ids=QUIRK_ID), True), + (registries.MatchRule(quirk_ids="no match"), False), ( registries.MatchRule( - quirk_classes=QUIRK_CLASS, aux_cluster_handlers="aux_cluster_handler" + quirk_ids=QUIRK_ID, aux_cluster_handlers="aux_cluster_handler" ), True, ), ( registries.MatchRule( - quirk_classes="no match", aux_cluster_handlers="aux_cluster_handler" + quirk_ids="no match", aux_cluster_handlers="aux_cluster_handler" ), False, ), @@ -128,7 +128,7 @@ def cluster_handlers(cluster_handler): cluster_handler_names={"on_off", "level"}, manufacturers=MANUFACTURER, models=MODEL, - quirk_classes=QUIRK_CLASS, + quirk_ids=QUIRK_ID, ), True, ), @@ -187,33 +187,31 @@ def cluster_handlers(cluster_handler): ( registries.MatchRule( cluster_handler_names="on_off", - quirk_classes={"random quirk", QUIRK_CLASS}, + quirk_ids={"random quirk", QUIRK_ID}, ), True, ), ( registries.MatchRule( cluster_handler_names="on_off", - quirk_classes={"random quirk", "another quirk"}, + quirk_ids={"random quirk", "another quirk"}, ), False, ), ( registries.MatchRule( - cluster_handler_names="on_off", quirk_classes=lambda x: x == QUIRK_CLASS + cluster_handler_names="on_off", quirk_ids=lambda x: x == QUIRK_ID ), True, ), ( registries.MatchRule( - cluster_handler_names="on_off", quirk_classes=lambda x: x != QUIRK_CLASS + cluster_handler_names="on_off", quirk_ids=lambda x: x != QUIRK_ID ), False, ), ( - registries.MatchRule( - cluster_handler_names="on_off", quirk_classes=QUIRK_CLASS_SHORT - ), + registries.MatchRule(cluster_handler_names="on_off", quirk_ids=QUIRK_ID), True, ), ], @@ -221,8 +219,7 @@ def cluster_handlers(cluster_handler): def test_registry_matching(rule, matched, cluster_handlers) -> None: """Test strict rule matching.""" assert ( - rule.strict_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_CLASS) - is matched + rule.strict_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_ID) is matched ) @@ -314,8 +311,8 @@ def test_registry_matching(rule, matched, cluster_handlers) -> None: (registries.MatchRule(manufacturers=MANUFACTURER), True), (registries.MatchRule(models=MODEL), True), (registries.MatchRule(models="no match"), False), - (registries.MatchRule(quirk_classes=QUIRK_CLASS), True), - (registries.MatchRule(quirk_classes="no match"), False), + (registries.MatchRule(quirk_ids=QUIRK_ID), True), + (registries.MatchRule(quirk_ids="no match"), False), # match everything ( registries.MatchRule( @@ -323,7 +320,7 @@ def test_registry_matching(rule, matched, cluster_handlers) -> None: cluster_handler_names={"on_off", "level"}, manufacturers=MANUFACTURER, models=MODEL, - quirk_classes=QUIRK_CLASS, + quirk_ids=QUIRK_ID, ), True, ), @@ -332,8 +329,7 @@ def test_registry_matching(rule, matched, cluster_handlers) -> None: def test_registry_loose_matching(rule, matched, cluster_handlers) -> None: """Test loose rule matching.""" assert ( - rule.loose_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_CLASS) - is matched + rule.loose_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_ID) is matched ) @@ -397,12 +393,12 @@ def entity_registry(): @pytest.mark.parametrize( - ("manufacturer", "model", "quirk_class", "match_name"), + ("manufacturer", "model", "quirk_id", "match_name"), ( ("random manufacturer", "random model", "random.class", "OnOff"), ("random manufacturer", MODEL, "random.class", "OnOffModel"), (MANUFACTURER, "random model", "random.class", "OnOffManufacturer"), - ("random manufacturer", "random model", QUIRK_CLASS, "OnOffQuirk"), + ("random manufacturer", "random model", QUIRK_ID, "OnOffQuirk"), (MANUFACTURER, MODEL, "random.class", "OnOffModelManufacturer"), (MANUFACTURER, "some model", "random.class", "OnOffMultimodel"), ), @@ -412,7 +408,7 @@ def test_weighted_match( entity_registry: er.EntityRegistry, manufacturer, model, - quirk_class, + quirk_id, match_name, ) -> None: """Test weightedd match.""" @@ -453,7 +449,7 @@ class OnOffModelManufacturer: pass @entity_registry.strict_match( - s.component, cluster_handler_names="on_off", quirk_classes=QUIRK_CLASS + s.component, cluster_handler_names="on_off", quirk_ids=QUIRK_ID ) class OnOffQuirk: pass @@ -462,7 +458,7 @@ class OnOffQuirk: ch_level = cluster_handler("level", 8) match, claimed = entity_registry.get_entity( - s.component, manufacturer, model, [ch_on_off, ch_level], quirk_class + s.component, manufacturer, model, [ch_on_off, ch_level], quirk_id ) assert match.__name__ == match_name @@ -490,7 +486,7 @@ class SmartEnergySensor2: "manufacturer", "model", cluster_handlers=[ch_se, ch_illuminati], - quirk_class="quirk_class", + quirk_id="quirk_id", ) assert s.binary_sensor in match @@ -520,7 +516,7 @@ class SmartEnergySensor3: "manufacturer", "model", cluster_handlers={ch_se, ch_illuminati}, - quirk_class="quirk_class", + quirk_id="quirk_id", ) assert s.binary_sensor in match @@ -554,18 +550,10 @@ def iter_all_rules() -> typing.Iterable[registries.MatchRule, list[type[ZhaEntit def test_quirk_classes() -> None: - """Make sure that quirk_classes in components matches are valid.""" - - def find_quirk_class(base_obj, quirk_mod, quirk_cls): - """Find a specific quirk class.""" - - module = importlib.import_module(quirk_mod) - clss = dict(inspect.getmembers(module, inspect.isclass)) - # Check quirk_cls in module classes - return quirk_cls in clss + """Make sure that all quirk IDs in components matches exist.""" def quirk_class_validator(value): - """Validate quirk classes during self test.""" + """Validate quirk IDs during self test.""" if callable(value): # Callables cannot be tested return @@ -576,16 +564,22 @@ def quirk_class_validator(value): quirk_class_validator(v) return - quirk_tok = value.rsplit(".", 1) - if len(quirk_tok) != 2: - # quirk_class is at least __module__.__class__ - raise ValueError(f"Invalid quirk class : '{value}'") + if value not in all_quirk_ids: + raise ValueError(f"Quirk ID '{value}' does not exist.") - if not find_quirk_class(zhaquirks, quirk_tok[0], quirk_tok[1]): - raise ValueError(f"Quirk class '{value}' does not exists.") + # get all quirk ID from zigpy quirks registry + all_quirk_ids = [] + for manufacturer in zigpy_quirks._DEVICE_REGISTRY._registry.values(): + for model_quirk_list in manufacturer.values(): + for quirk in model_quirk_list: + quirk_id = getattr(quirk, ATTR_QUIRK_ID, None) + if quirk_id is not None and quirk_id not in all_quirk_ids: + all_quirk_ids.append(quirk_id) + del quirk, model_quirk_list, manufacturer + # validate all quirk IDs used in component match rules for rule, _ in iter_all_rules(): - quirk_class_validator(rule.quirk_classes) + quirk_class_validator(rule.quirk_ids) def test_entity_names() -> None: diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 842110ace8745e..44f01555b19e79 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -1831,7 +1831,7 @@ }, ("fan", "00:11:22:33:44:55:66:77-1-514"): { DEV_SIG_CLUSTER_HANDLERS: ["fan"], - DEV_SIG_ENT_MAP_CLASS: "ZhaFan", + DEV_SIG_ENT_MAP_CLASS: "KofFan", DEV_SIG_ENT_MAP_ID: "fan.king_of_fans_inc_hbuniversalcfremote_fan", }, }, diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index db5495bce01536..5a424b38c5b8fa 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -662,12 +662,6 @@ def logic_group_zdb5100_state_fixture(): return json.loads(load_fixture("zwave_js/logic_group_zdb5100_state.json")) -@pytest.fixture(name="climate_intermatic_pe653_state", scope="session") -def climate_intermatic_pe653_state_fixture(): - """Load Intermatic PE653 Pool Control node state fixture data.""" - return json.loads(load_fixture("zwave_js/climate_intermatic_pe653_state.json")) - - @pytest.fixture(name="central_scene_node_state", scope="session") def central_scene_node_state_fixture(): """Load node with Central Scene CC node state fixture data.""" @@ -677,9 +671,19 @@ def central_scene_node_state_fixture(): # model fixtures +@pytest.fixture(name="listen_block") +def mock_listen_block_fixture(): + """Mock a listen block.""" + return asyncio.Event() + + @pytest.fixture(name="client") def mock_client_fixture( - controller_state, controller_node_state, version_state, log_config_state + controller_state, + controller_node_state, + version_state, + log_config_state, + listen_block, ): """Mock a client.""" with patch( @@ -693,9 +697,7 @@ async def connect(): async def listen(driver_ready: asyncio.Event) -> None: driver_ready.set() - listen_block = asyncio.Event() await listen_block.wait() - pytest.fail("Listen wasn't canceled!") async def disconnect(): client.connected = False @@ -1304,14 +1306,6 @@ def logic_group_zdb5100_fixture(client, logic_group_zdb5100_state): return node -@pytest.fixture(name="climate_intermatic_pe653") -def climate_intermatic_pe653_fixture(client, climate_intermatic_pe653_state): - """Mock an Intermatic PE653 node.""" - node = Node(client, copy.deepcopy(climate_intermatic_pe653_state)) - client.driver.controller.nodes[node.node_id] = node - return node - - @pytest.fixture(name="central_scene_node") def central_scene_node_fixture(client, central_scene_node_state): """Mock a node with the Central Scene CC.""" diff --git a/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json b/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json deleted file mode 100644 index a5e86b9c0137ff..00000000000000 --- a/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json +++ /dev/null @@ -1,4508 +0,0 @@ -{ - "nodeId": 19, - "index": 0, - "status": 4, - "ready": true, - "isListening": true, - "isRouting": true, - "isSecure": false, - "manufacturerId": 5, - "productId": 1619, - "productType": 20549, - "firmwareVersion": "3.9", - "deviceConfig": { - "filename": "/data/db/devices/0x0005/pe653.json", - "isEmbedded": true, - "manufacturer": "Intermatic", - "manufacturerId": 5, - "label": "PE653", - "description": "Pool Control", - "devices": [ - { - "productType": 20549, - "productId": 1619 - } - ], - "firmwareVersion": { - "min": "0.0", - "max": "255.255" - }, - "preferred": false, - "associations": {}, - "paramInformation": { - "_map": {} - }, - "compat": { - "addCCs": {}, - "overrideQueries": { - "overrides": {} - } - } - }, - "label": "PE653", - "endpointCountIsDynamic": false, - "endpointsHaveIdenticalCapabilities": false, - "individualEndpointCount": 39, - "aggregatedEndpointCount": 0, - "interviewAttempts": 1, - "endpoints": [ - { - "nodeId": 19, - "index": 0, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 145, - "name": "Manufacturer Proprietary", - "version": 1, - "isSecure": false - }, - { - "id": 115, - "name": "Powerlevel", - "version": 1, - "isSecure": false - }, - { - "id": 114, - "name": "Manufacturer Specific", - "version": 1, - "isSecure": false - }, - { - "id": 134, - "name": "Version", - "version": 1, - "isSecure": false - }, - { - "id": 129, - "name": "Clock", - "version": 1, - "isSecure": false - }, - { - "id": 96, - "name": "Multi Channel", - "version": 1, - "isSecure": false - }, - { - "id": 112, - "name": "Configuration", - "version": 1, - "isSecure": false - }, - { - "id": 133, - "name": "Association", - "version": 1, - "isSecure": false - }, - { - "id": 67, - "name": "Thermostat Setpoint", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 48, - "name": "Binary Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 1, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 145, - "name": "Manufacturer Proprietary", - "version": 1, - "isSecure": false - }, - { - "id": 115, - "name": "Powerlevel", - "version": 1, - "isSecure": false - }, - { - "id": 112, - "name": "Configuration", - "version": 1, - "isSecure": false - }, - { - "id": 67, - "name": "Thermostat Setpoint", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 48, - "name": "Binary Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 114, - "name": "Manufacturer Specific", - "version": 1, - "isSecure": false - }, - { - "id": 134, - "name": "Version", - "version": 1, - "isSecure": false - }, - { - "id": 129, - "name": "Clock", - "version": 1, - "isSecure": false - }, - { - "id": 133, - "name": "Association", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 2, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 3, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 4, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 5, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 6, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 7, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 8, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 9, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 10, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 11, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 12, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 13, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 14, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 15, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 16, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 17, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 18, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 19, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 20, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 21, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 22, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 23, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 24, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 25, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 26, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 27, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 28, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 29, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 30, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 31, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 32, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 33, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 34, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 35, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 36, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 37, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 38, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 39, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - } - ], - "values": [ - { - "endpoint": 0, - "commandClass": 67, - "commandClassName": "Thermostat Setpoint", - "property": "setpoint", - "propertyKey": 7, - "propertyName": "setpoint", - "propertyKeyName": "Furnace", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Setpoint (Furnace)", - "ccSpecific": { - "setpointType": 7 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 60 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 1, - "propertyKey": 2, - "propertyName": "Installed Pump Type", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Installed Pump Type", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "One Speed", - "1": "Two Speed" - }, - "valueSize": 2, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Installed Pump Type" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 1, - "propertyKey": 1, - "propertyName": "Booster (Cleaner) Pump Installed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Booster (Cleaner) Pump Installed", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 2, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Booster (Cleaner) Pump Installed" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 1, - "propertyKey": 65280, - "propertyName": "Booster (Cleaner) Pump Operation Mode", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Set the filter pump mode to use when the booster (cleaner) pump is running.", - "label": "Booster (Cleaner) Pump Operation Mode", - "default": 1, - "min": 1, - "max": 6, - "states": { - "1": "Disable", - "2": "Circuit 1", - "3": "VSP Speed 1", - "4": "VSP Speed 2", - "5": "VSP Speed 3", - "6": "VSP Speed 4" - }, - "valueSize": 2, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Booster (Cleaner) Pump Operation Mode", - "info": "Set the filter pump mode to use when the booster (cleaner) pump is running." - }, - "value": 1 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 2, - "propertyKey": 65280, - "propertyName": "Heater Cooldown Period", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Heater Cooldown Period", - "default": -1, - "min": -1, - "max": 15, - "states": { - "0": "Heater installed with no cooldown", - "-1": "No heater installed" - }, - "unit": "minutes", - "valueSize": 2, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Heater Cooldown Period" - }, - "value": 2 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 2, - "propertyKey": 1, - "propertyName": "Heater Safety Setting", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Prevent the heater from turning on while the pump is off.", - "label": "Heater Safety Setting", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 2, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Heater Safety Setting", - "info": "Prevent the heater from turning on while the pump is off." - }, - "value": 1 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 3, - "propertyKey": 4278190080, - "propertyName": "Water Temperature Offset", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Water Temperature Offset", - "default": 0, - "min": -5, - "max": 5, - "unit": "°F", - "valueSize": 4, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Water Temperature Offset" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 3, - "propertyKey": 16711680, - "propertyName": "Air/Freeze Temperature Offset", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Air/Freeze Temperature Offset", - "default": 0, - "min": -5, - "max": 5, - "unit": "°F", - "valueSize": 4, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Air/Freeze Temperature Offset" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 3, - "propertyKey": 65280, - "propertyName": "Solar Temperature Offset", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Solar Temperature Offset", - "default": 0, - "min": -5, - "max": 5, - "unit": "°F", - "valueSize": 4, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Solar Temperature Offset" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 22, - "propertyName": "Pool/Spa Configuration", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Pool/Spa Configuration", - "default": 0, - "min": 0, - "max": 2, - "states": { - "0": "Pool", - "1": "Spa", - "2": "Both" - }, - "valueSize": 1, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Pool/Spa Configuration" - }, - "value": 2 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 23, - "propertyName": "Spa Mode Pump Speed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires pool/spa configuration.", - "label": "Spa Mode Pump Speed", - "default": 1, - "min": 1, - "max": 6, - "states": { - "1": "Disabled", - "2": "Circuit 1", - "3": "VSP Speed 1", - "4": "VSP Speed 2", - "5": "VSP Speed 3", - "6": "VSP Speed 4" - }, - "valueSize": 1, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Spa Mode Pump Speed", - "info": "Requires pool/spa configuration." - }, - "value": 1 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 32, - "propertyName": "Variable Speed Pump - Speed 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 1", - "default": 750, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 1", - "info": "Requires connected variable speed pump." - }, - "value": 1400 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 33, - "propertyName": "Variable Speed Pump - Speed 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 2", - "default": 1500, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 2", - "info": "Requires connected variable speed pump." - }, - "value": 1700 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 34, - "propertyName": "Variable Speed Pump - Speed 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 3", - "default": 2350, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 3", - "info": "Requires connected variable speed pump." - }, - "value": 2500 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 35, - "propertyName": "Variable Speed Pump - Speed 4", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 4", - "default": 3110, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 4", - "info": "Requires connected variable speed pump." - }, - "value": 2500 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 49, - "propertyName": "Variable Speed Pump - Max Speed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Max Speed", - "default": 3450, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Max Speed", - "info": "Requires connected variable speed pump." - }, - "value": 3000 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 4278190080, - "propertyName": "Freeze Protection: Temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Temperature", - "default": 0, - "min": 0, - "max": 44, - "states": { - "0": "Disabled", - "40": "40 °F", - "41": "41 °F", - "42": "42 °F", - "43": "43 °F", - "44": "44 °F" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Temperature" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 65536, - "propertyName": "Freeze Protection: Turn On Circuit 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 1", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 1" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 131072, - "propertyName": "Freeze Protection: Turn On Circuit 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 2", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 2" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 262144, - "propertyName": "Freeze Protection: Turn On Circuit 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 3", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 3" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 524288, - "propertyName": "Freeze Protection: Turn On Circuit 4", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 4", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 4" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 1048576, - "propertyName": "Freeze Protection: Turn On Circuit 5", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 5", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 5" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 65280, - "propertyName": "Freeze Protection: Turn On VSP Speed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires variable speed pump and connected air/freeze sensor.", - "label": "Freeze Protection: Turn On VSP Speed", - "default": 0, - "min": 0, - "max": 5, - "states": { - "0": "None", - "2": "VSP Speed 1", - "3": "VSP Speed 2", - "4": "VSP Speed 3", - "5": "VSP Speed 4" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On VSP Speed", - "info": "Requires variable speed pump and connected air/freeze sensor." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 128, - "propertyName": "Freeze Protection: Turn On Heater", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires heater and connected air/freeze sensor.", - "label": "Freeze Protection: Turn On Heater", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Heater", - "info": "Requires heater and connected air/freeze sensor." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 127, - "propertyName": "Freeze Protection: Pool/Spa Cycle Time", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires pool/spa configuration and connected air/freeze sensor.", - "label": "Freeze Protection: Pool/Spa Cycle Time", - "default": 0, - "min": 0, - "max": 30, - "unit": "minutes", - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Freeze Protection: Pool/Spa Cycle Time", - "info": "Requires pool/spa configuration and connected air/freeze sensor." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 4, - "propertyName": "Circuit 1 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Start time (first two bytes, little endian) and stop time (last two bytes, little endian) of schedule in minutes past midnight, e.g. 12:05am (0x0500) to 3:00pm (0x8403) is entered as 83919875. Set to 4294967295 (0xffffffff) to disable.", - "label": "Circuit 1 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 1 Schedule 1", - "info": "Start time (first two bytes, little endian) and stop time (last two bytes, little endian) of schedule in minutes past midnight, e.g. 12:05am (0x0500) to 3:00pm (0x8403) is entered as 83919875. Set to 4294967295 (0xffffffff) to disable." - }, - "value": 1979884035 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 5, - "propertyName": "Circuit 1 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 1 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 1 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 6, - "propertyName": "Circuit 1 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 1 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 1 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 7, - "propertyName": "Circuit 2 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 2 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 2 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 8, - "propertyName": "Circuit 2 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 2 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 2 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 9, - "propertyName": "Circuit 2 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 2 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 2 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 10, - "propertyName": "Circuit 3 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 3 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 3 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 11, - "propertyName": "Circuit 3 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 3 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 3 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 12, - "propertyName": "Circuit 3 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 3 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 3 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 13, - "propertyName": "Circuit 4 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 4 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 4 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 14, - "propertyName": "Circuit 4 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 4 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 4 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 15, - "propertyName": "Circuit 4 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 4 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 4 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 16, - "propertyName": "Circuit 5 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 5 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 5 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 17, - "propertyName": "Circuit 5 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 5 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 5 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 18, - "propertyName": "Circuit 5 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 5 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 5 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 19, - "propertyName": "Pool/Spa Mode Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Pool/Spa Mode Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Pool/Spa Mode Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 20, - "propertyName": "Pool/Spa Mode Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Pool/Spa Mode Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Pool/Spa Mode Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 21, - "propertyName": "Pool/Spa Mode Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Pool/Spa Mode Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Pool/Spa Mode Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 36, - "propertyName": "Variable Speed Pump Speed 1 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 1 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 1 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 37, - "propertyName": "Variable Speed Pump Speed 1 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 1 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 1 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 38, - "propertyName": "Variable Speed Pump Speed 1 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 1 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 1 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 39, - "propertyName": "Variable Speed Pump Speed 2 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 2 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 2 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 1476575235 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 40, - "propertyName": "Variable Speed Pump Speed 2 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 2 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 2 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 41, - "propertyName": "Variable Speed Pump Speed 2 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 2 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 2 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 42, - "propertyName": "Variable Speed Pump Speed 3 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 3 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 3 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 43, - "propertyName": "Variable Speed Pump Speed 3 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 3 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 3 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 44, - "propertyName": "Variable Speed Pump Speed 3 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 3 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 3 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 45, - "propertyName": "Variable Speed Pump Speed 4 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 4 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 4 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 46, - "propertyName": "Variable Speed Pump Speed 4 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 4 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 4 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 47, - "propertyName": "Variable Speed Pump Speed 4 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 4 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 4 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "manufacturerId", - "propertyName": "manufacturerId", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Manufacturer ID", - "min": 0, - "max": 65535, - "stateful": true, - "secret": false - }, - "value": 5 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "productType", - "propertyName": "productType", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Product type", - "min": 0, - "max": 65535, - "stateful": true, - "secret": false - }, - "value": 20549 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "productId", - "propertyName": "productId", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Product ID", - "min": 0, - "max": 65535, - "stateful": true, - "secret": false - }, - "value": 1619 - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "libraryType", - "propertyName": "libraryType", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Library type", - "states": { - "0": "Unknown", - "1": "Static Controller", - "2": "Controller", - "3": "Enhanced Slave", - "4": "Slave", - "5": "Installer", - "6": "Routing Slave", - "7": "Bridge Controller", - "8": "Device under Test", - "9": "N/A", - "10": "AV Remote", - "11": "AV Device" - }, - "stateful": true, - "secret": false - }, - "value": 6 - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "protocolVersion", - "propertyName": "protocolVersion", - "ccVersion": 1, - "metadata": { - "type": "string", - "readable": true, - "writeable": false, - "label": "Z-Wave protocol version", - "stateful": true, - "secret": false - }, - "value": "2.78" - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "firmwareVersions", - "propertyName": "firmwareVersions", - "ccVersion": 1, - "metadata": { - "type": "string[]", - "readable": true, - "writeable": false, - "label": "Z-Wave chip firmware versions", - "stateful": true, - "secret": false - }, - "value": ["3.9"] - }, - { - "endpoint": 1, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": true - }, - { - "endpoint": 1, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 1, - "commandClass": 48, - "commandClassName": "Binary Sensor", - "property": "Any", - "propertyName": "Any", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Sensor state (Any)", - "ccSpecific": { - "sensorType": 255 - }, - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 1, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 81, - "nodeId": 19 - }, - { - "endpoint": 1, - "commandClass": 67, - "commandClassName": "Thermostat Setpoint", - "property": "setpoint", - "propertyKey": 1, - "propertyName": "setpoint", - "propertyKeyName": "Heating", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Setpoint (Heating)", - "ccSpecific": { - "setpointType": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 39 - }, - { - "endpoint": 2, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 2, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 2, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 84, - "nodeId": 19 - }, - { - "endpoint": 3, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 3, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 3, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 86, - "nodeId": 19 - }, - { - "endpoint": 4, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 4, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 4, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 80, - "nodeId": 19 - }, - { - "endpoint": 5, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 5, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 5, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 83, - "nodeId": 19 - }, - { - "endpoint": 6, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 6, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 7, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 7, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 8, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 8, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 9, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 9, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 10, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 10, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 11, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 11, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 12, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 12, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 13, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 13, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 14, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 14, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 15, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 15, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 16, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 16, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 17, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": true - }, - { - "endpoint": 17, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 18, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 18, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 19, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 19, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 20, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 20, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 21, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 21, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 22, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 22, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 23, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 23, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 24, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 24, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 25, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 25, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 26, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 26, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 27, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 27, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 28, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 28, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 29, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 29, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 30, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 30, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 31, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 31, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 32, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 32, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 33, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 33, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 34, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 34, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 35, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 35, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 36, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 36, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 37, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 37, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 38, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 38, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 39, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": true - }, - { - "endpoint": 39, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - } - ], - "isFrequentListening": false, - "maxDataRate": 40000, - "supportedDataRates": [40000], - "protocolVersion": 2, - "supportsBeaming": true, - "supportsSecurity": false, - "nodeType": 1, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "interviewStage": "Complete", - "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0005:0x5045:0x0653:3.9", - "highestSecurityClass": -1, - "isControllerNode": false, - "keepAwake": false -} diff --git a/tests/components/zwave_js/fixtures/device_diagnostics.json b/tests/components/zwave_js/fixtures/device_diagnostics.json new file mode 100644 index 00000000000000..a206cb8353c3cd --- /dev/null +++ b/tests/components/zwave_js/fixtures/device_diagnostics.json @@ -0,0 +1,2315 @@ +{ + "home_assistant": { + "installation_type": "Home Assistant OS", + "version": "2023.10.5", + "dev": false, + "hassio": true, + "virtualenv": false, + "python_version": "3.11.5", + "docker": true, + "arch": "aarch64", + "timezone": "America/New_York", + "os_name": "Linux", + "os_version": "6.1.56", + "supervisor": "2023.10.1", + "host_os": "Home Assistant OS 11.0", + "docker_version": "24.0.6", + "chassis": "embedded", + "run_as_root": true + }, + "custom_components": { + "pyscript": { + "version": "1.5.0", + "requirements": ["croniter==1.3.8", "watchdog==2.3.1"] + } + }, + "integration_manifest": { + "domain": "zwave_js", + "name": "Z-Wave", + "codeowners": ["@home-assistant/z-wave"], + "config_flow": true, + "dependencies": ["http", "repairs", "usb", "websocket_api"], + "documentation": "https://www.home-assistant.io/integrations/zwave_js", + "integration_type": "hub", + "iot_class": "local_push", + "loggers": ["zwave_js_server"], + "quality_scale": "platinum", + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.52.1"], + "usb": [ + { + "vid": "0658", + "pid": "0200", + "known_devices": ["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"] + }, + { + "vid": "10C4", + "pid": "8A2A", + "description": "*z-wave*", + "known_devices": ["Nortek HUSBZB-1"] + } + ], + "zeroconf": ["_zwave-js-server._tcp.local."], + "is_built_in": true + }, + "data": { + "versionInfo": { + "driverVersion": "12.2.1", + "serverVersion": "1.33.0", + "minSchemaVersion": 0, + "maxSchemaVersion": 33 + }, + "entities": [ + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_heat_alarm_heat_sensor_status", + "original_name": "Heat Alarm Heat sensor status", + "original_device_class": "enum", + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Heat Alarm-Heat sensor status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Heat Alarm", + "property_name": "Heat Alarm", + "property_key": "Heat sensor status", + "property_key_name": "Heat sensor status" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_weather_alarm_moisture_alarm_status", + "original_name": "Weather Alarm Moisture alarm status", + "original_device_class": "enum", + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Weather Alarm-Moisture alarm status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Weather Alarm", + "property_name": "Weather Alarm", + "property_key": "Moisture alarm status", + "property_key_name": "Moisture alarm status" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_alarmtype", + "original_name": "Alarm Type", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-alarmType", + "primary_value": null + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_alarmlevel", + "original_name": "Alarm Level", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-alarmLevel", + "primary_value": null + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_battery_level", + "original_name": "Battery level", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-128-0-level", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "level", + "property_name": "level", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_charging_status", + "original_name": "Charging status", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-128-0-chargingStatus", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "chargingStatus", + "property_name": "chargingStatus", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_recharge_or_replace", + "original_name": "Recharge or replace", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-128-0-rechargeOrReplace", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "rechargeOrReplace", + "property_name": "rechargeOrReplace", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_node_identify_on_off_period_duration", + "original_name": "Node Identify - On/Off Period: Duration", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-135-0-80-3", + "primary_value": { + "command_class": 135, + "command_class_name": "Indicator", + "endpoint": 0, + "property": 80, + "property_name": "Node Identify", + "property_key": 3, + "property_key_name": "On/Off Period: Duration" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_node_identify_on_off_cycle_count", + "original_name": "Node Identify - On/Off Cycle Count", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-135-0-80-4", + "primary_value": { + "command_class": 135, + "command_class_name": "Indicator", + "endpoint": 0, + "property": 80, + "property_name": "Node Identify", + "property_key": 4, + "property_key_name": "On/Off Cycle Count" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_node_identify_on_off_period_on_time", + "original_name": "Node Identify - On/Off Period: On time", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-135-0-80-5", + "primary_value": { + "command_class": 135, + "command_class_name": "Indicator", + "endpoint": 0, + "property": 80, + "property_name": "Node Identify", + "property_key": 5, + "property_key_name": "On/Off Period: On time" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_sensor_indicator_value", + "original_name": "Indicator value", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-135-0-value", + "primary_value": null + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_low_battery_level", + "original_name": "Low battery level", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-isLow", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "isLow", + "property_name": "isLow", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_rechargeable", + "original_name": "Rechargeable", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-rechargeable", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "rechargeable", + "property_name": "rechargeable", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_used_as_backup", + "original_name": "Used as backup", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-backup", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "backup", + "property_name": "backup", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_overheating", + "original_name": "Overheating", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-overheating", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "overheating", + "property_name": "overheating", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_fluid_is_low", + "original_name": "Fluid is low", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-lowFluid", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "lowFluid", + "property_name": "lowFluid", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_battery_is_disconnected", + "original_name": "Battery is disconnected", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-disconnected", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "disconnected", + "property_name": "disconnected", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_sensor_battery_temperature_is_low", + "original_name": "Battery temperature is low", + "original_device_class": "battery", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-128-0-lowTemperatureStatus", + "primary_value": { + "command_class": 128, + "command_class_name": "Battery", + "endpoint": 0, + "property": "lowTemperatureStatus", + "property_name": "lowTemperatureStatus", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_air_temperature", + "original_name": "Air temperature", + "original_device_class": "temperature", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "\u00b0F", + "value_id": "23-49-0-Air temperature", + "primary_value": { + "command_class": 49, + "command_class_name": "Multilevel Sensor", + "endpoint": 0, + "property": "Air temperature", + "property_name": "Air temperature", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_underheat_detected", + "original_name": "Underheat detected", + "original_device_class": "heat", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Heat Alarm-Heat sensor status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Heat Alarm", + "property_name": "Heat Alarm", + "property_key": "Heat sensor status", + "property_key_name": "Heat sensor status", + "state_key": 6 + } + }, + { + "domain": "sensor", + "entity_id": "sensor.2nd_floor_humidity", + "original_name": "Humidity", + "original_device_class": "humidity", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-49-0-Humidity", + "primary_value": { + "command_class": 49, + "command_class_name": "Multilevel Sensor", + "endpoint": 0, + "property": "Humidity", + "property_name": "Humidity", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.2nd_floor_moisture_alarm", + "original_name": "Moisture alarm", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Weather Alarm-Moisture alarm status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Weather Alarm", + "property_name": "Weather Alarm", + "property_key": "Moisture alarm status", + "property_key_name": "Moisture alarm status", + "state_key": 2 + } + }, + { + "domain": "button", + "entity_id": "button.2nd_floor_sensor_idle_heat_sensor_status", + "original_name": "Idle Heat Alarm Heat sensor status", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Heat Alarm-Heat sensor status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Heat Alarm", + "property_name": "Heat Alarm", + "property_key": "Heat sensor status", + "property_key_name": "Heat sensor status" + } + }, + { + "domain": "button", + "entity_id": "button.2nd_floor_sensor_idle_moisture_alarm_status", + "original_name": "Idle Weather Alarm Moisture alarm status", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-113-0-Weather Alarm-Moisture alarm status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 0, + "property": "Weather Alarm", + "property_name": "Weather Alarm", + "property_key": "Moisture alarm status", + "property_key_name": "Moisture alarm status" + } + }, + { + "domain": "select", + "entity_id": "select.2nd_floor_sensor_high_temperature_alert_reporting", + "original_name": "High Temperature Alert Reporting", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-112-0-6", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 6, + "property_name": "High Temperature Alert Reporting", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "select", + "entity_id": "select.2nd_floor_sensor_low_temperature_alert_reporting", + "original_name": "Low Temperature Alert Reporting", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-112-0-8", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 8, + "property_name": "Low Temperature Alert Reporting", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "select", + "entity_id": "select.2nd_floor_sensor_high_humidity_alert_reporting", + "original_name": "High Humidity Alert Reporting", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-112-0-10", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 10, + "property_name": "High Humidity Alert Reporting", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "select", + "entity_id": "select.2nd_floor_sensor_low_humidity_alert_reporting", + "original_name": "Low Humidity Alert Reporting", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-112-0-12", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 12, + "property_name": "Low Humidity Alert Reporting", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "select", + "entity_id": "select.2nd_floor_sensor_temperature_scale", + "original_name": "Temperature Scale", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "23-112-0-13", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 13, + "property_name": "Temperature Scale", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_battery_report_threshold", + "original_name": "Battery Report Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-112-0-1", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 1, + "property_name": "Battery Report Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_low_battery_alarm_threshold", + "original_name": "Low Battery Alarm Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-112-0-2", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 2, + "property_name": "Low Battery Alarm Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_temperature_report_threshold", + "original_name": "Temperature Report Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "0.1 \u00b0F/C", + "value_id": "23-112-0-3", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 3, + "property_name": "Temperature Report Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_humidity_report_threshold", + "original_name": "Humidity Report Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-112-0-4", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 4, + "property_name": "Humidity Report Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_high_temperature_alert_threshold", + "original_name": "High Temperature Alert Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "\u00b0F/C", + "value_id": "23-112-0-5", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 5, + "property_name": "High Temperature Alert Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_low_temperature_alert_threshold", + "original_name": "Low Temperature Alert Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "\u00b0F/C", + "value_id": "23-112-0-7", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 7, + "property_name": "Low Temperature Alert Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_high_humidity_alert_threshold", + "original_name": "High Humidity Alert Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-112-0-9", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 9, + "property_name": "High Humidity Alert Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_low_humidity_alert_threshold", + "original_name": "Low Humidity Alert Threshold", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "%", + "value_id": "23-112-0-11", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 11, + "property_name": "Low Humidity Alert Threshold", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_temperature_offset", + "original_name": "Temperature Offset", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "0.1 \u00b0F/C", + "value_id": "23-112-0-14", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 14, + "property_name": "Temperature Offset", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_humidity_offset", + "original_name": "Humidity Offset", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "0.1 %", + "value_id": "23-112-0-15", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 15, + "property_name": "Humidity Offset", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_temperature_reporting_interval", + "original_name": "Temperature Reporting Interval", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "minutes", + "value_id": "23-112-0-16", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 16, + "property_name": "Temperature Reporting Interval", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "number", + "entity_id": "number.2nd_floor_sensor_humidity_reporting_interval", + "original_name": "Humidity Reporting Interval", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": "minutes", + "value_id": "23-112-0-17", + "primary_value": { + "command_class": 112, + "command_class_name": "Configuration", + "endpoint": 0, + "property": 17, + "property_name": "Humidity Reporting Interval", + "property_key": null, + "property_key_name": null + } + } + ], + "state": { + "nodeId": 23, + "index": 0, + "installerIcon": 3327, + "userIcon": 3327, + "status": 1, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 634, + "productId": 57348, + "productType": 28672, + "firmwareVersion": "1.10", + "zwavePlusVersion": 2, + "name": "2nd Floor Sensor", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/usr/src/app/store/.config-db/devices/0x027a/zse44.json", + "isEmbedded": true, + "manufacturer": "Zooz", + "manufacturerId": 634, + "label": "ZSE44", + "description": "Temperature Humidity XS Sensor", + "devices": [ + { + "productType": 28672, + "productId": 57348 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "Initiate inclusion (pairing) in the app (or web interface). Not sure how? ask@getzooz.com\nWhile the hub is looking for new devices, click the Z-Wave button 3 times as quickly as possible. The LED indicator will start flashing to confirm inclusion mode and turn off once inclusion is completed.", + "exclusion": "1. Bring the sensor within direct range of your Z-Wave hub.\n2. Put the Z-Wave hub into exclusion mode (not sure how to do that? ask@getzooz.com).\n3. Click the Z-Wave button 3 times as quickly as possible.\n4. Your hub will confirm exclusion and the sensor will disappear from your controller's device list", + "reset": "When your network\u2019s primary controller is missing or otherwise inoperable, you may need to reset the device to factory settings manually. In order to complete the process, make sure the sensor is powered, then click the Z-Wave button twice and hold it the third time for 10 seconds. The LED indicator will blink continuously. Immediately after, click the Z-Wave button twice more to finalize the reset. The LED indicator will flash 3 times to confirm a successful reset", + "manual": "https://cdn.shopify.com/s/files/1/0218/7704/files/zooz-700-series-tilt-shock-xs-sensor-zse43-manual.pdf" + } + }, + "label": "ZSE44", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 6, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x027a:0x7000:0xe004:1.10", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "lwr": { + "repeaters": [2], + "protocolDataRate": 3 + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2023-08-09T13:26:05.031Z", + "values": { + "23-49-0-Air temperature": { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 1 + }, + "unit": "\u00b0F", + "stateful": true, + "secret": false + }, + "value": 69.9 + }, + "23-49-0-Humidity": { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Humidity", + "propertyName": "Humidity", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Humidity", + "ccSpecific": { + "sensorType": 5, + "scale": 0 + }, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 54 + }, + "23-112-0-1": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Battery Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Battery Report Threshold", + "default": 5, + "min": 1, + "max": 10, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + "23-112-0-2": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Low Battery Alarm Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Battery Alarm Threshold", + "default": 20, + "min": 10, + "max": 50, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + "23-112-0-3": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Temperature Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Report Threshold", + "default": 20, + "min": 10, + "max": 100, + "unit": "0.1 \u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + "23-112-0-4": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Humidity Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Humidity Report Threshold", + "default": 10, + "min": 1, + "max": 50, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + "23-112-0-5": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "High Temperature Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Temperature Alert Threshold", + "default": 120, + "min": 50, + "max": 120, + "unit": "\u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 120 + }, + "23-112-0-6": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "High Temperature Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Temperature Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 2 only", + "3": "0xff (on) to Lifeline and Group 2", + "4": "0x00 (off) to Group 2 only", + "5": "0x00 (off) to Lifeline and Group 2", + "6": "0xff (on) and 0x00 (off) to Group 2 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 2" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + "23-112-0-7": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Low Temperature Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Temperature Alert Threshold", + "default": 10, + "min": 10, + "max": 100, + "unit": "\u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + "23-112-0-8": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Low Temperature Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Temperature Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 3 only", + "3": "0xff (on) to Lifeline and Group 3", + "4": "0x00 (off) to Group 3 only", + "5": "0x00 (off) to Lifeline and Group 3", + "6": "0xff (on) and 0x00 (off) to Group 3 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 3" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + "23-112-0-9": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "High Humidity Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Humidity Alert Threshold", + "default": 0, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + "23-112-0-10": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "High Humidity Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Humidity Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 4 only", + "3": "0xff (on) to Lifeline and Group 4", + "4": "0x00 (off) to Group 4 only", + "5": "0x00 (off) to Lifeline and Group 4", + "6": "0xff (on) and 0x00 (off) to Group 4 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 4" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + "23-112-0-11": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Low Humidity Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Humidity Alert Threshold", + "default": 0, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + "23-112-0-12": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Low Humidity Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Humidity Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 5 only", + "3": "0xff (on) to Lifeline and Group 5", + "4": "0x00 (off) to Group 5 only", + "5": "0x00 (off) to Lifeline and Group 5", + "6": "0xff (on) and 0x00 (off) to Group 5 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 5" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + "23-112-0-13": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Temperature Scale", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Scale", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Celsius", + "1": "Fahrenheit" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + "23-112-0-14": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyName": "Temperature Offset", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "0=-10, 100=0, 200=+10", + "label": "Temperature Offset", + "default": 100, + "min": 0, + "max": 200, + "unit": "0.1 \u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + "23-112-0-15": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyName": "Humidity Offset", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "0=-10, 100=0, 200=+10", + "label": "Humidity Offset", + "default": 100, + "min": 0, + "max": 200, + "unit": "0.1 %", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + "23-112-0-16": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyName": "Temperature Reporting Interval", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Reporting Interval", + "default": 240, + "min": 1, + "max": 480, + "unit": "minutes", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 240 + }, + "23-112-0-17": { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyName": "Humidity Reporting Interval", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Humidity Reporting Interval", + "default": 240, + "min": 1, + "max": 480, + "unit": "minutes", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 240 + }, + "23-113-0-Heat Alarm-Heat sensor status": { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Heat Alarm", + "propertyKey": "Heat sensor status", + "propertyName": "Heat Alarm", + "propertyKeyName": "Heat sensor status", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Heat sensor status", + "ccSpecific": { + "notificationType": 4 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Underheat detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "23-113-0-Weather Alarm-Moisture alarm status": { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Weather Alarm", + "propertyKey": "Moisture alarm status", + "propertyName": "Weather Alarm", + "propertyKeyName": "Moisture alarm status", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Moisture alarm status", + "ccSpecific": { + "notificationType": 16 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Moisture alarm" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "23-114-0-manufacturerId": { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 634 + }, + "23-114-0-productType": { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 28672 + }, + "23-114-0-productId": { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 57348 + }, + "23-128-0-level": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 0 + }, + "23-128-0-isLow": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": true + }, + "23-128-0-chargingStatus": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "chargingStatus", + "propertyName": "chargingStatus", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Charging status", + "min": 0, + "max": 255, + "states": { + "0": "Discharging", + "1": "Charging", + "2": "Maintaining" + }, + "stateful": true, + "secret": false + } + }, + "23-128-0-rechargeable": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeable", + "propertyName": "rechargeable", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Rechargeable", + "stateful": true, + "secret": false + } + }, + "23-128-0-backup": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "backup", + "propertyName": "backup", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Used as backup", + "stateful": true, + "secret": false + } + }, + "23-128-0-overheating": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "overheating", + "propertyName": "overheating", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Overheating", + "stateful": true, + "secret": false + } + }, + "23-128-0-lowFluid": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "lowFluid", + "propertyName": "lowFluid", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Fluid is low", + "stateful": true, + "secret": false + } + }, + "23-128-0-rechargeOrReplace": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeOrReplace", + "propertyName": "rechargeOrReplace", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Recharge or replace", + "min": 0, + "max": 255, + "states": { + "0": "No", + "1": "Soon", + "2": "Now" + }, + "stateful": true, + "secret": false + } + }, + "23-128-0-disconnected": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "disconnected", + "propertyName": "disconnected", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Battery is disconnected", + "stateful": true, + "secret": false + } + }, + "23-128-0-lowTemperatureStatus": { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "lowTemperatureStatus", + "propertyName": "lowTemperatureStatus", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Battery temperature is low", + "stateful": true, + "secret": false + } + }, + "23-132-0-wakeUpInterval": { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "wakeUpInterval", + "propertyName": "wakeUpInterval", + "ccVersion": 1, + "metadata": { + "type": "number", + "default": 21600, + "readable": false, + "writeable": true, + "min": 3600, + "max": 86400, + "steps": 60, + "stateful": true, + "secret": false + }, + "value": 21600 + }, + "23-132-0-controllerNodeId": { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "controllerNodeId", + "propertyName": "controllerNodeId", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Node ID of the controller", + "stateful": true, + "secret": false + }, + "value": 1 + }, + "23-134-0-libraryType": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + "23-134-0-protocolVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.13" + }, + "23-134-0-firmwareVersions": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.10"] + }, + "23-134-0-hardwareVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 1 + }, + "23-134-0-sdkVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + } + }, + "23-134-0-applicationFrameworkAPIVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + } + }, + "23-134-0-applicationFrameworkBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + } + }, + "23-134-0-hostInterfaceVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + } + }, + "23-134-0-hostInterfaceBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + } + }, + "23-134-0-zWaveProtocolVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + } + }, + "23-134-0-zWaveProtocolBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + } + }, + "23-134-0-applicationVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + } + }, + "23-134-0-applicationBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + } + }, + "23-135-0-80-3": { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "Node Identify - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "23-135-0-80-4": { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "Node Identify - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "23-135-0-80-5": { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "Node Identify - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + } + }, + "endpoints": { + "0": { + "nodeId": 23, + "index": 0, + "installerIcon": 3327, + "userIcon": 3327, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 4, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 11, + "isSecure": true + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 3, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 4, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 132, + "name": "Wake Up", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 2, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 7, + "isSecure": true + } + ] + } + } + } + } +} diff --git a/tests/components/zwave_js/fixtures/zooz_zse44_state.json b/tests/components/zwave_js/fixtures/zooz_zse44_state.json new file mode 100644 index 00000000000000..a2fb5421fb70c6 --- /dev/null +++ b/tests/components/zwave_js/fixtures/zooz_zse44_state.json @@ -0,0 +1,1330 @@ +{ + "nodeId": 23, + "index": 0, + "installerIcon": 3327, + "userIcon": 3327, + "status": 1, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 634, + "productId": 57348, + "productType": 28672, + "firmwareVersion": "1.10", + "zwavePlusVersion": 2, + "name": "2nd Floor Sensor", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/usr/src/app/store/.config-db/devices/0x027a/zse44.json", + "isEmbedded": true, + "manufacturer": "Zooz", + "manufacturerId": 634, + "label": "ZSE44", + "description": "Temperature Humidity XS Sensor", + "devices": [ + { + "productType": 28672, + "productId": 57348 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "Initiate inclusion (pairing) in the app (or web interface). Not sure how? ask@getzooz.com\nWhile the hub is looking for new devices, click the Z-Wave button 3 times as quickly as possible. The LED indicator will start flashing to confirm inclusion mode and turn off once inclusion is completed.", + "exclusion": "1. Bring the sensor within direct range of your Z-Wave hub.\n2. Put the Z-Wave hub into exclusion mode (not sure how to do that? ask@getzooz.com).\n3. Click the Z-Wave button 3 times as quickly as possible.\n4. Your hub will confirm exclusion and the sensor will disappear from your controller's device list", + "reset": "When your network\u2019s primary controller is missing or otherwise inoperable, you may need to reset the device to factory settings manually. In order to complete the process, make sure the sensor is powered, then click the Z-Wave button twice and hold it the third time for 10 seconds. The LED indicator will blink continuously. Immediately after, click the Z-Wave button twice more to finalize the reset. The LED indicator will flash 3 times to confirm a successful reset", + "manual": "https://cdn.shopify.com/s/files/1/0218/7704/files/zooz-700-series-tilt-shock-xs-sensor-zse43-manual.pdf" + } + }, + "label": "ZSE44", + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 6, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x027a:0x7000:0xe004:1.10", + "statistics": { + "commandsTX": 0, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "lwr": { + "repeaters": [2], + "protocolDataRate": 3 + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2023-08-09T13:26:05.031Z", + "endpoints": { + "0": { + "nodeId": 23, + "index": 0, + "installerIcon": 3327, + "userIcon": 3327, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 7, + "label": "Notification Sensor" + }, + "specific": { + "key": 1, + "label": "Notification Sensor" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 4, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 11, + "isSecure": true + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 3, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 4, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 132, + "name": "Wake Up", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 2, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 7, + "isSecure": true + } + ] + } + }, + "values": [ + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 1 + }, + "unit": "\u00b0F", + "stateful": true, + "secret": false + }, + "value": 69.9 + }, + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Humidity", + "propertyName": "Humidity", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Humidity", + "ccSpecific": { + "sensorType": 5, + "scale": 0 + }, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 54 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Battery Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Battery Report Threshold", + "default": 5, + "min": 1, + "max": 10, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Low Battery Alarm Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Battery Alarm Threshold", + "default": 20, + "min": 10, + "max": 50, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Temperature Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Report Threshold", + "default": 20, + "min": 10, + "max": 100, + "unit": "0.1 \u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Humidity Report Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Humidity Report Threshold", + "default": 10, + "min": 1, + "max": 50, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "High Temperature Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Temperature Alert Threshold", + "default": 120, + "min": 50, + "max": 120, + "unit": "\u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 120 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "High Temperature Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Temperature Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 2 only", + "3": "0xff (on) to Lifeline and Group 2", + "4": "0x00 (off) to Group 2 only", + "5": "0x00 (off) to Lifeline and Group 2", + "6": "0xff (on) and 0x00 (off) to Group 2 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 2" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Low Temperature Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Temperature Alert Threshold", + "default": 10, + "min": 10, + "max": 100, + "unit": "\u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Low Temperature Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Temperature Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 3 only", + "3": "0xff (on) to Lifeline and Group 3", + "4": "0x00 (off) to Group 3 only", + "5": "0x00 (off) to Lifeline and Group 3", + "6": "0xff (on) and 0x00 (off) to Group 3 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 3" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "High Humidity Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Humidity Alert Threshold", + "default": 0, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "High Humidity Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "High Humidity Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 4 only", + "3": "0xff (on) to Lifeline and Group 4", + "4": "0x00 (off) to Group 4 only", + "5": "0x00 (off) to Lifeline and Group 4", + "6": "0xff (on) and 0x00 (off) to Group 4 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 4" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Low Humidity Alert Threshold", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Humidity Alert Threshold", + "default": 0, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Low Humidity Alert Reporting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Low Humidity Alert Reporting", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Lifeline only", + "2": "0xff (on) to Group 5 only", + "3": "0xff (on) to Lifeline and Group 5", + "4": "0x00 (off) to Group 5 only", + "5": "0x00 (off) to Lifeline and Group 5", + "6": "0xff (on) and 0x00 (off) to Group 5 only", + "7": "0xff (on) and 0x00 (off) to Lifeline and Group 5" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 7 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Temperature Scale", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Scale", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Celsius", + "1": "Fahrenheit" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyName": "Temperature Offset", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "0=-10, 100=0, 200=+10", + "label": "Temperature Offset", + "default": 100, + "min": 0, + "max": 200, + "unit": "0.1 \u00b0F/C", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyName": "Humidity Offset", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "0=-10, 100=0, 200=+10", + "label": "Humidity Offset", + "default": 100, + "min": 0, + "max": 200, + "unit": "0.1 %", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyName": "Temperature Reporting Interval", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Temperature Reporting Interval", + "default": 240, + "min": 1, + "max": 480, + "unit": "minutes", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 240 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyName": "Humidity Reporting Interval", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Humidity Reporting Interval", + "default": 240, + "min": 1, + "max": 480, + "unit": "minutes", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 240 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Heat Alarm", + "propertyKey": "Heat sensor status", + "propertyName": "Heat Alarm", + "propertyKeyName": "Heat sensor status", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Heat sensor status", + "ccSpecific": { + "notificationType": 4 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Underheat detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Weather Alarm", + "propertyKey": "Moisture alarm status", + "propertyName": "Weather Alarm", + "propertyKeyName": "Moisture alarm status", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Moisture alarm status", + "ccSpecific": { + "notificationType": 16 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Moisture alarm" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 634 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 28672 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 57348 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": true + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "chargingStatus", + "propertyName": "chargingStatus", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Charging status", + "min": 0, + "max": 255, + "states": { + "0": "Discharging", + "1": "Charging", + "2": "Maintaining" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeable", + "propertyName": "rechargeable", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Rechargeable", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "backup", + "propertyName": "backup", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Used as backup", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "overheating", + "propertyName": "overheating", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Overheating", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "lowFluid", + "propertyName": "lowFluid", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Fluid is low", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeOrReplace", + "propertyName": "rechargeOrReplace", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Recharge or replace", + "min": 0, + "max": 255, + "states": { + "0": "No", + "1": "Soon", + "2": "Now" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "disconnected", + "propertyName": "disconnected", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Battery is disconnected", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "lowTemperatureStatus", + "propertyName": "lowTemperatureStatus", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Battery temperature is low", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "wakeUpInterval", + "propertyName": "wakeUpInterval", + "ccVersion": 1, + "metadata": { + "type": "number", + "default": 21600, + "readable": false, + "writeable": true, + "min": 3600, + "max": 86400, + "steps": 60, + "stateful": true, + "secret": false + }, + "value": 21600 + }, + { + "endpoint": 0, + "commandClass": 132, + "commandClassName": "Wake Up", + "property": "controllerNodeId", + "propertyName": "controllerNodeId", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Node ID of the controller", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.13" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.10"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "Node Identify - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "Node Identify - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "Node Identify - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + } + ] +} diff --git a/tests/components/zwave_js/scripts/__init__.py b/tests/components/zwave_js/scripts/__init__.py new file mode 100644 index 00000000000000..96d81d993e9f3a --- /dev/null +++ b/tests/components/zwave_js/scripts/__init__.py @@ -0,0 +1 @@ +"""Tests for zwave_js scripts.""" diff --git a/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py b/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py new file mode 100644 index 00000000000000..d1e12e7abb462e --- /dev/null +++ b/tests/components/zwave_js/scripts/test_convert_device_diagnostics_to_fixture.py @@ -0,0 +1,80 @@ +"""Test convert_device_diagnostics_to_fixture script.""" +import copy +import json +from pathlib import Path +import sys +from unittest.mock import patch + +import pytest + +from homeassistant.components.zwave_js.scripts.convert_device_diagnostics_to_fixture import ( + extract_fixture_data, + get_fixtures_dir_path, + load_file, + main, +) + +from tests.common import load_fixture + + +def _minify(text: str) -> str: + """Minify string by removing whitespace and new lines.""" + return text.replace(" ", "").replace("\n", "") + + +def test_fixture_functions() -> None: + """Test functions related to the fixture.""" + diagnostics_data = json.loads(load_fixture("zwave_js/device_diagnostics.json")) + state = extract_fixture_data(copy.deepcopy(diagnostics_data)) + assert isinstance(state["values"], list) + assert ( + get_fixtures_dir_path(state) + == Path(__file__).parents[1] / "fixtures" / "zooz_zse44_state.json" + ) + + old_diagnostics_format_data = copy.deepcopy(diagnostics_data) + old_diagnostics_format_data["data"]["state"]["values"] = list( + old_diagnostics_format_data["data"]["state"]["values"].values() + ) + assert ( + extract_fixture_data(old_diagnostics_format_data) + == old_diagnostics_format_data["data"]["state"] + ) + + with pytest.raises(ValueError): + extract_fixture_data({}) + + +def test_load_file() -> None: + """Test load file.""" + assert load_file( + Path(__file__).parents[1] / "fixtures" / "device_diagnostics.json" + ) == json.loads(load_fixture("zwave_js/device_diagnostics.json")) + + +def test_main(capfd: pytest.CaptureFixture[str]) -> None: + """Test main function.""" + Path(__file__).parents[1] / "fixtures" / "zooz_zse44_state.json" + fixture_str = load_fixture("zwave_js/zooz_zse44_state.json") + fixture_dict = json.loads(fixture_str) + + # Test dump to stdout + args = [ + "homeassistant/components/zwave_js/scripts/convert_device_diagnostics_to_fixture.py", + str(Path(__file__).parents[1] / "fixtures" / "device_diagnostics.json"), + ] + with patch.object(sys, "argv", args): + main() + + captured = capfd.readouterr() + assert _minify(captured.out) == _minify(fixture_str) + + # Check file dump + args.append("--file") + with patch.object(sys, "argv", args), patch( + "homeassistant.components.zwave_js.scripts.convert_device_diagnostics_to_fixture.Path.write_text" + ) as write_text_mock: + main() + + assert len(write_text_mock.call_args_list) == 1 + assert write_text_mock.call_args[0][0] == json.dumps(fixture_dict, indent=2) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 4ff7c481e3756e..9c4a6339a78ead 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -4653,12 +4653,21 @@ async def test_subscribe_node_statistics( async def test_hard_reset_controller( - hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + client, + integration, + listen_block, + hass_ws_client: WebSocketGenerator, ) -> None: """Test that the hard_reset_controller WS API call works.""" entry = integration ws_client = await hass_ws_client(hass) + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_device( + identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} + ) + client.async_send_command.return_value = {} await ws_client.send_json( { @@ -4667,8 +4676,13 @@ async def test_hard_reset_controller( ENTRY_ID: entry.entry_id, } ) + + listen_block.set() + listen_block.clear() + await hass.async_block_till_done() + msg = await ws_client.receive_json() - assert msg["result"] is None + assert msg["result"] == device.id assert msg["success"] assert len(client.async_send_command.call_args_list) == 1 diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index cdc1e9959a7357..e9040dfd397e6c 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -792,196 +792,3 @@ async def test_thermostat_raise_repair_issue_and_warning_when_setting_fan_preset "Dry and Fan preset modes are deprecated and will be removed in Home Assistant 2024.2. Please use the corresponding Dry and Fan HVAC modes instead" in caplog.text ) - - -async def test_multi_setpoint_thermostat( - hass: HomeAssistant, client, climate_intermatic_pe653, integration -) -> None: - """Test a thermostat with multiple setpoints.""" - node = climate_intermatic_pe653 - - heating_entity_id = "climate.pool_control_2" - heating = hass.states.get(heating_entity_id) - assert heating - assert heating.state == HVACMode.HEAT - assert heating.attributes[ATTR_TEMPERATURE] == 3.9 - assert heating.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] - assert ( - heating.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE - ) - - furnace_entity_id = "climate.pool_control" - furnace = hass.states.get(furnace_entity_id) - assert furnace - assert furnace.state == HVACMode.HEAT - assert furnace.attributes[ATTR_TEMPERATURE] == 15.6 - assert furnace.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] - assert ( - furnace.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE - ) - - client.async_send_command_no_wait.reset_mock() - - # Test setting temperature of heating setpoint - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: heating_entity_id, - ATTR_TEMPERATURE: 20.0, - }, - blocking=True, - ) - - # Test setting temperature of furnace setpoint - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: furnace_entity_id, - ATTR_TEMPERATURE: 2.0, - }, - blocking=True, - ) - - # Test setting illegal mode raises an error - with pytest.raises(ValueError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: heating_entity_id, - ATTR_HVAC_MODE: HVACMode.COOL, - }, - blocking=True, - ) - - with pytest.raises(ValueError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: furnace_entity_id, - ATTR_HVAC_MODE: HVACMode.COOL, - }, - blocking=True, - ) - - # this is a no-op since there's no mode - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: heating_entity_id, - ATTR_HVAC_MODE: HVACMode.HEAT, - }, - blocking=True, - ) - - # this is a no-op since there's no mode - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: furnace_entity_id, - ATTR_HVAC_MODE: HVACMode.HEAT, - }, - blocking=True, - ) - - assert len(client.async_send_command.call_args_list) == 2 - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == 19 - assert args["valueId"] == { - "endpoint": 1, - "commandClass": 67, - "property": "setpoint", - "propertyKey": 1, - } - assert args["value"] == 68.0 - - args = client.async_send_command.call_args_list[1][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == 19 - assert args["valueId"] == { - "endpoint": 0, - "commandClass": 67, - "property": "setpoint", - "propertyKey": 7, - } - assert args["value"] == 35.6 - - client.async_send_command.reset_mock() - - # Test heating setpoint value update from value updated event - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": 19, - "args": { - "commandClassName": "Thermostat Setpoint", - "commandClass": 67, - "endpoint": 1, - "property": "setpoint", - "propertyKey": 1, - "propertyKeyName": "Heating", - "propertyName": "setpoint", - "newValue": 23, - "prevValue": 21.5, - }, - }, - ) - node.receive_event(event) - - state = hass.states.get(heating_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == -5 - - # furnace not changed - state = hass.states.get(furnace_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 15.6 - - client.async_send_command.reset_mock() - - # Test furnace setpoint value update from value updated event - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": 19, - "args": { - "commandClassName": "Thermostat Setpoint", - "commandClass": 67, - "endpoint": 0, - "property": "setpoint", - "propertyKey": 7, - "propertyKeyName": "Furnace", - "propertyName": "setpoint", - "newValue": 68, - "prevValue": 21.5, - }, - }, - ) - node.receive_event(event) - - # heating not changed - state = hass.states.get(heating_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == -5 - - state = hass.states.get(furnace_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 20 - - client.async_send_command.reset_mock() diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index ccbe956fbe57f3..84d9b457d18856 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -224,7 +224,16 @@ async def test_set_config_parameter( # Test groups get expanded assert await async_setup_component(hass, "group", {}) - await Group.async_create_group(hass, "test", [AIR_TEMPERATURE_SENSOR]) + await Group.async_create_group( + hass, + "test", + created_by_service=False, + entity_ids=[AIR_TEMPERATURE_SENSOR], + icon=None, + mode=None, + object_id=None, + order=None, + ) await hass.services.async_call( DOMAIN, SERVICE_SET_CONFIG_PARAMETER, @@ -594,7 +603,16 @@ async def test_bulk_set_config_parameters( # Test groups get expanded assert await async_setup_component(hass, "group", {}) - await Group.async_create_group(hass, "test", [AIR_TEMPERATURE_SENSOR]) + await Group.async_create_group( + hass, + "test", + created_by_service=False, + entity_ids=[AIR_TEMPERATURE_SENSOR], + icon=None, + mode=None, + object_id=None, + order=None, + ) await hass.services.async_call( DOMAIN, SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS, @@ -728,7 +746,16 @@ async def test_refresh_value( # Test groups get expanded assert await async_setup_component(hass, "group", {}) - await Group.async_create_group(hass, "test", [CLIMATE_RADIO_THERMOSTAT_ENTITY]) + await Group.async_create_group( + hass, + "test", + created_by_service=False, + entity_ids=[CLIMATE_RADIO_THERMOSTAT_ENTITY], + icon=None, + mode=None, + object_id=None, + order=None, + ) client.async_send_command.return_value = {"result": 2} await hass.services.async_call( DOMAIN, @@ -848,7 +875,16 @@ async def test_set_value( # Test groups get expanded assert await async_setup_component(hass, "group", {}) - await Group.async_create_group(hass, "test", [CLIMATE_DANFOSS_LC13_ENTITY]) + await Group.async_create_group( + hass, + "test", + created_by_service=False, + entity_ids=[CLIMATE_DANFOSS_LC13_ENTITY], + icon=None, + mode=None, + object_id=None, + order=None, + ) await hass.services.async_call( DOMAIN, SERVICE_SET_VALUE, @@ -1150,7 +1186,14 @@ async def test_multicast_set_value( # Test groups get expanded for multicast call assert await async_setup_component(hass, "group", {}) await Group.async_create_group( - hass, "test", [CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY] + hass, + "test", + created_by_service=False, + entity_ids=[CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.services.async_call( DOMAIN, @@ -1516,7 +1559,14 @@ async def test_ping( # Test groups get expanded for multicast call assert await async_setup_component(hass, "group", {}) await Group.async_create_group( - hass, "test", [CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_RADIO_THERMOSTAT_ENTITY] + hass, + "test", + created_by_service=False, + entity_ids=[CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_RADIO_THERMOSTAT_ENTITY], + icon=None, + mode=None, + object_id=None, + order=None, ) await hass.services.async_call( DOMAIN, diff --git a/tests/components/zwave_js/test_siren.py b/tests/components/zwave_js/test_siren.py index 210339e22d7d90..6df5881107a109 100644 --- a/tests/components/zwave_js/test_siren.py +++ b/tests/components/zwave_js/test_siren.py @@ -9,7 +9,7 @@ from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -SIREN_ENTITY = "siren.indoor_siren_6_2" +SIREN_ENTITY = "siren.indoor_siren_6_play_tone_2" TONE_ID_VALUE_ID = { "endpoint": 2, diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index daeb324b19f1e3..46b389722e89b3 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -52,26 +52,53 @@ def camera_client_fixture(hass, hass_client): async def test_get_clientsession_with_ssl(hass: HomeAssistant) -> None: """Test init clientsession with ssl.""" client.async_get_clientsession(hass) + verify_ssl = True + family = 0 - assert isinstance(hass.data[client.DATA_CLIENTSESSION], aiohttp.ClientSession) - assert isinstance(hass.data[client.DATA_CONNECTOR], aiohttp.TCPConnector) + client_session = hass.data[client.DATA_CLIENTSESSION][(verify_ssl, family)] + assert isinstance(client_session, aiohttp.ClientSession) + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family)] + assert isinstance(connector, aiohttp.TCPConnector) async def test_get_clientsession_without_ssl(hass: HomeAssistant) -> None: """Test init clientsession without ssl.""" client.async_get_clientsession(hass, verify_ssl=False) + verify_ssl = False + family = 0 - assert isinstance( - hass.data[client.DATA_CLIENTSESSION_NOTVERIFY], aiohttp.ClientSession - ) - assert isinstance(hass.data[client.DATA_CONNECTOR_NOTVERIFY], aiohttp.TCPConnector) + client_session = hass.data[client.DATA_CLIENTSESSION][(verify_ssl, family)] + assert isinstance(client_session, aiohttp.ClientSession) + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family)] + assert isinstance(connector, aiohttp.TCPConnector) + + +@pytest.mark.parametrize( + ("verify_ssl", "expected_family"), + [(True, 0), (False, 0), (True, 4), (False, 4), (True, 6), (False, 6)], +) +async def test_get_clientsession( + hass: HomeAssistant, verify_ssl: bool, expected_family: int +) -> None: + """Test init clientsession combinations.""" + client.async_get_clientsession(hass, verify_ssl=verify_ssl, family=expected_family) + client_session = hass.data[client.DATA_CLIENTSESSION][(verify_ssl, expected_family)] + assert isinstance(client_session, aiohttp.ClientSession) + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, expected_family)] + assert isinstance(connector, aiohttp.TCPConnector) async def test_create_clientsession_with_ssl_and_cookies(hass: HomeAssistant) -> None: """Test create clientsession with ssl.""" session = client.async_create_clientsession(hass, cookies={"bla": True}) assert isinstance(session, aiohttp.ClientSession) - assert isinstance(hass.data[client.DATA_CONNECTOR], aiohttp.TCPConnector) + + verify_ssl = True + family = 0 + + assert client.DATA_CLIENTSESSION not in hass.data + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family)] + assert isinstance(connector, aiohttp.TCPConnector) async def test_create_clientsession_without_ssl_and_cookies( @@ -80,46 +107,53 @@ async def test_create_clientsession_without_ssl_and_cookies( """Test create clientsession without ssl.""" session = client.async_create_clientsession(hass, False, cookies={"bla": True}) assert isinstance(session, aiohttp.ClientSession) - assert isinstance(hass.data[client.DATA_CONNECTOR_NOTVERIFY], aiohttp.TCPConnector) + verify_ssl = False + family = 0 -async def test_get_clientsession_cleanup(hass: HomeAssistant) -> None: - """Test init clientsession with ssl.""" - client.async_get_clientsession(hass) + assert client.DATA_CLIENTSESSION not in hass.data + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family)] + assert isinstance(connector, aiohttp.TCPConnector) - assert isinstance(hass.data[client.DATA_CLIENTSESSION], aiohttp.ClientSession) - assert isinstance(hass.data[client.DATA_CONNECTOR], aiohttp.TCPConnector) - hass.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) - await hass.async_block_till_done() - - assert hass.data[client.DATA_CLIENTSESSION].closed - assert hass.data[client.DATA_CONNECTOR].closed - - -async def test_get_clientsession_cleanup_without_ssl(hass: HomeAssistant) -> None: - """Test init clientsession with ssl.""" - client.async_get_clientsession(hass, verify_ssl=False) +@pytest.mark.parametrize( + ("verify_ssl", "expected_family"), + [(True, 0), (False, 0), (True, 4), (False, 4), (True, 6), (False, 6)], +) +async def test_get_clientsession_cleanup( + hass: HomeAssistant, verify_ssl: bool, expected_family: int +) -> None: + """Test init clientsession cleanup.""" + client.async_get_clientsession(hass, verify_ssl=verify_ssl, family=expected_family) - assert isinstance( - hass.data[client.DATA_CLIENTSESSION_NOTVERIFY], aiohttp.ClientSession - ) - assert isinstance(hass.data[client.DATA_CONNECTOR_NOTVERIFY], aiohttp.TCPConnector) + client_session = hass.data[client.DATA_CLIENTSESSION][(verify_ssl, expected_family)] + assert isinstance(client_session, aiohttp.ClientSession) + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, expected_family)] + assert isinstance(connector, aiohttp.TCPConnector) hass.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) await hass.async_block_till_done() - assert hass.data[client.DATA_CLIENTSESSION_NOTVERIFY].closed - assert hass.data[client.DATA_CONNECTOR_NOTVERIFY].closed + assert client_session.closed + assert connector.closed async def test_get_clientsession_patched_close(hass: HomeAssistant) -> None: """Test closing clientsession does not work.""" + + verify_ssl = True + family = 0 + with patch("aiohttp.ClientSession.close") as mock_close: session = client.async_get_clientsession(hass) - assert isinstance(hass.data[client.DATA_CLIENTSESSION], aiohttp.ClientSession) - assert isinstance(hass.data[client.DATA_CONNECTOR], aiohttp.TCPConnector) + assert isinstance( + hass.data[client.DATA_CLIENTSESSION][(verify_ssl, family)], + aiohttp.ClientSession, + ) + assert isinstance( + hass.data[client.DATA_CONNECTOR][(verify_ssl, family)], aiohttp.TCPConnector + ) with pytest.raises(RuntimeError): await session.close() diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 6af03136760836..973dec7381ec62 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -125,6 +125,19 @@ async def test_component_not_found_recovery_mode(hass: HomeAssistant) -> None: assert not res.errors +async def test_component_not_found_safe_mode(hass: HomeAssistant) -> None: + """Test no errors if component not found in safe mode.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} + hass.config.safe_mode = True + with patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant"} + assert not res.errors + + async def test_component_platform_not_found_2(hass: HomeAssistant) -> None: """Test errors if component or platform not found.""" # Make sure they don't exist @@ -146,7 +159,7 @@ async def test_component_platform_not_found_2(hass: HomeAssistant) -> None: async def test_platform_not_found_recovery_mode(hass: HomeAssistant) -> None: - """Test no errors if platform not found in recovery_mode.""" + """Test no errors if platform not found in recovery mode.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: beer"} hass.config.recovery_mode = True @@ -160,6 +173,21 @@ async def test_platform_not_found_recovery_mode(hass: HomeAssistant) -> None: assert not res.errors +async def test_platform_not_found_safe_mode(hass: HomeAssistant) -> None: + """Test no errors if platform not found in safe mode.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: beer"} + hass.config.safe_mode = True + with patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant", "light"} + assert res["light"] == [] + + assert not res.errors + + async def test_package_invalid(hass: HomeAssistant) -> None: """Test a valid platform setup.""" files = {YAML_CONFIG_FILE: BASE_CONFIG + ' packages:\n p1:\n group: ["a"]'} diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 8e4409daa547de..6c327345881ee6 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -13,7 +13,7 @@ import voluptuous as vol # Otherwise can't test just this file (import order issue) -from homeassistant import exceptions +from homeassistant import config_entries, exceptions import homeassistant.components.scene as scene from homeassistant.const import ( ATTR_ENTITY_ID, @@ -33,6 +33,7 @@ from homeassistant.exceptions import ConditionError, HomeAssistantError, ServiceNotFound from homeassistant.helpers import ( config_validation as cv, + device_registry as dr, entity_registry as er, script, template, @@ -43,6 +44,7 @@ import homeassistant.util.dt as dt_util from tests.common import ( + MockConfigEntry, async_capture_events, async_fire_time_changed, async_mock_service, @@ -4532,12 +4534,23 @@ async def test_set_redefines_variable( assert_action_trace(expected_trace) -async def test_validate_action_config(hass: HomeAssistant) -> None: +async def test_validate_action_config( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Validate action config.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + + mock_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:02")}, + ) + def templated_device_action(message): return { - "device_id": "abcd", + "device_id": mock_device.id, "domain": "mobile_app", "message": f"{message} {{{{ 5 + 5}}}}", "type": "notify", diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 03a8b5e11b2d8c..04324cdbfa33e6 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -465,7 +465,14 @@ async def test_extract_entity_ids(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() await hass.components.group.Group.async_create_group( - hass, "test", ["light.Ceiling", "light.Kitchen"] + hass, + "test", + created_by_service=False, + entity_ids=["light.Ceiling", "light.Kitchen"], + icon=None, + mode=None, + object_id=None, + order=None, ) call = ServiceCall("light", "turn_on", {ATTR_ENTITY_ID: "light.Bowl"}) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 58e0c730165b50..5f7ef594909a7a 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -7,6 +7,7 @@ import logging import math import random +from types import MappingProxyType from typing import Any from unittest.mock import patch @@ -43,6 +44,7 @@ from homeassistant.helpers.typing import TemplateVarsType from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.unit_system import UnitSystem from tests.common import MockConfigEntry, async_fire_time_changed @@ -475,6 +477,171 @@ def test_isnumber(hass: HomeAssistant, value, expected) -> None: ) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], True), + ({1, 2}, False), + ({"a": 1, "b": 2}, False), + (ReadOnlyDict({"a": 1, "b": 2}), False), + (MappingProxyType({"a": 1, "b": 2}), False), + ("abc", False), + (b"abc", False), + ((1, 2), False), + (datetime(2024, 1, 1, 0, 0, 0), False), + ], +) +def test_is_list(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test is list.""" + assert ( + template.Template("{{ value is list }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], False), + ({1, 2}, True), + ({"a": 1, "b": 2}, False), + (ReadOnlyDict({"a": 1, "b": 2}), False), + (MappingProxyType({"a": 1, "b": 2}), False), + ("abc", False), + (b"abc", False), + ((1, 2), False), + (datetime(2024, 1, 1, 0, 0, 0), False), + ], +) +def test_is_set(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test is set.""" + assert ( + template.Template("{{ value is set }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], False), + ({1, 2}, False), + ({"a": 1, "b": 2}, False), + (ReadOnlyDict({"a": 1, "b": 2}), False), + (MappingProxyType({"a": 1, "b": 2}), False), + ("abc", False), + (b"abc", False), + ((1, 2), True), + (datetime(2024, 1, 1, 0, 0, 0), False), + ], +) +def test_is_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test is tuple.""" + assert ( + template.Template("{{ value is tuple }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], {1, 2}), + ({1, 2}, {1, 2}), + ({"a": 1, "b": 2}, {"a", "b"}), + (ReadOnlyDict({"a": 1, "b": 2}), {"a", "b"}), + (MappingProxyType({"a": 1, "b": 2}), {"a", "b"}), + ("abc", {"a", "b", "c"}), + (b"abc", {97, 98, 99}), + ((1, 2), {1, 2}), + ], +) +def test_set(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test convert to set function.""" + assert ( + template.Template("{{ set(value) }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], (1, 2)), + ({1, 2}, (1, 2)), + ({"a": 1, "b": 2}, ("a", "b")), + (ReadOnlyDict({"a": 1, "b": 2}), ("a", "b")), + (MappingProxyType({"a": 1, "b": 2}), ("a", "b")), + ("abc", ("a", "b", "c")), + (b"abc", (97, 98, 99)), + ((1, 2), (1, 2)), + ], +) +def test_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test convert to tuple function.""" + assert ( + template.Template("{{ tuple(value) }}", hass).async_render({"value": value}) + == expected + ) + + +def test_converting_datetime_to_iterable(hass: HomeAssistant) -> None: + """Test converting a datetime to an iterable raises an error.""" + dt_ = datetime(2020, 1, 1, 0, 0, 0) + with pytest.raises(TemplateError): + template.Template("{{ tuple(value) }}", hass).async_render({"value": dt_}) + with pytest.raises(TemplateError): + template.Template("{{ set(value) }}", hass).async_render({"value": dt_}) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], False), + ({1, 2}, False), + ({"a": 1, "b": 2}, False), + (ReadOnlyDict({"a": 1, "b": 2}), False), + (MappingProxyType({"a": 1, "b": 2}), False), + ("abc", False), + (b"abc", False), + ((1, 2), False), + (datetime(2024, 1, 1, 0, 0, 0), True), + ], +) +def test_is_datetime(hass: HomeAssistant, value, expected) -> None: + """Test is datetime.""" + assert ( + template.Template("{{ value is datetime }}", hass).async_render( + {"value": value} + ) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], False), + ({1, 2}, False), + ({"a": 1, "b": 2}, False), + (ReadOnlyDict({"a": 1, "b": 2}), False), + (MappingProxyType({"a": 1, "b": 2}), False), + ("abc", True), + (b"abc", True), + ((1, 2), False), + (datetime(2024, 1, 1, 0, 0, 0), False), + ], +) +def test_is_string_like(hass: HomeAssistant, value, expected) -> None: + """Test is string_like.""" + assert ( + template.Template("{{ value is string_like }}", hass).async_render( + {"value": value} + ) + == expected + ) + + def test_rounding_value(hass: HomeAssistant) -> None: """Test rounding value.""" hass.states.async_set("sensor.temperature", 12.78) @@ -2482,7 +2649,16 @@ async def test_closest_function_home_vs_group_entity_id(hass: HomeAssistant) -> assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() - await group.Group.async_create_group(hass, "location group", ["test_domain.object"]) + await group.Group.async_create_group( + hass, + "location group", + created_by_service=False, + entity_ids=["test_domain.object"], + icon=None, + mode=None, + object_id=None, + order=None, + ) info = render_to_info(hass, '{{ closest("group.location_group").entity_id }}') assert_result_info( @@ -2510,7 +2686,16 @@ async def test_closest_function_home_vs_group_state(hass: HomeAssistant) -> None assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() - await group.Group.async_create_group(hass, "location group", ["test_domain.object"]) + await group.Group.async_create_group( + hass, + "location group", + created_by_service=False, + entity_ids=["test_domain.object"], + icon=None, + mode=None, + object_id=None, + order=None, + ) info = render_to_info(hass, '{{ closest("group.location_group").entity_id }}') assert_result_info( @@ -2560,7 +2745,16 @@ async def test_expand(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() - await group.Group.async_create_group(hass, "new group", ["test.object"]) + await group.Group.async_create_group( + hass, + "new group", + created_by_service=False, + entity_ids=["test.object"], + icon=None, + mode=None, + object_id=None, + order=None, + ) info = render_to_info( hass, @@ -2602,7 +2796,14 @@ async def test_expand(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "group", {}) await hass.async_block_till_done() await group.Group.async_create_group( - hass, "power sensors", ["sensor.power_1", "sensor.power_2", "sensor.power_3"] + hass, + "power sensors", + created_by_service=False, + entity_ids=["sensor.power_1", "sensor.power_2", "sensor.power_3"], + icon=None, + mode=None, + object_id=None, + order=None, ) info = render_to_info( diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index d7901b0566eb0d..555bcbdf6b20f1 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -642,6 +642,72 @@ async def test_setup_hass_recovery_mode( assert len(browser_setup.mock_calls) == 0 +async def test_setup_hass_safe_mode( + mock_hass_config: None, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + mock_mount_local_lib_path: AsyncMock, + mock_ensure_config_exists: AsyncMock, + mock_process_ha_config_upgrade: Mock, + caplog: pytest.LogCaptureFixture, + event_loop: asyncio.AbstractEventLoop, +) -> None: + """Test it works.""" + with patch("homeassistant.components.browser.setup"), patch( + "homeassistant.config_entries.ConfigEntries.async_domains", + return_value=["browser"], + ): + hass = await bootstrap.async_setup_hass( + runner.RuntimeConfig( + config_dir=get_test_config_dir(), + verbose=False, + log_rotate_days=10, + log_file="", + log_no_color=False, + skip_pip=True, + recovery_mode=False, + safe_mode=True, + ), + ) + + assert "recovery_mode" not in hass.config.components + assert "Starting in recovery mode" not in caplog.text + assert "Starting in safe mode" in caplog.text + + +async def test_setup_hass_recovery_mode_and_safe_mode( + mock_hass_config: None, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + mock_mount_local_lib_path: AsyncMock, + mock_ensure_config_exists: AsyncMock, + mock_process_ha_config_upgrade: Mock, + caplog: pytest.LogCaptureFixture, + event_loop: asyncio.AbstractEventLoop, +) -> None: + """Test it works.""" + with patch("homeassistant.components.browser.setup"), patch( + "homeassistant.config_entries.ConfigEntries.async_domains", + return_value=["browser"], + ): + hass = await bootstrap.async_setup_hass( + runner.RuntimeConfig( + config_dir=get_test_config_dir(), + verbose=False, + log_rotate_days=10, + log_file="", + log_no_color=False, + skip_pip=True, + recovery_mode=True, + safe_mode=True, + ), + ) + + assert "recovery_mode" in hass.config.components + assert "Starting in recovery mode" in caplog.text + assert "Starting in safe mode" not in caplog.text + + @pytest.mark.parametrize("hass_config", [{"homeassistant": {"non-existing": 1}}]) async def test_setup_hass_invalid_core_config( mock_hass_config: None, diff --git a/tests/test_config.py b/tests/test_config.py index aeb25313302aa3..d5181bbe115197 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -49,6 +49,7 @@ AUTOMATIONS_PATH = os.path.join(CONFIG_DIR, config_util.AUTOMATION_CONFIG_PATH) SCRIPTS_PATH = os.path.join(CONFIG_DIR, config_util.SCRIPT_CONFIG_PATH) SCENES_PATH = os.path.join(CONFIG_DIR, config_util.SCENE_CONFIG_PATH) +SAFE_MODE_PATH = os.path.join(CONFIG_DIR, config_util.SAFE_MODE_FILENAME) def create_file(path): @@ -80,6 +81,9 @@ def teardown(): if os.path.isfile(SCENES_PATH): os.remove(SCENES_PATH) + if os.path.isfile(SAFE_MODE_PATH): + os.remove(SAFE_MODE_PATH) + async def test_create_default_config(hass: HomeAssistant) -> None: """Test creation of default config.""" @@ -1386,3 +1390,12 @@ async def test_core_store_no_country( await hass.config.async_update(**{"country": "SE"}) issue = issue_registry.async_get_issue("homeassistant", issue_id) assert not issue + + +async def test_safe_mode(hass: HomeAssistant) -> None: + """Test safe mode.""" + assert config_util.safe_mode_enabled(hass.config.config_dir) is False + assert config_util.safe_mode_enabled(hass.config.config_dir) is False + await config_util.async_enable_safe_mode(hass) + assert config_util.safe_mode_enabled(hass.config.config_dir) is True + assert config_util.safe_mode_enabled(hass.config.config_dir) is False diff --git a/tests/test_core.py b/tests/test_core.py index cd855ab2c73e5e..9fed1141a76110 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1494,6 +1494,7 @@ async def test_config_as_dict() -> None: "currency": "EUR", "country": None, "language": "en", + "safe_mode": False, } assert expected == config.as_dict() @@ -2497,3 +2498,39 @@ async def test_get_release_channel(version: str, release_channel: str) -> None: """Test if release channel detection works from Home Assistant version number.""" with patch("homeassistant.core.__version__", f"{version}"): assert get_release_channel() == release_channel + + +def test_is_callback_check_partial(): + """Test is_callback_check_partial matches HassJob.""" + + @ha.callback + def callback_func(): + pass + + def not_callback_func(): + pass + + assert ha.is_callback(callback_func) + assert HassJob(callback_func).job_type == ha.HassJobType.Callback + assert ha.is_callback_check_partial(functools.partial(callback_func)) + assert HassJob(functools.partial(callback_func)).job_type == ha.HassJobType.Callback + assert ha.is_callback_check_partial( + functools.partial(functools.partial(callback_func)) + ) + assert HassJob(functools.partial(functools.partial(callback_func))).job_type == ( + ha.HassJobType.Callback + ) + assert not ha.is_callback_check_partial(not_callback_func) + assert HassJob(not_callback_func).job_type == ha.HassJobType.Executor + assert not ha.is_callback_check_partial(functools.partial(not_callback_func)) + assert HassJob(functools.partial(not_callback_func)).job_type == ( + ha.HassJobType.Executor + ) + + # We check the inner function, not the outer one + assert not ha.is_callback_check_partial( + ha.callback(functools.partial(not_callback_func)) + ) + assert HassJob(ha.callback(functools.partial(not_callback_func))).job_type == ( + ha.HassJobType.Executor + )