diff --git a/.core_files.yaml b/.core_files.yaml index 4ac65cd92c7..6fbfdf90a4b 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -30,6 +30,7 @@ base_platforms: &base_platforms - homeassistant/components/humidifier/** - homeassistant/components/image/** - homeassistant/components/image_processing/** + - homeassistant/components/lawn_mower/** - homeassistant/components/light/** - homeassistant/components/lock/** - homeassistant/components/media_player/** diff --git a/.coveragerc b/.coveragerc index bfa2cd07b81..297193e4d31 100644 --- a/.coveragerc +++ b/.coveragerc @@ -727,8 +727,6 @@ omit = homeassistant/components/meteoclimatic/__init__.py homeassistant/components/meteoclimatic/sensor.py homeassistant/components/meteoclimatic/weather.py - homeassistant/components/metoffice/sensor.py - homeassistant/components/metoffice/weather.py homeassistant/components/microsoft/tts.py homeassistant/components/mikrotik/hub.py homeassistant/components/mill/climate.py @@ -783,6 +781,7 @@ omit = homeassistant/components/neato/__init__.py homeassistant/components/neato/api.py homeassistant/components/neato/camera.py + homeassistant/components/neato/entity.py homeassistant/components/neato/hub.py homeassistant/components/neato/sensor.py homeassistant/components/neato/switch.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5cb51a30dda..a96a0602473 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,6 +19,10 @@ on: description: "Skip pytest" default: false type: boolean + skip-coverage: + description: "Skip coverage" + default: false + type: boolean pylint-only: description: "Only run pylint" default: false @@ -79,6 +83,7 @@ jobs: test_groups: ${{ steps.info.outputs.test_groups }} tests_glob: ${{ steps.info.outputs.tests_glob }} tests: ${{ steps.info.outputs.tests }} + skip_coverage: ${{ steps.info.outputs.skip_coverage }} runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub @@ -127,6 +132,7 @@ jobs: test_group_count=10 tests="[]" tests_glob="" + skip_coverage="" if [[ "${{ steps.integrations.outputs.changes }}" != "[]" ]]; then @@ -176,6 +182,12 @@ jobs: test_full_suite="true" fi + if [[ "${{ github.event.inputs.skip-coverage }}" == "true" ]] \ + || [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-skip-coverage') }}" == "true" ]]; + then + skip_coverage="true" + fi + # Output & sent to GitHub Actions echo "mariadb_groups: ${mariadb_groups}" echo "mariadb_groups=${mariadb_groups}" >> $GITHUB_OUTPUT @@ -195,6 +207,8 @@ jobs: echo "tests=${tests}" >> $GITHUB_OUTPUT echo "tests_glob: ${tests_glob}" echo "tests_glob=${tests_glob}" >> $GITHUB_OUTPUT + echo "skip_coverage: ${skip_coverage}" + echo "skip_coverage=${skip_coverage}" >> $GITHUB_OUTPUT pre-commit: name: Prepare pre-commit base @@ -741,6 +755,11 @@ jobs: . venv/bin/activate python --version set -o pipefail + cov_params=() + if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then + cov_params+=(--cov="homeassistant") + cov_params+=(--cov-report=xml) + fi python3 -X dev -m pytest \ -qq \ @@ -750,8 +769,7 @@ jobs: --dist=loadfile \ --test-group-count ${{ needs.info.outputs.test_group_count }} \ --test-group=${{ matrix.group }} \ - --cov="homeassistant" \ - --cov-report=xml \ + ${cov_params[@]} \ -o console_output_style=count \ -p no:sugar \ tests \ @@ -773,13 +791,18 @@ jobs: exit 1 fi + cov_params=() + if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then + cov_params+=(--cov="homeassistant.components.${{ matrix.group }}") + cov_params+=(--cov-report=xml) + cov_params+=(--cov-report=term-missing) + fi + python3 -X dev -m pytest \ -qq \ --timeout=9 \ -n auto \ - --cov="homeassistant.components.${{ matrix.group }}" \ - --cov-report=xml \ - --cov-report=term-missing \ + ${cov_params[@]} \ -o console_output_style=count \ --durations=0 \ --durations-min=1 \ @@ -793,6 +816,7 @@ jobs: name: pytest-${{ github.run_number }} path: pytest-*.txt - name: Upload coverage artifact + if: needs.info.outputs.skip_coverage != 'true' uses: actions/upload-artifact@v3.1.2 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} @@ -888,14 +912,18 @@ jobs: python --version set -o pipefail mariadb=$(echo "${{ matrix.mariadb-group }}" | sed "s/:/-/g") + cov_params=() + if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then + cov_params+=(--cov="homeassistant.components.recorder") + cov_params+=(--cov-report=xml) + cov_params+=(--cov-report=term-missing) + fi python3 -X dev -m pytest \ -qq \ --timeout=20 \ -n 1 \ - --cov="homeassistant.components.recorder" \ - --cov-report=xml \ - --cov-report=term-missing \ + ${cov_params[@]} \ -o console_output_style=count \ --durations=10 \ -p no:sugar \ @@ -912,6 +940,7 @@ jobs: name: pytest-${{ github.run_number }} path: pytest-*.txt - name: Upload coverage artifact + if: needs.info.outputs.skip_coverage != 'true' uses: actions/upload-artifact@v3.1.2 with: name: coverage-${{ matrix.python-version }}-mariadb @@ -1007,14 +1036,18 @@ jobs: python --version set -o pipefail postgresql=$(echo "${{ matrix.postgresql-group }}" | sed "s/:/-/g") + cov_params=() + if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then + cov_params+=(--cov="homeassistant.components.recorder") + cov_params+=(--cov-report=xml) + cov_params+=(--cov-report=term-missing) + fi python3 -X dev -m pytest \ -qq \ --timeout=9 \ -n 1 \ - --cov="homeassistant.components.recorder" \ - --cov-report=xml \ - --cov-report=term-missing \ + ${cov_params[@]} \ -o console_output_style=count \ --durations=0 \ --durations-min=10 \ @@ -1032,6 +1065,7 @@ jobs: name: pytest-${{ github.run_number }} path: pytest-*.txt - name: Upload coverage artifact + if: needs.info.outputs.skip_coverage != 'true' uses: actions/upload-artifact@v3.1.0 with: name: coverage-${{ matrix.python-version }}-postgresql @@ -1042,6 +1076,7 @@ jobs: coverage: name: Upload test coverage to Codecov + if: needs.info.outputs.skip_coverage != 'true' runs-on: ubuntu-22.04 needs: - info diff --git a/.strict-typing b/.strict-typing index c56c7d9f137..5ecdc54826b 100644 --- a/.strict-typing +++ b/.strict-typing @@ -104,6 +104,7 @@ homeassistant.components.dhcp.* homeassistant.components.diagnostics.* homeassistant.components.dlna_dmr.* homeassistant.components.dnsip.* +homeassistant.components.doorbird.* homeassistant.components.dormakaba_dkey.* homeassistant.components.dsmr.* homeassistant.components.dunehd.* @@ -194,6 +195,7 @@ homeassistant.components.lacrosse.* homeassistant.components.lacrosse_view.* homeassistant.components.lametric.* homeassistant.components.laundrify.* +homeassistant.components.lawn_mower.* homeassistant.components.lcn.* homeassistant.components.ld2410_ble.* homeassistant.components.lidarr.* diff --git a/CODEOWNERS b/CODEOWNERS index 6efe5da8bfe..9a881459e83 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -675,6 +675,8 @@ build.json @home-assistant/supervisor /tests/components/launch_library/ @ludeeus @DurgNomis-drol /homeassistant/components/laundrify/ @xLarry /tests/components/laundrify/ @xLarry +/homeassistant/components/lawn_mower/ @home-assistant/core +/tests/components/lawn_mower/ @home-assistant/core /homeassistant/components/lcn/ @alengwenus /tests/components/lcn/ @alengwenus /homeassistant/components/ld2410_ble/ @930913 diff --git a/homeassistant/backports/functools.py b/homeassistant/backports/functools.py index 83d66a39f71..212c8516b48 100644 --- a/homeassistant/backports/functools.py +++ b/homeassistant/backports/functools.py @@ -8,7 +8,7 @@ from typing import Any, Generic, Self, TypeVar, overload _T = TypeVar("_T") -class cached_property(Generic[_T]): # pylint: disable=invalid-name +class cached_property(Generic[_T]): """Backport of Python 3.12's cached_property. Includes https://github.com/python/cpython/pull/101890/files diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index 032e0a3a9f6..68e7bb6c5e0 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -6,6 +6,7 @@ from aemet_opendata.interface import AEMET from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client from .const import ( CONF_STATION_UPDATES, @@ -27,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: longitude = entry.data[CONF_LONGITUDE] station_updates = entry.options.get(CONF_STATION_UPDATES, True) - aemet = AEMET(api_key) + aemet = AEMET(aiohttp_client.async_get_clientsession(hass), api_key) weather_coordinator = WeatherUpdateCoordinator( hass, aemet, latitude, longitude, station_updates ) diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py index 9db0c6f7db1..129f513025a 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -2,12 +2,13 @@ from __future__ import annotations from aemet_opendata import AEMET +from aemet_opendata.exceptions import AuthError import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, SchemaOptionsFlowHandler, @@ -39,8 +40,13 @@ class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(f"{latitude}-{longitude}") self._abort_if_unique_id_configured() - api_online = await _is_aemet_api_online(self.hass, user_input[CONF_API_KEY]) - if not api_online: + aemet = AEMET( + aiohttp_client.async_get_clientsession(self.hass), + user_input[CONF_API_KEY], + ) + try: + await aemet.get_conventional_observation_stations(False) + except AuthError: errors["base"] = "invalid_api_key" if not errors: @@ -70,10 +76,3 @@ class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> SchemaOptionsFlowHandler: """Get the options flow for this handler.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) - - -async def _is_aemet_api_online(hass, api_key): - aemet = AEMET(api_key) - return await hass.async_add_executor_job( - aemet.get_conventional_observation_stations, False - ) diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json index f9f1129f3b0..a460d9e16bc 100644 --- a/homeassistant/components/aemet/manifest.json +++ b/homeassistant/components/aemet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aemet", "iot_class": "cloud_polling", "loggers": ["aemet_opendata"], - "requirements": ["AEMET-OpenData==0.2.2"] + "requirements": ["AEMET-OpenData==0.3.0"] } diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index f9b0f7ef6ca..6affc39c7a8 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -11,8 +11,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, DOMAIN as WEATHER_DOMAIN, + CoordinatorWeatherEntity, Forecast, - WeatherEntity, WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -22,10 +22,9 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_API_CONDITION, @@ -111,7 +110,7 @@ async def async_setup_entry( async_add_entities(entities, False) -class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): +class AemetWeather(CoordinatorWeatherEntity[WeatherUpdateCoordinator]): """Implementation of an AEMET OpenData sensor.""" _attr_attribution = ATTRIBUTION @@ -139,15 +138,6 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): self._attr_name = name self._attr_unique_id = unique_id - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - super()._handle_coordinator_update() - assert self.platform.config_entry - self.platform.config_entry.async_create_task( - self.hass, self.async_update_listeners(("daily", "hourly")) - ) - @property def condition(self): """Return the current condition.""" diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index 5e9ce6af677..d44160116f2 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -146,13 +146,13 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): async def _get_aemet_weather(self): """Poll weather data from AEMET OpenData.""" - weather = await self.hass.async_add_executor_job(self._get_weather_and_forecast) + weather = await self._get_weather_and_forecast() return weather - def _get_weather_station(self): + async def _get_weather_station(self): if not self._station: self._station = ( - self._aemet.get_conventional_observation_station_by_coordinates( + await self._aemet.get_conventional_observation_station_by_coordinates( self._latitude, self._longitude ) ) @@ -171,9 +171,9 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): ) return self._station - def _get_weather_town(self): + async def _get_weather_town(self): if not self._town: - self._town = self._aemet.get_town_by_coordinates( + self._town = await self._aemet.get_town_by_coordinates( self._latitude, self._longitude ) if self._town: @@ -192,18 +192,20 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): raise TownNotFound return self._town - def _get_weather_and_forecast(self): + async def _get_weather_and_forecast(self): """Get weather and forecast data from AEMET OpenData.""" - self._get_weather_town() + await self._get_weather_town() - daily = self._aemet.get_specific_forecast_town_daily(self._town[AEMET_ATTR_ID]) + daily = await self._aemet.get_specific_forecast_town_daily( + self._town[AEMET_ATTR_ID] + ) if not daily: _LOGGER.error( 'Error fetching daily data for town "%s"', self._town[AEMET_ATTR_ID] ) - hourly = self._aemet.get_specific_forecast_town_hourly( + hourly = await self._aemet.get_specific_forecast_town_hourly( self._town[AEMET_ATTR_ID] ) if not hourly: @@ -212,8 +214,8 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): ) station = None - if self._station_updates and self._get_weather_station(): - station = self._aemet.get_conventional_observation_station_data( + if self._station_updates and await self._get_weather_station(): + station = await self._aemet.get_conventional_observation_station_data( self._station[AEMET_ATTR_IDEMA] ) if not station: diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index 021aaa3535c..267cd210ff0 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -10,6 +10,7 @@ from aioairzone.const import ( AZD_AVAILABLE, AZD_FIRMWARE, AZD_FULL_NAME, + AZD_HOT_WATER, AZD_ID, AZD_MAC, AZD_MODEL, @@ -81,6 +82,31 @@ class AirzoneSystemEntity(AirzoneEntity): return value +class AirzoneHotWaterEntity(AirzoneEntity): + """Define an Airzone Hot Water entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + entry: ConfigEntry, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{entry.entry_id}_dhw")}, + manufacturer=MANUFACTURER, + model="DHW", + name=self.get_airzone_value(AZD_NAME), + via_device=(DOMAIN, f"{entry.entry_id}_ws"), + ) + self._attr_unique_id = entry.unique_id or entry.entry_id + + def get_airzone_value(self, key: str) -> Any: + """Return DHW value by key.""" + return self.coordinator.data[AZD_HOT_WATER].get(key) + + class AirzoneWebServerEntity(AirzoneEntity): """Define an Airzone WebServer entity.""" diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 711da2ec993..bb1e448c8eb 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.6.6"] + "requirements": ["aioairzone==0.6.7"] } diff --git a/homeassistant/components/airzone/sensor.py b/homeassistant/components/airzone/sensor.py index d90fdf93607..1dd67294aff 100644 --- a/homeassistant/components/airzone/sensor.py +++ b/homeassistant/components/airzone/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any, Final from aioairzone.const import ( + AZD_HOT_WATER, AZD_HUMIDITY, AZD_NAME, AZD_TEMP, @@ -31,7 +32,21 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, TEMP_UNIT_LIB_TO_HASS from .coordinator import AirzoneUpdateCoordinator -from .entity import AirzoneEntity, AirzoneWebServerEntity, AirzoneZoneEntity +from .entity import ( + AirzoneEntity, + AirzoneHotWaterEntity, + AirzoneWebServerEntity, + AirzoneZoneEntity, +) + +HOT_WATER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key=AZD_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), +) WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( SensorEntityDescription( @@ -71,6 +86,18 @@ async def async_setup_entry( sensors: list[AirzoneSensor] = [] + if AZD_HOT_WATER in coordinator.data: + dhw_data = coordinator.data[AZD_HOT_WATER] + for description in HOT_WATER_SENSOR_TYPES: + if description.key in dhw_data: + sensors.append( + AirzoneHotWaterSensor( + coordinator, + description, + entry, + ) + ) + if AZD_WEBSERVER in coordinator.data: ws_data = coordinator.data[AZD_WEBSERVER] for description in WEBSERVER_SENSOR_TYPES: @@ -114,6 +141,30 @@ class AirzoneSensor(AirzoneEntity, SensorEntity): self._attr_native_value = self.get_airzone_value(self.entity_description.key) +class AirzoneHotWaterSensor(AirzoneHotWaterEntity, AirzoneSensor): + """Define an Airzone Hot Water sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: SensorEntityDescription, + entry: ConfigEntry, + ) -> None: + """Initialize.""" + super().__init__(coordinator, entry) + + self._attr_unique_id = f"{self._attr_unique_id}_dhw_{description.key}" + self.entity_description = description + + self._attr_native_unit_of_measurement = TEMP_UNIT_LIB_TO_HASS.get( + self.get_airzone_value(AZD_TEMP_UNIT) + ) + + self._async_update_attrs() + + class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): """Define an Airzone WebServer sensor.""" diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py index 765eec2d288..a364ad0d753 100644 --- a/homeassistant/components/airzone_cloud/binary_sensor.py +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -9,6 +9,7 @@ from aioairzone_cloud.const import ( AZD_AIDOOS, AZD_ERRORS, AZD_PROBLEMS, + AZD_SYSTEMS, AZD_WARNINGS, AZD_ZONES, ) @@ -25,7 +26,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import AirzoneUpdateCoordinator -from .entity import AirzoneAidooEntity, AirzoneEntity, AirzoneZoneEntity +from .entity import ( + AirzoneAidooEntity, + AirzoneEntity, + AirzoneSystemEntity, + AirzoneZoneEntity, +) @dataclass @@ -51,6 +57,20 @@ AIDOO_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ... ), ) + +SYSTEM_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]] = ( + AirzoneBinarySensorEntityDescription( + attributes={ + "errors": AZD_ERRORS, + "warnings": AZD_WARNINGS, + }, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + key=AZD_PROBLEMS, + ), +) + + ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]] = ( AirzoneBinarySensorEntityDescription( device_class=BinarySensorDeviceClass.RUNNING, @@ -87,6 +107,18 @@ async def async_setup_entry( ) ) + for system_id, system_data in coordinator.data.get(AZD_SYSTEMS, {}).items(): + for description in SYSTEM_BINARY_SENSOR_TYPES: + if description.key in system_data: + binary_sensors.append( + AirzoneSystemBinarySensor( + coordinator, + description, + system_id, + system_data, + ) + ) + for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items(): for description in ZONE_BINARY_SENSOR_TYPES: if description.key in zone_data: @@ -145,6 +177,27 @@ class AirzoneAidooBinarySensor(AirzoneAidooEntity, AirzoneBinarySensor): self._async_update_attrs() +class AirzoneSystemBinarySensor(AirzoneSystemEntity, AirzoneBinarySensor): + """Define an Airzone Cloud System binary sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: AirzoneBinarySensorEntityDescription, + system_id: str, + system_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator, system_id, system_data) + + self._attr_unique_id = f"{system_id}_{description.key}" + self.entity_description = description + + self._async_update_attrs() + + class AirzoneZoneBinarySensor(AirzoneZoneEntity, AirzoneBinarySensor): """Define an Airzone Cloud Zone binary sensor.""" diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index 32c41b8f1cd..090e81e4170 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -10,6 +10,7 @@ from aioairzone_cloud.const import ( AZD_FIRMWARE, AZD_NAME, AZD_SYSTEM_ID, + AZD_SYSTEMS, AZD_WEBSERVER, AZD_WEBSERVERS, AZD_ZONES, @@ -65,6 +66,35 @@ class AirzoneAidooEntity(AirzoneEntity): return value +class AirzoneSystemEntity(AirzoneEntity): + """Define an Airzone Cloud System entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + system_id: str, + system_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.system_id = system_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, system_id)}, + manufacturer=MANUFACTURER, + name=system_data[AZD_NAME], + via_device=(DOMAIN, system_data[AZD_WEBSERVER]), + ) + + def get_airzone_value(self, key: str) -> Any: + """Return system value by key.""" + value = None + if system := self.coordinator.data[AZD_SYSTEMS].get(self.system_id): + value = system.get(key) + return value + + class AirzoneWebServerEntity(AirzoneEntity): """Define an Airzone Cloud WebServer entity.""" diff --git a/homeassistant/components/amazon_polly/manifest.json b/homeassistant/components/amazon_polly/manifest.json index aeda26c9b23..57971899cc0 100644 --- a/homeassistant/components/amazon_polly/manifest.json +++ b/homeassistant/components/amazon_polly/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/amazon_polly", "iot_class": "cloud_push", "loggers": ["boto3", "botocore", "s3transfer"], - "requirements": ["boto3==1.20.24"] + "requirements": ["boto3==1.28.17"] } diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index cb7a969379e..f45dee34afe 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["androidtvremote2"], "quality_scale": "platinum", - "requirements": ["androidtvremote2==0.0.13"], + "requirements": ["androidtvremote2==0.0.14"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py index 1768d3291a7..fdb399f0646 100644 --- a/homeassistant/components/august/activity.py +++ b/homeassistant/components/august/activity.py @@ -1,6 +1,7 @@ """Consume the august activity stream.""" import asyncio from datetime import datetime +from functools import partial import logging from aiohttp import ClientError @@ -9,7 +10,7 @@ from yalexs.api_async import ApiAsync from yalexs.pubnub_async import AugustPubNub from yalexs.util import get_latest_activity -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.event import async_call_later from homeassistant.util.dt import utcnow @@ -58,33 +59,38 @@ class ActivityStream(AugustSubscriberMixin): self._did_first_update = False self.pubnub = pubnub self._update_debounce: dict[str, Debouncer] = {} + self._update_debounce_jobs: dict[str, HassJob] = {} - async def async_setup(self): + async def _async_update_house_id_later( + self, debouncer: Debouncer, _: datetime + ) -> None: + """Call a debouncer from async_call_later.""" + await debouncer.async_call() + + async def async_setup(self) -> None: """Token refresh check and catch up the activity stream.""" - self._update_debounce = { - house_id: self._async_create_debouncer(house_id) - for house_id in self._house_ids - } + update_debounce = self._update_debounce + update_debounce_jobs = self._update_debounce_jobs + for house_id in self._house_ids: + debouncer = Debouncer( + self._hass, + _LOGGER, + cooldown=ACTIVITY_DEBOUNCE_COOLDOWN, + immediate=True, + function=partial(self._async_update_house_id, house_id), + ) + update_debounce[house_id] = debouncer + update_debounce_jobs[house_id] = HassJob( + partial(self._async_update_house_id_later, debouncer), + f"debounced august activity update for {house_id}", + cancel_on_shutdown=True, + ) + await self._async_refresh(utcnow()) self._did_first_update = True @callback - def _async_create_debouncer(self, house_id): - """Create a debouncer for the house id.""" - - async def _async_update_house_id(): - await self._async_update_house_id(house_id) - - return Debouncer( - self._hass, - _LOGGER, - cooldown=ACTIVITY_DEBOUNCE_COOLDOWN, - immediate=True, - function=_async_update_house_id, - ) - - @callback - def async_stop(self): + def async_stop(self) -> None: """Cleanup any debounces.""" for debouncer in self._update_debounce.values(): debouncer.async_cancel() @@ -127,28 +133,23 @@ class ActivityStream(AugustSubscriberMixin): @callback def async_schedule_house_id_refresh(self, house_id: str) -> None: """Update for a house activities now and once in the future.""" - if cancels := self._schedule_updates.get(house_id): - _async_cancel_future_scheduled_updates(cancels) + if future_updates := self._schedule_updates.setdefault(house_id, []): + _async_cancel_future_scheduled_updates(future_updates) debouncer = self._update_debounce[house_id] - self._hass.async_create_task(debouncer.async_call()) # Schedule two updates past the debounce time # to ensure we catch the case where the activity # api does not update right away and we need to poll # it again. Sometimes the lock operator or a doorbell # will not show up in the activity stream right away. - future_updates = self._schedule_updates.setdefault(house_id, []) - - async def _update_house_activities(now: datetime) -> None: - await debouncer.async_call() - + job = self._update_debounce_jobs[house_id] for step in (1, 2): future_updates.append( async_call_later( self._hass, (step * ACTIVITY_DEBOUNCE_COOLDOWN) + 0.1, - _update_house_activities, + job, ) ) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 98c9cbacbda..cd2737adca3 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.5.2", "yalexs-ble==2.2.3"] + "requirements": ["yalexs==1.8.0", "yalexs-ble==2.2.3"] } diff --git a/homeassistant/components/aws/manifest.json b/homeassistant/components/aws/manifest.json index 35d20258ead..c93a8493845 100644 --- a/homeassistant/components/aws/manifest.json +++ b/homeassistant/components/aws/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/aws", "iot_class": "cloud_push", "loggers": ["aiobotocore", "botocore"], - "requirements": ["aiobotocore==2.1.0"] + "requirements": ["aiobotocore==2.6.0"] } diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 1ae23633bdf..84453344c3c 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.0", "bluetooth-auto-recovery==1.2.1", "bluetooth-data-tools==1.8.0", - "dbus-fast==1.92.0" + "dbus-fast==1.93.0" ] } diff --git a/homeassistant/components/bosch_shc/binary_sensor.py b/homeassistant/components/bosch_shc/binary_sensor.py index 348bfe80701..c9969fcf415 100644 --- a/homeassistant/components/bosch_shc/binary_sensor.py +++ b/homeassistant/components/bosch_shc/binary_sensor.py @@ -62,6 +62,8 @@ async def async_setup_entry( class ShutterContactSensor(SHCEntity, BinarySensorEntity): """Representation of an SHC shutter contact sensor.""" + _attr_name = None + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC shutter contact sensor..""" super().__init__(device, parent_id, entry_id) @@ -89,7 +91,6 @@ class BatterySensor(SHCEntity, BinarySensorEntity): def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC battery reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Battery" self._attr_unique_id = f"{device.serial}_battery" @property diff --git a/homeassistant/components/bosch_shc/cover.py b/homeassistant/components/bosch_shc/cover.py index 3f1a9eccb93..8b2a2f65c12 100644 --- a/homeassistant/components/bosch_shc/cover.py +++ b/homeassistant/components/bosch_shc/cover.py @@ -42,6 +42,7 @@ async def async_setup_entry( class ShutterControlCover(SHCEntity, CoverEntity): """Representation of a SHC shutter control device.""" + _attr_name = None _attr_device_class = CoverDeviceClass.SHUTTER _attr_supported_features = ( CoverEntityFeature.OPEN diff --git a/homeassistant/components/bosch_shc/entity.py b/homeassistant/components/bosch_shc/entity.py index 5af77f8ee87..8c26d2e6d5a 100644 --- a/homeassistant/components/bosch_shc/entity.py +++ b/homeassistant/components/bosch_shc/entity.py @@ -24,6 +24,7 @@ class SHCBaseEntity(Entity): """Base representation of a SHC entity.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, device: SHCDevice | SHCIntrusionSystem, parent_id: str, entry_id: str @@ -31,7 +32,6 @@ class SHCBaseEntity(Entity): """Initialize the generic SHC device.""" self._device = device self._entry_id = entry_id - self._attr_name = device.name async def async_added_to_hass(self) -> None: """Subscribe to SHC events.""" diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index 73307d9ea0a..df216ed0ff2 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -170,7 +170,6 @@ class TemperatureSensor(SHCEntity, SensorEntity): def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC temperature reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Temperature" self._attr_unique_id = f"{device.serial}_temperature" @property @@ -188,7 +187,6 @@ class HumiditySensor(SHCEntity, SensorEntity): def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC humidity reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Humidity" self._attr_unique_id = f"{device.serial}_humidity" @property @@ -200,13 +198,13 @@ class HumiditySensor(SHCEntity, SensorEntity): class PuritySensor(SHCEntity, SensorEntity): """Representation of an SHC purity reporting sensor.""" + _attr_translation_key = "purity" _attr_icon = "mdi:molecule-co2" _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC purity reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Purity" self._attr_unique_id = f"{device.serial}_purity" @property @@ -218,10 +216,11 @@ class PuritySensor(SHCEntity, SensorEntity): class AirQualitySensor(SHCEntity, SensorEntity): """Representation of an SHC airquality reporting sensor.""" + _attr_translation_key = "air_quality" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC airquality reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Air Quality" self._attr_unique_id = f"{device.serial}_airquality" @property @@ -240,10 +239,11 @@ class AirQualitySensor(SHCEntity, SensorEntity): class TemperatureRatingSensor(SHCEntity, SensorEntity): """Representation of an SHC temperature rating sensor.""" + _attr_translation_key = "temperature_rating" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC temperature rating sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Temperature Rating" self._attr_unique_id = f"{device.serial}_temperature_rating" @property @@ -255,12 +255,12 @@ class TemperatureRatingSensor(SHCEntity, SensorEntity): class CommunicationQualitySensor(SHCEntity, SensorEntity): """Representation of an SHC communication quality reporting sensor.""" + _attr_translation_key = "communication_quality" _attr_icon = "mdi:wifi" def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC communication quality reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Communication Quality" self._attr_unique_id = f"{device.serial}_communication_quality" @property @@ -272,10 +272,11 @@ class CommunicationQualitySensor(SHCEntity, SensorEntity): class HumidityRatingSensor(SHCEntity, SensorEntity): """Representation of an SHC humidity rating sensor.""" + _attr_translation_key = "humidity_rating" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC humidity rating sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Humidity Rating" self._attr_unique_id = f"{device.serial}_humidity_rating" @property @@ -287,10 +288,11 @@ class HumidityRatingSensor(SHCEntity, SensorEntity): class PurityRatingSensor(SHCEntity, SensorEntity): """Representation of an SHC purity rating sensor.""" + _attr_translation_key = "purity_rating" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC purity rating sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Purity Rating" self._attr_unique_id = f"{device.serial}_purity_rating" @property @@ -308,7 +310,6 @@ class PowerSensor(SHCEntity, SensorEntity): def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC power reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Power" self._attr_unique_id = f"{device.serial}_power" @property @@ -327,7 +328,6 @@ class EnergySensor(SHCEntity, SensorEntity): def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC energy reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{self._device.name} Energy" self._attr_unique_id = f"{self._device.serial}_energy" @property @@ -340,13 +340,13 @@ class ValveTappetSensor(SHCEntity, SensorEntity): """Representation of an SHC valve tappet reporting sensor.""" _attr_icon = "mdi:gauge" + _attr_translation_key = "valvetappet" _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC valve tappet reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Valvetappet" self._attr_unique_id = f"{device.serial}_valvetappet" @property diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 2b5720f0849..67462b78bec 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -36,5 +36,35 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "flow_title": "Bosch SHC: {name}" + }, + "entity": { + "sensor": { + "purity_rating": { + "name": "Purity rating" + }, + "purity": { + "name": "Purity" + }, + "valvetappet": { + "name": "Valvetappet" + }, + "air_quality": { + "name": "Air quality" + }, + "temperature_rating": { + "name": "Temperature rating" + }, + "humidity_rating": { + "name": "Humidity rating" + }, + "communication_quality": { + "name": "Communication quality" + } + }, + "switch": { + "routing": { + "name": "Routing" + } + } } } diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index 3b3b6e2ffd4..25af0628780 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -200,12 +200,12 @@ class SHCRoutingSwitch(SHCEntity, SwitchEntity): """Representation of a SHC routing switch.""" _attr_icon = "mdi:wifi" + _attr_translation_key = "routing" _attr_entity_category = EntityCategory.CONFIG def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC communication quality reporting sensor.""" super().__init__(device, parent_id, entry_id) - self._attr_name = f"{device.name} Routing" self._attr_unique_id = f"{device.serial}_routing" @property diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 418c7b8e3e3..7f53a5b5f06 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.0.0"] + "requirements": ["bthome-ble==3.1.0"] } diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index caa652715bf..06f205246c8 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -2,6 +2,9 @@ from __future__ import annotations from bthome_ble import SensorDeviceClass as BTHomeSensorDeviceClass, SensorUpdate, Units +from bthome_ble.const import ( + ExtendedSensorDeviceClass as BTHomeExtendedSensorDeviceClass, +) from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( @@ -66,7 +69,7 @@ SENSOR_DESCRIPTIONS = { ), # Count (-) (BTHomeSensorDeviceClass.COUNT, None): SensorEntityDescription( - key=f"{BTHomeSensorDeviceClass.COUNT}", + key=str(BTHomeSensorDeviceClass.COUNT), state_class=SensorStateClass.MEASUREMENT, ), # CO2 (parts per million) @@ -186,7 +189,7 @@ SENSOR_DESCRIPTIONS = { ), # Packet Id (-) (BTHomeSensorDeviceClass.PACKET_ID, None): SensorEntityDescription( - key=f"{BTHomeSensorDeviceClass.PACKET_ID}", + key=str(BTHomeSensorDeviceClass.PACKET_ID), state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -260,12 +263,16 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), + # Text (-) + (BTHomeExtendedSensorDeviceClass.TEXT, None): SensorEntityDescription( + key=str(BTHomeExtendedSensorDeviceClass.TEXT), + ), # Timestamp (datetime object) ( BTHomeSensorDeviceClass.TIMESTAMP, None, ): SensorEntityDescription( - key=f"{BTHomeSensorDeviceClass.TIMESTAMP}", + key=str(BTHomeSensorDeviceClass.TIMESTAMP), device_class=SensorDeviceClass.TIMESTAMP, state_class=SensorStateClass.MEASUREMENT, ), @@ -274,7 +281,7 @@ SENSOR_DESCRIPTIONS = { BTHomeSensorDeviceClass.UV_INDEX, None, ): SensorEntityDescription( - key=f"{BTHomeSensorDeviceClass.UV_INDEX}", + key=str(BTHomeSensorDeviceClass.UV_INDEX), state_class=SensorStateClass.MEASUREMENT, ), # Volatile organic Compounds (VOC) (µg/m3) diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index fb67f4b1ffb..b04008672ae 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import logging from typing import final @@ -110,7 +110,7 @@ class DateTimeEntity(Entity): "which is missing timezone information" ) - return value.astimezone(timezone.utc).isoformat(timespec="seconds") + return value.astimezone(UTC).isoformat(timespec="seconds") @property def native_value(self) -> datetime | None: diff --git a/homeassistant/components/demo/datetime.py b/homeassistant/components/demo/datetime.py index e7f72b66a87..63c8a5a7873 100644 --- a/homeassistant/components/demo/datetime.py +++ b/homeassistant/components/demo/datetime.py @@ -1,7 +1,7 @@ """Demo platform that offers a fake date/time entity.""" from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from homeassistant.components.datetime import DateTimeEntity from homeassistant.config_entries import ConfigEntry @@ -23,7 +23,7 @@ async def async_setup_entry( DemoDateTime( "datetime", "Date and Time", - datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + datetime(2020, 1, 1, 12, 0, 0, tzinfo=UTC), "mdi:calendar-clock", False, ), diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index ed070abf0c8..181c47aac61 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -9,6 +9,7 @@ from devolo_plc_api import Device from devolo_plc_api.device_api import ( ConnectedStationInfo, NeighborAPInfo, + UpdateFirmwareCheck, WifiGuestAccessGet, ) from devolo_plc_api.exceptions.device import ( @@ -37,6 +38,7 @@ from .const import ( DOMAIN, LONG_UPDATE_INTERVAL, NEIGHBORING_WIFI_NETWORKS, + REGULAR_FIRMWARE, SHORT_UPDATE_INTERVAL, SWITCH_GUEST_WIFI, SWITCH_LEDS, @@ -45,7 +47,9 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( # noqa: C901 + hass: HomeAssistant, entry: ConfigEntry +) -> bool: """Set up devolo Home Network from a config entry.""" hass.data.setdefault(DOMAIN, {}) zeroconf_instance = await zeroconf.async_get_async_instance(hass) @@ -66,6 +70,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = {"device": device} + async def async_update_firmware_available() -> UpdateFirmwareCheck: + """Fetch data from API endpoint.""" + assert device.device + try: + async with asyncio.timeout(10): + return await device.device.async_check_firmware_available() + except DeviceUnavailable as err: + raise UpdateFailed(err) from err + async def async_update_connected_plc_devices() -> LogicalNetwork: """Fetch data from API endpoint.""" assert device.plcnet @@ -134,6 +147,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_method=async_update_led_status, update_interval=SHORT_UPDATE_INTERVAL, ) + if device.device and "update" in device.device.features: + coordinators[REGULAR_FIRMWARE] = DataUpdateCoordinator( + hass, + _LOGGER, + name=REGULAR_FIRMWARE, + update_method=async_update_firmware_available, + update_interval=LONG_UPDATE_INTERVAL, + ) if device.device and "wifi1" in device.device.features: coordinators[CONNECTED_WIFI_CLIENTS] = DataUpdateCoordinator( hass, @@ -192,4 +213,6 @@ def platforms(device: Device) -> set[Platform]: supported_platforms.add(Platform.BINARY_SENSOR) if device.device and "wifi1" in device.device.features: supported_platforms.add(Platform.DEVICE_TRACKER) + if device.device and "update" in device.device.features: + supported_platforms.add(Platform.UPDATE) return supported_platforms diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py index 39016ac7916..53019e28a23 100644 --- a/homeassistant/components/devolo_home_network/const.py +++ b/homeassistant/components/devolo_home_network/const.py @@ -23,6 +23,7 @@ CONNECTED_WIFI_CLIENTS = "connected_wifi_clients" IDENTIFY = "identify" NEIGHBORING_WIFI_NETWORKS = "neighboring_wifi_networks" PAIRING = "pairing" +REGULAR_FIRMWARE = "regular_firmware" RESTART = "restart" START_WPS = "start_wps" SWITCH_GUEST_WIFI = "switch_guest_wifi" diff --git a/homeassistant/components/devolo_home_network/update.py b/homeassistant/components/devolo_home_network/update.py new file mode 100644 index 00000000000..21f6edd862c --- /dev/null +++ b/homeassistant/components/devolo_home_network/update.py @@ -0,0 +1,132 @@ +"""Platform for update integration.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from devolo_plc_api.device import Device +from devolo_plc_api.device_api import UpdateFirmwareCheck +from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, REGULAR_FIRMWARE +from .entity import DevoloCoordinatorEntity + + +@dataclass +class DevoloUpdateRequiredKeysMixin: + """Mixin for required keys.""" + + latest_version: Callable[[UpdateFirmwareCheck], str] + update_func: Callable[[Device], Awaitable[bool]] + + +@dataclass +class DevoloUpdateEntityDescription( + UpdateEntityDescription, DevoloUpdateRequiredKeysMixin +): + """Describes devolo update entity.""" + + +UPDATE_TYPES: dict[str, DevoloUpdateEntityDescription] = { + REGULAR_FIRMWARE: DevoloUpdateEntityDescription( + key=REGULAR_FIRMWARE, + device_class=UpdateDeviceClass.FIRMWARE, + entity_category=EntityCategory.CONFIG, + latest_version=lambda data: data.new_firmware_version.split("_")[0], + update_func=lambda device: device.device.async_start_firmware_update(), # type: ignore[union-attr] + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Get all devices and sensors and setup them via config entry.""" + device: Device = hass.data[DOMAIN][entry.entry_id]["device"] + coordinators: dict[str, DataUpdateCoordinator[Any]] = hass.data[DOMAIN][ + entry.entry_id + ]["coordinators"] + + async_add_entities( + [ + DevoloUpdateEntity( + entry, + coordinators[REGULAR_FIRMWARE], + UPDATE_TYPES[REGULAR_FIRMWARE], + device, + ) + ] + ) + + +class DevoloUpdateEntity(DevoloCoordinatorEntity, UpdateEntity): + """Representation of a devolo update.""" + + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + + entity_description: DevoloUpdateEntityDescription + + def __init__( + self, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator, + description: DevoloUpdateEntityDescription, + device: Device, + ) -> None: + """Initialize entity.""" + self.entity_description = description + super().__init__(entry, coordinator, device) + self._attr_translation_key = None + self._in_progress_old_version: str | None = None + + @property + def installed_version(self) -> str: + """Version currently in use.""" + return self.device.firmware_version + + @property + def latest_version(self) -> str: + """Latest version available for install.""" + if latest_version := self.entity_description.latest_version( + self.coordinator.data + ): + return latest_version + return self.device.firmware_version + + @property + def in_progress(self) -> bool: + """Update installation in progress.""" + return self._in_progress_old_version == self.installed_version + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Turn the entity on.""" + self._in_progress_old_version = self.installed_version + try: + await self.entity_description.update_func(self.device) + except DevicePasswordProtected as ex: + self.entry.async_start_reauth(self.hass) + raise HomeAssistantError( + f"Device {self.entry.title} require re-authenticatication to set or change the password" + ) from ex + except DeviceUnavailable as ex: + raise HomeAssistantError( + f"Device {self.entry.title} did not respond" + ) from ex diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py index cbe24088378..126d946e57d 100644 --- a/homeassistant/components/dexcom/sensor.py +++ b/homeassistant/components/dexcom/sensor.py @@ -5,6 +5,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -25,8 +26,10 @@ async def async_setup_entry( unit_of_measurement = config_entry.options[CONF_UNIT_OF_MEASUREMENT] async_add_entities( [ - DexcomGlucoseTrendSensor(coordinator, username), - DexcomGlucoseValueSensor(coordinator, username, unit_of_measurement), + DexcomGlucoseTrendSensor(coordinator, username, config_entry.entry_id), + DexcomGlucoseValueSensor( + coordinator, username, config_entry.entry_id, unit_of_measurement + ), ], False, ) @@ -35,30 +38,37 @@ async def async_setup_entry( class DexcomSensorEntity(CoordinatorEntity, SensorEntity): """Base Dexcom sensor entity.""" + _attr_has_entity_name = True + def __init__( - self, coordinator: DataUpdateCoordinator, username: str, key: str + self, coordinator: DataUpdateCoordinator, username: str, entry_id: str, key: str ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self._attr_unique_id = f"{username}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + name=username, + ) class DexcomGlucoseValueSensor(DexcomSensorEntity): """Representation of a Dexcom glucose value sensor.""" _attr_icon = GLUCOSE_VALUE_ICON + _attr_translation_key = "glucose_value" def __init__( self, coordinator: DataUpdateCoordinator, username: str, + entry_id: str, unit_of_measurement: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, username, "value") + super().__init__(coordinator, username, entry_id, "value") self._attr_native_unit_of_measurement = unit_of_measurement self._key = "mg_dl" if unit_of_measurement == MG_DL else "mmol_l" - self._attr_name = f"{DOMAIN}_{username}_glucose_value" @property def native_value(self): @@ -71,10 +81,13 @@ class DexcomGlucoseValueSensor(DexcomSensorEntity): class DexcomGlucoseTrendSensor(DexcomSensorEntity): """Representation of a Dexcom glucose trend sensor.""" - def __init__(self, coordinator: DataUpdateCoordinator, username: str) -> None: + _attr_translation_key = "glucose_trend" + + def __init__( + self, coordinator: DataUpdateCoordinator, username: str, entry_id: str + ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, username, "trend") - self._attr_name = f"{DOMAIN}_{username}_glucose_trend" + super().__init__(coordinator, username, entry_id, "trend") @property def icon(self): diff --git a/homeassistant/components/dexcom/strings.json b/homeassistant/components/dexcom/strings.json index 35d80371c12..7efc2708bcc 100644 --- a/homeassistant/components/dexcom/strings.json +++ b/homeassistant/components/dexcom/strings.json @@ -28,5 +28,15 @@ } } } + }, + "entity": { + "sensor": { + "glucose_value": { + "name": "Glucose value" + }, + "glucose_trend": { + "name": "Glucose trend" + } + } } } diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index bf5fdeb1f60..d7800a26fc8 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -7,7 +7,6 @@ from typing import Any from doorbirdpy import DoorBird import requests -import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry @@ -32,19 +31,6 @@ _LOGGER = logging.getLogger(__name__) CONF_CUSTOM_URL = "hass_url_override" - -DEVICE_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_TOKEN): cv.string, - vol.Optional(CONF_EVENTS, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_CUSTOM_URL): cv.string, - vol.Optional(CONF_NAME): cv.string, - } -) - CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -157,7 +143,9 @@ async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: @callback -def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): +def _async_import_options_from_data_if_missing( + hass: HomeAssistant, entry: ConfigEntry +) -> None: options = dict(entry.options) modified = False for importable_option in (CONF_EVENTS,): diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index a29272168d4..a4133f2da2c 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -87,7 +87,7 @@ class DoorBirdCamera(DoorBirdEntity, Camera): self._last_update = datetime.datetime.min self._attr_unique_id = f"{self._mac_addr}_{camera_id}" - async def stream_source(self): + async def stream_source(self) -> str | None: """Return the stream source.""" return self._stream_url diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index d2197de93c9..56a02f49042 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -23,7 +23,9 @@ from .util import get_mac_address_from_door_station_info _LOGGER = logging.getLogger(__name__) -def _schema_with_defaults(host=None, name=None): +def _schema_with_defaults( + host: str | None = None, name: str | None = None +) -> vol.Schema: return vol.Schema( { vol.Required(CONF_HOST, default=host): str, @@ -39,7 +41,9 @@ def _check_device(device: DoorBird) -> tuple[tuple[bool, int], dict[str, Any]]: return device.ready(), device.info() -async def validate_input(hass: core.HomeAssistant, data): +async def validate_input( + hass: core.HomeAssistant, data: dict[str, Any] +) -> dict[str, str]: """Validate the user input allows us to connect.""" device = DoorBird(data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD]) try: @@ -78,13 +82,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize the DoorBird config flow.""" - self.discovery_schema = {} + self.discovery_schema: vol.Schema | None = None - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: info, errors = await self._async_validate_or_error(user_input) if not errors: @@ -128,7 +134,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_user() - async def _async_validate_or_error(self, user_input): + async def _async_validate_or_error( + self, user_input: dict[str, Any] + ) -> tuple[dict[str, Any], dict[str, Any]]: """Validate doorbird or error.""" errors = {} info = {} @@ -159,7 +167,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle options flow.""" if user_input is not None: events = [event.strip() for event in user_input[CONF_EVENTS].split(",")] diff --git a/homeassistant/components/doorbird/device.py b/homeassistant/components/doorbird/device.py index 3a50700fa37..767a80a7857 100644 --- a/homeassistant/components/doorbird/device.py +++ b/homeassistant/components/doorbird/device.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast from doorbirdpy import DoorBird @@ -131,7 +131,7 @@ class ConfiguredDoorBird: for fav_id in favs["http"]: if favs["http"][fav_id]["value"] == url: - return fav_id + return cast(str, fav_id) return None diff --git a/homeassistant/components/doorbird/logbook.py b/homeassistant/components/doorbird/logbook.py index 7c8e3cd3c51..84497a312ae 100644 --- a/homeassistant/components/doorbird/logbook.py +++ b/homeassistant/components/doorbird/logbook.py @@ -1,6 +1,8 @@ """Describe logbook events.""" from __future__ import annotations +from collections.abc import Callable + from homeassistant.components.logbook import ( LOGBOOK_ENTRY_ENTITY_ID, LOGBOOK_ENTRY_MESSAGE, @@ -14,11 +16,16 @@ from .models import DoorBirdData @callback -def async_describe_events(hass: HomeAssistant, async_describe_event): +def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[ + [str, str, Callable[[Event], dict[str, str | None]]], None + ], +) -> None: """Describe logbook events.""" @callback - def async_describe_logbook_event(event: Event): + def async_describe_logbook_event(event: Event) -> dict[str, str | None]: """Describe a logbook event.""" return { LOGBOOK_ENTRY_NAME: "Doorbird", diff --git a/homeassistant/components/doorbird/view.py b/homeassistant/components/doorbird/view.py index fca72d36fc1..396db79bf4c 100644 --- a/homeassistant/components/doorbird/view.py +++ b/homeassistant/components/doorbird/view.py @@ -2,7 +2,6 @@ from __future__ import annotations from http import HTTPStatus -import logging from aiohttp import web @@ -13,8 +12,6 @@ from .const import API_URL, DOMAIN from .device import async_reset_device_favorites from .util import get_door_station_by_token -_LOGGER = logging.getLogger(__name__) - class DoorBirdRequestView(HomeAssistantView): """Provide a page for the device to call.""" diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index 7f06a032128..2473c2d9b2f 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -6,6 +6,7 @@ from pyenphase import Envoy from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.httpx_client import get_async_client @@ -24,6 +25,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not entry.unique_id: hass.config_entries.async_update_entry(entry, unique_id=envoy.serial_number) + if entry.unique_id != envoy.serial_number: + # If the serial number of the device does not match the unique_id + # of the config entry, it likely means the DHCP lease has expired + # and the device has been assigned a new IP address. We need to + # wait for the next discovery to find the device at its new address + # and update the config entry so we do not mix up devices. + raise ConfigEntryNotReady( + f"Unexpected device found at {host}; expected {entry.unique_id}, " + f"found {envoy.serial_number}" + ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index 009b5d18338..7060943deb8 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -5,7 +5,6 @@ from collections.abc import Callable from dataclasses import dataclass from pyenphase import EnvoyEncharge, EnvoyEnpower -from pyenphase.models.dry_contacts import DryContactStatus from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -53,12 +52,6 @@ ENCHARGE_SENSORS = ( ), ) -RELAY_STATUS_SENSOR = BinarySensorEntityDescription( - key="relay_status", - translation_key="relay", - icon="mdi:power-plug", -) - @dataclass class EnvoyEnpowerRequiredKeysMixin: @@ -114,11 +107,6 @@ async def async_setup_entry( for description in ENPOWER_SENSORS ) - if envoy_data.dry_contact_status: - entities.extend( - EnvoyRelayBinarySensorEntity(coordinator, RELAY_STATUS_SENSOR, relay) - for relay in envoy_data.dry_contact_status - ) async_add_entities(entities) @@ -190,34 +178,3 @@ class EnvoyEnpowerBinarySensorEntity(EnvoyBaseBinarySensorEntity): enpower = self.data.enpower assert enpower is not None return self.entity_description.value_fn(enpower) - - -class EnvoyRelayBinarySensorEntity(EnvoyBaseBinarySensorEntity): - """Defines an Enpower dry contact binary_sensor entity.""" - - def __init__( - self, - coordinator: EnphaseUpdateCoordinator, - description: BinarySensorEntityDescription, - relay_id: str, - ) -> None: - """Init the Enpower base entity.""" - super().__init__(coordinator, description) - enpower = self.data.enpower - assert enpower is not None - self._relay_id = relay_id - self._attr_unique_id = f"{enpower.serial_number}_relay_{relay_id}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, relay_id)}, - manufacturer="Enphase", - model="Dry contact relay", - name=self.data.dry_contact_settings[relay_id].load_name, - sw_version=str(enpower.firmware_version), - via_device=(DOMAIN, enpower.serial_number), - ) - - @property - def is_on(self) -> bool: - """Return the state of the Enpower binary_sensor.""" - relay = self.data.dry_contact_status[self._relay_id] - return relay.status == DryContactStatus.CLOSED diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 62f7c73ef76..540c121bb17 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.6.0"], + "requirements": ["pyenphase==1.8.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 477da2b3211..ae0ac31413c 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -31,9 +31,6 @@ }, "grid_status": { "name": "Grid status" - }, - "relay": { - "name": "Relay status" } }, "number": { diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index e0f211a1019..fb9e14406ac 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -6,7 +6,8 @@ from dataclasses import dataclass import logging from typing import Any -from pyenphase import Envoy, EnvoyEnpower +from pyenphase import Envoy, EnvoyDryContactStatus, EnvoyEnpower +from pyenphase.models.dry_contacts import DryContactStatus from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry @@ -37,6 +38,22 @@ class EnvoyEnpowerSwitchEntityDescription( """Describes an Envoy Enpower switch entity.""" +@dataclass +class EnvoyDryContactRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyDryContactStatus], bool] + turn_on_fn: Callable[[Envoy, str], Coroutine[Any, Any, dict[str, Any]]] + turn_off_fn: Callable[[Envoy, str], Coroutine[Any, Any, dict[str, Any]]] + + +@dataclass +class EnvoyDryContactSwitchEntityDescription( + SwitchEntityDescription, EnvoyDryContactRequiredKeysMixin +): + """Describes an Envoy Enpower dry contact switch entity.""" + + ENPOWER_GRID_SWITCH = EnvoyEnpowerSwitchEntityDescription( key="mains_admin_state", translation_key="grid_enabled", @@ -45,6 +62,13 @@ ENPOWER_GRID_SWITCH = EnvoyEnpowerSwitchEntityDescription( turn_off_fn=lambda envoy: envoy.go_off_grid(), ) +RELAY_STATE_SWITCH = EnvoyDryContactSwitchEntityDescription( + key="relay_status", + value_fn=lambda dry_contact: dry_contact.status == DryContactStatus.CLOSED, + turn_on_fn=lambda envoy, id: envoy.close_dry_contact(id), + turn_off_fn=lambda envoy, id: envoy.open_dry_contact(id), +) + async def async_setup_entry( hass: HomeAssistant, @@ -64,6 +88,13 @@ async def async_setup_entry( ) ] ) + + if envoy_data.dry_contact_status: + entities.extend( + EnvoyDryContactSwitchEntity(coordinator, RELAY_STATE_SWITCH, relay) + for relay in envoy_data.dry_contact_status + ) + async_add_entities(entities) @@ -109,3 +140,51 @@ class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity): """Turn off the Enpower switch.""" await self.entity_description.turn_off_fn(self.envoy) await self.coordinator.async_request_refresh() + + +class EnvoyDryContactSwitchEntity(EnvoyBaseEntity, SwitchEntity): + """Representation of an Enphase dry contact switch entity.""" + + entity_description: EnvoyDryContactSwitchEntityDescription + _attr_name = None + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyDryContactSwitchEntityDescription, + relay_id: str, + ) -> None: + """Initialize the Enphase dry contact switch entity.""" + super().__init__(coordinator, description) + self.envoy = coordinator.envoy + enpower = self.data.enpower + assert enpower is not None + self.relay_id = relay_id + serial_number = enpower.serial_number + self._attr_unique_id = f"{serial_number}_relay_{relay_id}_{description.key}" + relay = self.data.dry_contact_settings[relay_id] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, relay_id)}, + manufacturer="Enphase", + model="Dry contact relay", + name=relay.load_name, + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, enpower.serial_number), + ) + + @property + def is_on(self) -> bool: + """Return the state of the dry contact.""" + relay = self.data.dry_contact_status[self.relay_id] + assert relay is not None + return self.entity_description.value_fn(relay) + + async def async_turn_on(self): + """Turn on (close) the dry contact.""" + if await self.entity_description.turn_on_fn(self.envoy, self.relay_id): + self.async_write_ha_state() + + async def async_turn_off(self): + """Turn off (open) the dry contact.""" + if await self.entity_description.turn_off_fn(self.envoy, self.relay_id): + self.async_write_ha_state() diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index bdc300dc9a3..67cb2df5473 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -22,8 +22,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, DOMAIN as WEATHER_DOMAIN, + CoordinatorWeatherEntity, Forecast, - WeatherEntity, WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -33,10 +33,9 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from . import device_info @@ -87,7 +86,7 @@ def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> st return f"{config_entry_unique_id}{'-hourly' if hourly else '-daily'}" -class ECWeather(CoordinatorEntity, WeatherEntity): +class ECWeather(CoordinatorWeatherEntity): """Representation of a weather condition.""" _attr_has_entity_name = True @@ -112,15 +111,6 @@ class ECWeather(CoordinatorEntity, WeatherEntity): self._hourly = hourly self._attr_device_info = device_info(coordinator.config_entry) - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - super()._handle_coordinator_update() - assert self.platform.config_entry - self.platform.config_entry.async_create_task( - self.hass, self.async_update_listeners(("daily", "hourly")) - ) - @property def native_temperature(self): """Return the temperature.""" diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 35939dc9b1f..ee0d2371a56 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -18,7 +18,6 @@ from aioesphomeapi import ( UserServiceArgType, VoiceAssistantEventType, ) -from aioesphomeapi.model import VoiceAssistantCommandFlag from awesomeversion import AwesomeVersion import voluptuous as vol @@ -320,7 +319,7 @@ class ESPHomeManager: self.voice_assistant_udp_server = None async def _handle_pipeline_start( - self, conversation_id: str, use_vad: int + self, conversation_id: str, flags: int ) -> int | None: """Start a voice assistant pipeline.""" if self.voice_assistant_udp_server is not None: @@ -340,12 +339,10 @@ class ESPHomeManager: voice_assistant_udp_server.run_pipeline( device_id=self.device_id, conversation_id=conversation_id or None, - use_vad=VoiceAssistantCommandFlag(use_vad) - == VoiceAssistantCommandFlag.USE_VAD, + flags=flags, ), "esphome.voice_assistant_udp_server.run_pipeline", ) - self.entry_data.async_set_assist_pipeline_state(True) return port @@ -357,51 +354,93 @@ class ESPHomeManager: async def on_connect(self) -> None: """Subscribe to states and list entities on successful API login.""" entry = self.entry + unique_id = entry.unique_id entry_data = self.entry_data reconnect_logic = self.reconnect_logic + assert reconnect_logic is not None, "Reconnect logic must be set" hass = self.hass cli = self.cli + stored_device_name = entry.data.get(CONF_DEVICE_NAME) + unique_id_is_mac_address = unique_id and ":" in unique_id try: device_info = await cli.device_info() + except APIConnectionError as err: + _LOGGER.warning("Error getting device info for %s: %s", self.host, err) + # Re-connection logic will trigger after this + await cli.disconnect() + return - # Migrate config entry to new unique ID if necessary - # This was changed in 2023.1 - if entry.unique_id != format_mac(device_info.mac_address): - hass.config_entries.async_update_entry( - entry, unique_id=format_mac(device_info.mac_address) + device_mac = format_mac(device_info.mac_address) + mac_address_matches = unique_id == device_mac + # + # Migrate config entry to new unique ID if the current + # unique id is not a mac address. + # + # This was changed in 2023.1 + if not mac_address_matches and not unique_id_is_mac_address: + hass.config_entries.async_update_entry(entry, unique_id=device_mac) + + if not mac_address_matches and unique_id_is_mac_address: + # If the unique id is a mac address + # and does not match we have the wrong device and we need + # to abort the connection. This can happen if the DHCP + # server changes the IP address of the device and we end up + # connecting to the wrong device. + _LOGGER.error( + "Unexpected device found at %s; " + "expected `%s` with mac address `%s`, " + "found `%s` with mac address `%s`", + self.host, + stored_device_name, + unique_id, + device_info.name, + device_mac, + ) + await cli.disconnect() + await reconnect_logic.stop() + # We don't want to reconnect to the wrong device + # so we stop the reconnect logic and disconnect + # the client. When discovery finds the new IP address + # for the device, the config entry will be updated + # and we will connect to the correct device when + # the config entry gets reloaded by the discovery + # flow. + return + + # Make sure we have the correct device name stored + # so we can map the device to ESPHome Dashboard config + # If we got here, we know the mac address matches or we + # did a migration to the mac address so we can update + # the device name. + if stored_device_name != device_info.name: + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_DEVICE_NAME: device_info.name} + ) + + entry_data.device_info = device_info + assert cli.api_version is not None + entry_data.api_version = cli.api_version + entry_data.available = True + # Reset expected disconnect flag on successful reconnect + # as it will be flipped to False on unexpected disconnect. + # + # We use this to determine if a deep sleep device should + # be marked as unavailable or not. + entry_data.expected_disconnect = True + if device_info.name: + reconnect_logic.name = device_info.name + + if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version): + entry_data.disconnect_callbacks.append( + await async_connect_scanner( + hass, entry, cli, entry_data, self.domain_data.bluetooth_cache ) + ) - # Make sure we have the correct device name stored - # so we can map the device to ESPHome Dashboard config - if entry.data.get(CONF_DEVICE_NAME) != device_info.name: - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_DEVICE_NAME: device_info.name} - ) - - entry_data.device_info = device_info - assert cli.api_version is not None - entry_data.api_version = cli.api_version - entry_data.available = True - # Reset expected disconnect flag on successful reconnect - # as it will be flipped to False on unexpected disconnect. - # - # We use this to determine if a deep sleep device should - # be marked as unavailable or not. - entry_data.expected_disconnect = True - if entry_data.device_info.name: - assert reconnect_logic is not None, "Reconnect logic must be set" - reconnect_logic.name = entry_data.device_info.name - - if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version): - entry_data.disconnect_callbacks.append( - await async_connect_scanner( - hass, entry, cli, entry_data, self.domain_data.bluetooth_cache - ) - ) - - self.device_id = _async_setup_device_registry(hass, entry, entry_data) - entry_data.async_update_device_state(hass) + self.device_id = _async_setup_device_registry(hass, entry, entry_data) + entry_data.async_update_device_state(hass) + try: entity_infos, services = await cli.list_entities_services() await entry_data.async_update_static_infos(hass, entry, entity_infos) await _setup_services(hass, entry_data, services) diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index f870f9e42f7..c501d756e54 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -2,26 +2,23 @@ from __future__ import annotations import asyncio -from collections import deque -from collections.abc import AsyncIterable, Callable, MutableSequence, Sequence +from collections.abc import AsyncIterable, Callable import logging import socket from typing import cast -from aioesphomeapi import VoiceAssistantEventType +from aioesphomeapi import VoiceAssistantCommandFlag, VoiceAssistantEventType from homeassistant.components import stt, tts from homeassistant.components.assist_pipeline import ( PipelineEvent, PipelineEventType, PipelineNotFound, + PipelineStage, async_pipeline_from_audio_stream, select as pipeline_select, ) -from homeassistant.components.assist_pipeline.vad import ( - VadSensitivity, - VoiceCommandSegmenter, -) +from homeassistant.components.assist_pipeline.error import WakeWordDetectionError from homeassistant.components.media_player import async_process_play_media_url from homeassistant.core import Context, HomeAssistant, callback @@ -47,6 +44,8 @@ _VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[ VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: PipelineEventType.INTENT_END, VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: PipelineEventType.TTS_START, VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: PipelineEventType.TTS_END, + VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_START: PipelineEventType.WAKE_WORD_START, + VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: PipelineEventType.WAKE_WORD_END, } ) @@ -72,6 +71,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): self.hass = hass assert entry_data.device_info is not None + self.entry_data = entry_data self.device_info = entry_data.device_info self.queue: asyncio.Queue[bytes] = asyncio.Queue() @@ -159,7 +159,9 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): data_to_send = None error = False - if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: + if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: + self.entry_data.async_set_assist_pipeline_state(True) + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: assert event.data is not None data_to_send = {"text": event.data["stt_output"]["text"]} elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: @@ -183,121 +185,33 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): ) else: self._tts_done.set() + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: + assert event.data is not None + if not event.data["wake_word_output"]: + event_type = VoiceAssistantEventType.VOICE_ASSISTANT_ERROR + data_to_send = { + "code": "no_wake_word", + "message": "No wake word detected", + } + error = True elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: assert event.data is not None data_to_send = { "code": event.data["code"], "message": event.data["message"], } - self._tts_done.set() error = True self.handle_event(event_type, data_to_send) if error: + self._tts_done.set() self.handle_finished() - async def _wait_for_speech( - self, - segmenter: VoiceCommandSegmenter, - chunk_buffer: MutableSequence[bytes], - ) -> bool: - """Buffer audio chunks until speech is detected. - - Raises asyncio.TimeoutError if no audio data is retrievable from the queue (device stops sending packets / networking issue). - - Returns True if speech was detected - Returns False if the connection was stopped gracefully (b"" put onto the queue). - """ - # Timeout if no audio comes in for a while. - async with asyncio.timeout(self.audio_timeout): - chunk = await self.queue.get() - - while chunk: - segmenter.process(chunk) - # Buffer the data we have taken from the queue - chunk_buffer.append(chunk) - if segmenter.in_command: - return True - - async with asyncio.timeout(self.audio_timeout): - chunk = await self.queue.get() - - # If chunk is falsey, `stop()` was called - return False - - async def _segment_audio( - self, - segmenter: VoiceCommandSegmenter, - chunk_buffer: Sequence[bytes], - ) -> AsyncIterable[bytes]: - """Yield audio chunks until voice command has finished. - - Raises asyncio.TimeoutError if no audio data is retrievable from the queue. - """ - # Buffered chunks first - for buffered_chunk in chunk_buffer: - yield buffered_chunk - - # Timeout if no audio comes in for a while. - async with asyncio.timeout(self.audio_timeout): - chunk = await self.queue.get() - - while chunk: - if not segmenter.process(chunk): - # Voice command is finished - break - - yield chunk - - async with asyncio.timeout(self.audio_timeout): - chunk = await self.queue.get() - - async def _iterate_packets_with_vad( - self, pipeline_timeout: float, silence_seconds: float - ) -> Callable[[], AsyncIterable[bytes]] | None: - segmenter = VoiceCommandSegmenter(silence_seconds=silence_seconds) - chunk_buffer: deque[bytes] = deque(maxlen=100) - try: - async with asyncio.timeout(pipeline_timeout): - speech_detected = await self._wait_for_speech(segmenter, chunk_buffer) - if not speech_detected: - _LOGGER.debug( - "Device stopped sending audio before speech was detected" - ) - self.handle_finished() - return None - except asyncio.TimeoutError: - self.handle_event( - VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, - { - "code": "speech-timeout", - "message": "Timed out waiting for speech", - }, - ) - self.handle_finished() - return None - - async def _stream_packets() -> AsyncIterable[bytes]: - try: - async for chunk in self._segment_audio(segmenter, chunk_buffer): - yield chunk - except asyncio.TimeoutError: - self.handle_event( - VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, - { - "code": "speech-timeout", - "message": "No speech detected", - }, - ) - self.handle_finished() - - return _stream_packets - async def run_pipeline( self, device_id: str, conversation_id: str | None, - use_vad: bool = False, + flags: int = 0, pipeline_timeout: float = 30.0, ) -> None: """Run the Voice Assistant pipeline.""" @@ -306,24 +220,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): "raw" if self.device_info.voice_assistant_version >= 2 else "mp3" ) - if use_vad: - stt_stream = await self._iterate_packets_with_vad( - pipeline_timeout, - silence_seconds=VadSensitivity.to_seconds( - pipeline_select.get_vad_sensitivity( - self.hass, - DOMAIN, - self.device_info.mac_address, - ) - ), - ) - # Error or timeout occurred and was handled already - if stt_stream is None: - return - else: - stt_stream = self._iterate_packets - _LOGGER.debug("Starting pipeline") + if flags & VoiceAssistantCommandFlag.USE_WAKE_WORD: + start_stage = PipelineStage.WAKE_WORD + else: + start_stage = PipelineStage.STT try: async with asyncio.timeout(pipeline_timeout): await async_pipeline_from_audio_stream( @@ -338,13 +239,14 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ), - stt_stream=stt_stream(), + stt_stream=self._iterate_packets(), pipeline_id=pipeline_select.get_chosen_pipeline( self.hass, DOMAIN, self.device_info.mac_address ), conversation_id=conversation_id, device_id=device_id, tts_audio_output=tts_audio_output, + start_stage=start_stage, ) # Block until TTS is done sending @@ -356,11 +258,23 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, { "code": "pipeline not found", - "message": "Selected pipeline timeout", + "message": "Selected pipeline not found", }, ) _LOGGER.warning("Pipeline not found") + except WakeWordDetectionError as e: + self.handle_event( + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, + { + "code": e.code, + "message": e.message, + }, + ) + _LOGGER.warning("No Wake word provider found") except asyncio.TimeoutError: + if self.stopped: + # The pipeline was stopped gracefully + return self.handle_event( VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, { @@ -397,7 +311,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): self.transport.sendto(chunk, self.remote_addr) await asyncio.sleep( - samples_in_chunk / stt.AudioSampleRates.SAMPLERATE_16000 * 0.99 + samples_in_chunk / stt.AudioSampleRates.SAMPLERATE_16000 * 0.9 ) sample_offset += samples_in_chunk diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 6be0e3c219f..82312b8897c 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -1,22 +1,23 @@ """Support for RSS/Atom feeds.""" from __future__ import annotations +from calendar import timegm from datetime import datetime, timedelta from logging import getLogger -from os.path import exists +import os import pickle -from threading import Lock -from time import struct_time -from typing import cast +from time import gmtime, struct_time import feedparser import voluptuous as vol from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType +from homeassistant.util.dt import utc_from_timestamp _LOGGER = getLogger(__name__) @@ -25,10 +26,12 @@ CONF_MAX_ENTRIES = "max_entries" DEFAULT_MAX_ENTRIES = 20 DEFAULT_SCAN_INTERVAL = timedelta(hours=1) +DELAY_SAVE = 30 DOMAIN = "feedreader" EVENT_FEEDREADER = "feedreader" +STORAGE_VERSION = 1 CONFIG_SCHEMA = vol.Schema( { @@ -46,17 +49,25 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Feedreader component.""" urls: list[str] = config[DOMAIN][CONF_URLS] + if not urls: + return False + scan_interval: timedelta = config[DOMAIN][CONF_SCAN_INTERVAL] max_entries: int = config[DOMAIN][CONF_MAX_ENTRIES] - data_file = hass.config.path(f"{DOMAIN}.pickle") - storage = StoredData(data_file) + old_data_file = hass.config.path(f"{DOMAIN}.pickle") + storage = StoredData(hass, old_data_file) + await storage.async_setup() feeds = [ - FeedManager(url, scan_interval, max_entries, hass, storage) for url in urls + FeedManager(hass, url, scan_interval, max_entries, storage) for url in urls ] - return len(feeds) > 0 + + for feed in feeds: + feed.async_setup() + + return True class FeedManager: @@ -64,50 +75,47 @@ class FeedManager: def __init__( self, + hass: HomeAssistant, url: str, scan_interval: timedelta, max_entries: int, - hass: HomeAssistant, storage: StoredData, ) -> None: """Initialize the FeedManager object, poll as per scan interval.""" + self._hass = hass self._url = url self._scan_interval = scan_interval self._max_entries = max_entries self._feed: feedparser.FeedParserDict | None = None - self._hass = hass self._firstrun = True self._storage = storage self._last_entry_timestamp: struct_time | None = None - self._last_update_successful = False self._has_published_parsed = False self._has_updated_parsed = False self._event_type = EVENT_FEEDREADER self._feed_id = url - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, lambda _: self._update()) - self._init_regular_updates(hass) + + @callback + def async_setup(self) -> None: + """Set up the feed manager.""" + self._hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, self._async_update) + async_track_time_interval( + self._hass, self._async_update, self._scan_interval, cancel_on_shutdown=True + ) def _log_no_entries(self) -> None: """Send no entries log at debug level.""" _LOGGER.debug("No new entries to be published in feed %s", self._url) - def _init_regular_updates(self, hass: HomeAssistant) -> None: - """Schedule regular updates at the top of the clock.""" - track_time_interval( - hass, - lambda now: self._update(), - self._scan_interval, - cancel_on_shutdown=True, - ) - - @property - def last_update_successful(self) -> bool: - """Return True if the last feed update was successful.""" - return self._last_update_successful - - def _update(self) -> None: + async def _async_update(self, _: datetime | Event) -> None: """Update the feed and publish new entries to the event bus.""" - _LOGGER.info("Fetching new data from feed %s", self._url) + last_entry_timestamp = await self._hass.async_add_executor_job(self._update) + if last_entry_timestamp: + self._storage.async_put_timestamp(self._feed_id, last_entry_timestamp) + + def _update(self) -> struct_time | None: + """Update the feed and publish new entries to the event bus.""" + _LOGGER.debug("Fetching new data from feed %s", self._url) self._feed: feedparser.FeedParserDict = feedparser.parse( # type: ignore[no-redef] self._url, etag=None if not self._feed else self._feed.get("etag"), @@ -115,38 +123,41 @@ class FeedManager: ) if not self._feed: _LOGGER.error("Error fetching feed data from %s", self._url) - self._last_update_successful = False - else: - # The 'bozo' flag really only indicates that there was an issue - # during the initial parsing of the XML, but it doesn't indicate - # whether this is an unrecoverable error. In this case the - # feedparser lib is trying a less strict parsing approach. - # If an error is detected here, log warning message but continue - # processing the feed entries if present. - if self._feed.bozo != 0: - _LOGGER.warning( - "Possible issue parsing feed %s: %s", - self._url, - self._feed.bozo_exception, - ) - # Using etag and modified, if there's no new data available, - # the entries list will be empty - if self._feed.entries: - _LOGGER.debug( - "%s entri(es) available in feed %s", - len(self._feed.entries), - self._url, - ) - self._filter_entries() - self._publish_new_entries() - if self._has_published_parsed or self._has_updated_parsed: - self._storage.put_timestamp( - self._feed_id, cast(struct_time, self._last_entry_timestamp) - ) - else: - self._log_no_entries() - self._last_update_successful = True - _LOGGER.info("Fetch from feed %s completed", self._url) + return None + # The 'bozo' flag really only indicates that there was an issue + # during the initial parsing of the XML, but it doesn't indicate + # whether this is an unrecoverable error. In this case the + # feedparser lib is trying a less strict parsing approach. + # If an error is detected here, log warning message but continue + # processing the feed entries if present. + if self._feed.bozo != 0: + _LOGGER.warning( + "Possible issue parsing feed %s: %s", + self._url, + self._feed.bozo_exception, + ) + # Using etag and modified, if there's no new data available, + # the entries list will be empty + _LOGGER.debug( + "%s entri(es) available in feed %s", + len(self._feed.entries), + self._url, + ) + if not self._feed.entries: + self._log_no_entries() + return None + + self._filter_entries() + self._publish_new_entries() + + _LOGGER.debug("Fetch from feed %s completed", self._url) + + if ( + self._has_published_parsed or self._has_updated_parsed + ) and self._last_entry_timestamp: + return self._last_entry_timestamp + + return None def _filter_entries(self) -> None: """Filter the entries provided and return the ones to keep.""" @@ -219,47 +230,62 @@ class FeedManager: class StoredData: - """Abstraction over pickle data storage.""" + """Represent a data storage.""" - def __init__(self, data_file: str) -> None: - """Initialize pickle data storage.""" - self._data_file = data_file - self._lock = Lock() - self._cache_outdated = True + def __init__(self, hass: HomeAssistant, legacy_data_file: str) -> None: + """Initialize data storage.""" + self._legacy_data_file = legacy_data_file self._data: dict[str, struct_time] = {} - self._fetch_data() + self._hass = hass + self._store: Store[dict[str, str]] = Store(hass, STORAGE_VERSION, DOMAIN) - def _fetch_data(self) -> None: - """Fetch data stored into pickle file.""" - if self._cache_outdated and exists(self._data_file): - try: - _LOGGER.debug("Fetching data from file %s", self._data_file) - with self._lock, open(self._data_file, "rb") as myfile: - self._data = pickle.load(myfile) or {} - self._cache_outdated = False - except Exception: # pylint: disable=broad-except - _LOGGER.error( - "Error loading data from pickled file %s", self._data_file - ) + async def async_setup(self) -> None: + """Set up storage.""" + if not os.path.exists(self._store.path): + # Remove the legacy store loading after deprecation period. + data = await self._hass.async_add_executor_job(self._legacy_fetch_data) + else: + if (store_data := await self._store.async_load()) is None: + return + # Make sure that dst is set to 0, by using gmtime() on the timestamp. + data = { + feed_id: gmtime(datetime.fromisoformat(timestamp_string).timestamp()) + for feed_id, timestamp_string in store_data.items() + } + + self._data = data + + def _legacy_fetch_data(self) -> dict[str, struct_time]: + """Fetch data stored in pickle file.""" + _LOGGER.debug("Fetching data from legacy file %s", self._legacy_data_file) + try: + with open(self._legacy_data_file, "rb") as myfile: + return pickle.load(myfile) or {} + except FileNotFoundError: + pass + except (OSError, pickle.PickleError) as err: + _LOGGER.error( + "Error loading data from pickled file %s: %s", + self._legacy_data_file, + err, + ) + + return {} def get_timestamp(self, feed_id: str) -> struct_time | None: - """Return stored timestamp for given feed id (usually the url).""" - self._fetch_data() + """Return stored timestamp for given feed id.""" return self._data.get(feed_id) - def put_timestamp(self, feed_id: str, timestamp: struct_time) -> None: - """Update timestamp for given feed id (usually the url).""" - self._fetch_data() - with self._lock, open(self._data_file, "wb") as myfile: - self._data.update({feed_id: timestamp}) - _LOGGER.debug( - "Overwriting feed %s timestamp in storage file %s: %s", - feed_id, - self._data_file, - timestamp, - ) - try: - pickle.dump(self._data, myfile) - except Exception: # pylint: disable=broad-except - _LOGGER.error("Error saving pickled data to %s", self._data_file) - self._cache_outdated = True + @callback + def async_put_timestamp(self, feed_id: str, timestamp: struct_time) -> None: + """Update timestamp for given feed id.""" + self._data[feed_id] = timestamp + self._store.async_delay_save(self._async_save_data, DELAY_SAVE) + + @callback + def _async_save_data(self) -> dict[str, str]: + """Save feed data to storage.""" + return { + feed_id: utc_from_timestamp(timegm(struct_utc)).isoformat() + for feed_id, struct_utc in self._data.items() + } diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index e10d9651c3b..c6d4236c219 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -5,12 +5,38 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import ( + CONF_DAMPING, + CONF_DAMPING_EVENING, + CONF_DAMPING_MORNING, + CONF_MODULES_POWER, + DOMAIN, +) from .coordinator import ForecastSolarDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old config entry.""" + + if entry.version == 1: + new_options = entry.options.copy() + new_options |= { + CONF_MODULES_POWER: new_options.pop("modules power"), + CONF_DAMPING_MORNING: new_options.get(CONF_DAMPING, 0.0), + CONF_DAMPING_EVENING: new_options.pop(CONF_DAMPING, 0.0), + } + + entry.version = 2 + + hass.config_entries.async_update_entry( + entry, data=entry.data, options=new_options + ) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Forecast.Solar from a config entry.""" coordinator = ForecastSolarDataUpdateCoordinator(hass, entry) diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py index e74585da35b..47e1afaec7b 100644 --- a/homeassistant/components/forecast_solar/config_flow.py +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -14,7 +14,8 @@ from homeassistant.helpers import config_validation as cv from .const import ( CONF_AZIMUTH, - CONF_DAMPING, + CONF_DAMPING_EVENING, + CONF_DAMPING_MORNING, CONF_DECLINATION, CONF_INVERTER_SIZE, CONF_MODULES_POWER, @@ -27,7 +28,7 @@ RE_API_KEY = re.compile(r"^[a-zA-Z0-9]{16}$") class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Forecast.Solar.""" - VERSION = 1 + VERSION = 2 @staticmethod @callback @@ -127,8 +128,16 @@ class ForecastSolarOptionFlowHandler(OptionsFlow): default=self.config_entry.options[CONF_MODULES_POWER], ): vol.Coerce(int), vol.Optional( - CONF_DAMPING, - default=self.config_entry.options.get(CONF_DAMPING, 0.0), + CONF_DAMPING_MORNING, + default=self.config_entry.options.get( + CONF_DAMPING_MORNING, 0.0 + ), + ): vol.Coerce(float), + vol.Optional( + CONF_DAMPING_EVENING, + default=self.config_entry.options.get( + CONF_DAMPING_EVENING, 0.0 + ), ): vol.Coerce(float), vol.Optional( CONF_INVERTER_SIZE, diff --git a/homeassistant/components/forecast_solar/const.py b/homeassistant/components/forecast_solar/const.py index e566733413b..24273f32405 100644 --- a/homeassistant/components/forecast_solar/const.py +++ b/homeassistant/components/forecast_solar/const.py @@ -8,6 +8,8 @@ LOGGER = logging.getLogger(__package__) CONF_DECLINATION = "declination" CONF_AZIMUTH = "azimuth" -CONF_MODULES_POWER = "modules power" +CONF_MODULES_POWER = "modules_power" CONF_DAMPING = "damping" +CONF_DAMPING_MORNING = "damping_morning" +CONF_DAMPING_EVENING = "damping_evening" CONF_INVERTER_SIZE = "inverter_size" diff --git a/homeassistant/components/forecast_solar/coordinator.py b/homeassistant/components/forecast_solar/coordinator.py index 273d3a49a2f..2ef6912e5a2 100644 --- a/homeassistant/components/forecast_solar/coordinator.py +++ b/homeassistant/components/forecast_solar/coordinator.py @@ -13,7 +13,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( CONF_AZIMUTH, - CONF_DAMPING, + CONF_DAMPING_EVENING, + CONF_DAMPING_MORNING, CONF_DECLINATION, CONF_INVERTER_SIZE, CONF_MODULES_POWER, @@ -48,7 +49,8 @@ class ForecastSolarDataUpdateCoordinator(DataUpdateCoordinator[Estimate]): declination=entry.options[CONF_DECLINATION], azimuth=(entry.options[CONF_AZIMUTH] - 180), kwp=(entry.options[CONF_MODULES_POWER] / 1000), - damping=entry.options.get(CONF_DAMPING, 0), + damping_morning=entry.options.get(CONF_DAMPING_MORNING, 0.0), + damping_evening=entry.options.get(CONF_DAMPING_EVENING, 0.0), inverter=inverter_size, ) diff --git a/homeassistant/components/forecast_solar/strings.json b/homeassistant/components/forecast_solar/strings.json index 43e6fca4ada..1413dba23d4 100644 --- a/homeassistant/components/forecast_solar/strings.json +++ b/homeassistant/components/forecast_solar/strings.json @@ -24,10 +24,11 @@ "data": { "api_key": "Forecast.Solar API Key (optional)", "azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]", - "damping": "Damping factor: adjusts the results in the morning and evening", + "damping_morning": "Damping factor: adjusts the results in the morning", + "damping_evening": "Damping factor: adjusts the results in the evening", "inverter_size": "Inverter size (Watt)", "declination": "[%key:component::forecast_solar::config::step::user::data::declination%]", - "modules power": "[%key:component::forecast_solar::config::step::user::data::modules_power%]" + "modules_power": "[%key:component::forecast_solar::config::step::user::data::modules_power%]" } } } diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index ae28fd8d111..384aea4c5fa 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -11,9 +11,17 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_RTSP_PORT, CONF_STREAM, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET +from .const import ( + CONF_RTSP_PORT, + CONF_STREAM, + DOMAIN, + LOGGER, + SERVICE_PTZ, + SERVICE_PTZ_PRESET, +) DIR_UP = "up" DIR_DOWN = "down" @@ -94,12 +102,14 @@ async def async_setup_entry( class HassFoscamCamera(Camera): """An implementation of a Foscam IP camera.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, camera: FoscamCamera, config_entry: ConfigEntry) -> None: """Initialize a Foscam camera.""" super().__init__() self._foscam_session = camera - self._attr_name = config_entry.title self._username = config_entry.data[CONF_USERNAME] self._password = config_entry.data[CONF_PASSWORD] self._stream = config_entry.data[CONF_STREAM] @@ -107,6 +117,10 @@ class HassFoscamCamera(Camera): self._rtsp_port = config_entry.data[CONF_RTSP_PORT] if self._rtsp_port: self._attr_supported_features = CameraEntityFeature.STREAM + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="Foscam", + ) async def async_added_to_hass(self) -> None: """Handle entity addition to hass.""" diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index af641b5430c..2260e69cc3c 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -1,5 +1,6 @@ """Config flow to configure the Freebox integration.""" import logging +from typing import Any from freebox_api.exceptions import AuthorizationError, HttpRequestError import voluptuous as vol @@ -21,44 +22,36 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 def __init__(self) -> None: - """Initialize Freebox config flow.""" - self._host: str - self._port = None + """Initialize config flow.""" + self._data: dict[str, Any] = {} - def _show_setup_form(self, user_input=None, errors=None): - """Show the setup form to the user.""" - - if user_input is None: - user_input = {} - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, - vol.Required(CONF_PORT, default=user_input.get(CONF_PORT, "")): int, - } - ), - errors=errors or {}, - ) - - async def async_step_user(self, user_input=None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" - errors: dict[str, str] = {} - if user_input is None: - return self._show_setup_form(user_input, errors) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT): int, + } + ), + errors={}, + ) - self._host = user_input[CONF_HOST] - self._port = user_input[CONF_PORT] + self._data = user_input # Check if already configured - await self.async_set_unique_id(self._host) + await self.async_set_unique_id(self._data[CONF_HOST]) self._abort_if_unique_id_configured() return await self.async_step_link() - async def async_step_link(self, user_input=None) -> FlowResult: + async def async_step_link( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Attempt to link with the Freebox router. Given a configured host, will ask the user to press the button @@ -69,10 +62,10 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} - fbx = await get_api(self.hass, self._host) + fbx = await get_api(self.hass, self._data[CONF_HOST]) try: # Open connection and check authentication - await fbx.open(self._host, self._port) + await fbx.open(self._data[CONF_HOST], self._data[CONF_PORT]) # Check permissions await fbx.system.get_config() @@ -82,8 +75,8 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await fbx.close() return self.async_create_entry( - title=self._host, - data={CONF_HOST: self._host, CONF_PORT: self._port}, + title=self._data[CONF_HOST], + data=self._data, ) except AuthorizationError as error: @@ -91,18 +84,23 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "register_failed" except HttpRequestError: - _LOGGER.error("Error connecting to the Freebox router at %s", self._host) + _LOGGER.error( + "Error connecting to the Freebox router at %s", self._data[CONF_HOST] + ) errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except _LOGGER.exception( - "Unknown error connecting with Freebox router at %s", self._host + "Unknown error connecting with Freebox router at %s", + self._data[CONF_HOST], ) errors["base"] = "unknown" return self.async_show_form(step_id="link", errors=errors) - async def async_step_import(self, user_input=None) -> FlowResult: + async def async_step_import( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Import a config entry.""" return await self.async_step_user(user_input) diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 6111eb85b4c..f42a386087f 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -71,6 +71,7 @@ class FreeboxRouter: self.devices: dict[str, dict[str, Any]] = {} self.disks: dict[int, dict[str, Any]] = {} + self.supports_raid = True self.raids: dict[int, dict[str, Any]] = {} self.sensors_temperature: dict[str, int] = {} self.sensors_connection: dict[str, float] = {} @@ -159,14 +160,21 @@ class FreeboxRouter: async def _update_raids_sensors(self) -> None: """Update Freebox raids.""" - # None at first request + if not self.supports_raid: + return + try: fbx_raids: list[dict[str, Any]] = await self._api.storage.get_raids() or [] except HttpRequestError: - _LOGGER.warning("Unable to enumerate raid disks") - else: - for fbx_raid in fbx_raids: - self.raids[fbx_raid["id"]] = fbx_raid + self.supports_raid = False + _LOGGER.info( + "Router %s API does not support RAID", + self.name, + ) + return + + for fbx_raid in fbx_raids: + self.raids[fbx_raid["id"]] = fbx_raid async def update_home_devices(self) -> None: """Update Home devices (alarm, light, sensor, switch, remote ...).""" diff --git a/homeassistant/components/garages_amsterdam/__init__.py b/homeassistant/components/garages_amsterdam/__init__.py index 35d177b2cca..82e0c832e7b 100644 --- a/homeassistant/components/garages_amsterdam/__init__.py +++ b/homeassistant/components/garages_amsterdam/__init__.py @@ -3,7 +3,7 @@ import asyncio from datetime import timedelta import logging -from odp_amsterdam import ODPAmsterdam +from odp_amsterdam import ODPAmsterdam, VehicleType from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -45,7 +45,7 @@ async def get_coordinator( garage.garage_name: garage for garage in await ODPAmsterdam( session=aiohttp_client.async_get_clientsession(hass) - ).all_garages(vehicle="car") + ).all_garages(vehicle=VehicleType.CAR) } coordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/garages_amsterdam/config_flow.py b/homeassistant/components/garages_amsterdam/config_flow.py index 7799630ddee..65a2d359747 100644 --- a/homeassistant/components/garages_amsterdam/config_flow.py +++ b/homeassistant/components/garages_amsterdam/config_flow.py @@ -5,7 +5,7 @@ import logging from typing import Any from aiohttp import ClientResponseError -from odp_amsterdam import ODPAmsterdam +from odp_amsterdam import ODPAmsterdam, VehicleType import voluptuous as vol from homeassistant import config_entries @@ -32,7 +32,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: api_data = await ODPAmsterdam( session=aiohttp_client.async_get_clientsession(self.hass) - ).all_garages(vehicle="car") + ).all_garages(vehicle=VehicleType.CAR) except ClientResponseError: _LOGGER.error("Unexpected response from server") return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/garages_amsterdam/manifest.json b/homeassistant/components/garages_amsterdam/manifest.json index e67bdaa04d0..3f4ffc7fae1 100644 --- a/homeassistant/components/garages_amsterdam/manifest.json +++ b/homeassistant/components/garages_amsterdam/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/garages_amsterdam", "iot_class": "cloud_polling", - "requirements": ["odp-amsterdam==5.3.0"] + "requirements": ["odp-amsterdam==5.3.1"] } diff --git a/homeassistant/components/gardena_bluetooth/config_flow.py b/homeassistant/components/gardena_bluetooth/config_flow.py index 3e981675057..7b34edd29af 100644 --- a/homeassistant/components/gardena_bluetooth/config_flow.py +++ b/homeassistant/components/gardena_bluetooth/config_flow.py @@ -5,9 +5,9 @@ import logging from typing import Any from gardena_bluetooth.client import Client -from gardena_bluetooth.const import DeviceInformation, ScanService +from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation, ScanService from gardena_bluetooth.exceptions import CharacteristicNotFound, CommunicationFailure -from gardena_bluetooth.parse import ManufacturerData, ProductGroup +from gardena_bluetooth.parse import ManufacturerData, ProductType import voluptuous as vol from homeassistant import config_entries @@ -34,7 +34,13 @@ def _is_supported(discovery_info: BluetoothServiceInfo): return False manufacturer_data = ManufacturerData.decode(data) - if manufacturer_data.group != ProductGroup.WATER_CONTROL: + product_type = ProductType.from_manufacturer_data(manufacturer_data) + + if product_type not in ( + ProductType.PUMP, + ProductType.VALVE, + ProductType.WATER_COMPUTER, + ): _LOGGER.debug("Unsupported device: %s", manufacturer_data) return False @@ -42,9 +48,11 @@ def _is_supported(discovery_info: BluetoothServiceInfo): def _get_name(discovery_info: BluetoothServiceInfo): - if discovery_info.name and discovery_info.name != discovery_info.address: - return discovery_info.name - return "Gardena Device" + data = discovery_info.manufacturer_data[ManufacturerData.company] + manufacturer_data = ManufacturerData.decode(data) + product_type = ProductType.from_manufacturer_data(manufacturer_data) + + return PRODUCT_NAMES.get(product_type, "Gardena Device") class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index 0226460d4d8..5d1c1888586 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -13,5 +13,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", "iot_class": "local_polling", - "requirements": ["gardena_bluetooth==1.0.2"] + "requirements": ["gardena_bluetooth==1.3.0"] } diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index ec887458586..f53a7720577 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -71,15 +71,15 @@ DESCRIPTIONS = ( char=DeviceConfiguration.rain_pause, ), GardenaBluetoothNumberEntityDescription( - key=DeviceConfiguration.season_pause.uuid, - translation_key="season_pause", + key=DeviceConfiguration.seasonal_adjust.uuid, + translation_key="seasonal_adjust", native_unit_of_measurement=UnitOfTime.DAYS, mode=NumberMode.BOX, - native_min_value=0.0, - native_max_value=365.0, + native_min_value=-128.0, + native_max_value=127.0, native_step=1.0, entity_category=EntityCategory.CONFIG, - char=DeviceConfiguration.season_pause, + char=DeviceConfiguration.seasonal_adjust, ), ) diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index ebc83ae88af..dd2bde43cc4 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from gardena_bluetooth.const import Battery, Valve from gardena_bluetooth.parse import Characteristic @@ -106,7 +106,7 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity): super()._handle_coordinator_update() return - time = datetime.now(timezone.utc) + timedelta(seconds=value) + time = datetime.now(UTC) + timedelta(seconds=value) if not self._attr_native_value: self._attr_native_value = time super()._handle_coordinator_update() diff --git a/homeassistant/components/gardena_bluetooth/strings.json b/homeassistant/components/gardena_bluetooth/strings.json index 1fc6e10b5a6..538f97ffdb3 100644 --- a/homeassistant/components/gardena_bluetooth/strings.json +++ b/homeassistant/components/gardena_bluetooth/strings.json @@ -43,8 +43,8 @@ "rain_pause": { "name": "Rain pause" }, - "season_pause": { - "name": "Season pause" + "seasonal_adjust": { + "name": "Seasonal adjust" } }, "sensor": { diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index e1535037d35..5d5589c54d6 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -1,6 +1,7 @@ """Feed Entity Manager Sensor support for GDACS Feed.""" from __future__ import annotations +from collections.abc import Callable import logging from homeassistant.components.sensor import SensorEntity @@ -33,21 +34,22 @@ async def async_setup_entry( ) -> None: """Set up the GDACS Feed platform.""" manager = hass.data[DOMAIN][FEED][entry.entry_id] - sensor = GdacsSensor(entry.entry_id, entry.unique_id, entry.title, manager) + sensor = GdacsSensor(entry, manager) async_add_entities([sensor]) - _LOGGER.debug("Sensor setup done") class GdacsSensor(SensorEntity): """Status sensor for the GDACS integration.""" _attr_should_poll = False + _attr_icon = DEFAULT_ICON + _attr_native_unit_of_measurement = DEFAULT_UNIT_OF_MEASUREMENT - def __init__(self, config_entry_id, config_unique_id, config_title, manager): + def __init__(self, config_entry: ConfigEntry, manager) -> None: """Initialize entity.""" - self._config_entry_id = config_entry_id - self._config_unique_id = config_unique_id - self._config_title = config_title + self._config_entry_id = config_entry.entry_id + self._attr_unique_id = config_entry.unique_id + self._attr_name = f"GDACS ({config_entry.title})" self._manager = manager self._status = None self._last_update = None @@ -57,7 +59,7 @@ class GdacsSensor(SensorEntity): self._created = None self._updated = None self._removed = None - self._remove_signal_status = None + self._remove_signal_status: Callable[[], None] | None = None async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" @@ -112,26 +114,6 @@ class GdacsSensor(SensorEntity): """Return the state of the sensor.""" return self._total - @property - def unique_id(self) -> str | None: - """Return a unique ID containing latitude/longitude.""" - return self._config_unique_id - - @property - def name(self) -> str | None: - """Return the name of the entity.""" - return f"GDACS ({self._config_title})" - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return DEFAULT_ICON - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return DEFAULT_UNIT_OF_MEASUREMENT - @property def extra_state_attributes(self): """Return the device state attributes.""" diff --git a/homeassistant/components/google_mail/sensor.py b/homeassistant/components/google_mail/sensor.py index a65e845095c..dc1ee33c16e 100644 --- a/homeassistant/components/google_mail/sensor.py +++ b/homeassistant/components/google_mail/sensor.py @@ -1,7 +1,7 @@ """Support for Google Mail Sensors.""" from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from googleapiclient.http import HttpRequest @@ -46,7 +46,7 @@ class GoogleMailSensor(GoogleMailEntity, SensorEntity): data: dict = await self.hass.async_add_executor_job(settings.execute) if data["enableAutoReply"] and (end := data.get("endTime")): - value = datetime.fromtimestamp(int(end) / 1000, tz=timezone.utc) + value = datetime.fromtimestamp(int(end) / 1000, tz=UTC) else: value = None self._attr_native_value = value diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index 7415ee8c60d..105b1b95b1d 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -1,6 +1,9 @@ """Platform allowing several binary sensor to be grouped into one binary sensor.""" from __future__ import annotations +from collections.abc import Callable, Mapping +from typing import Any + import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -21,7 +24,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -100,7 +103,7 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity): name: str, device_class: BinarySensorDeviceClass | None, entity_ids: list[str], - mode: str | None, + mode: bool | None, ) -> None: """Initialize a BinarySensorGroup entity.""" super().__init__() @@ -113,6 +116,26 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity): if mode: self.mode = all + @callback + def async_start_preview( + self, + preview_callback: Callable[[str, Mapping[str, Any]], None], + ) -> CALLBACK_TYPE: + """Render a preview.""" + + @callback + def async_state_changed_listener( + event: EventType[EventStateChangedData] | None, + ) -> None: + """Handle child updates.""" + self.async_update_group_state() + preview_callback(*self._async_generate_attributes()) + + async_state_changed_listener(None) + return async_track_state_change_event( + self.hass, self._entity_ids, async_state_changed_listener + ) + async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 6cdc47f9e85..869a4d33b5f 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -3,12 +3,14 @@ from __future__ import annotations from collections.abc import Callable, Coroutine, Mapping from functools import partial -from typing import Any, cast +from typing import Any, Literal, cast import voluptuous as vol +from homeassistant.components import websocket_api from homeassistant.const import CONF_ENTITIES, CONF_TYPE from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, @@ -20,8 +22,9 @@ from homeassistant.helpers.schema_config_entry_flow import ( ) from . import DOMAIN -from .binary_sensor import CONF_ALL +from .binary_sensor import CONF_ALL, BinarySensorGroup from .const import CONF_HIDE_MEMBERS, CONF_IGNORE_NON_NUMERIC +from .sensor import SensorGroup _STATISTIC_MEASURES = [ "min", @@ -36,15 +39,22 @@ _STATISTIC_MEASURES = [ async def basic_group_options_schema( - domain: str | list[str], handler: SchemaCommonFlowHandler + domain: str | list[str], handler: SchemaCommonFlowHandler | None ) -> vol.Schema: """Generate options schema.""" + if handler is None: + entity_selector = selector.selector( + {"entity": {"domain": domain, "multiple": True}} + ) + else: + entity_selector = entity_selector_without_own_entities( + cast(SchemaOptionsFlowHandler, handler.parent_handler), + selector.EntitySelectorConfig(domain=domain, multiple=True), + ) + return vol.Schema( { - vol.Required(CONF_ENTITIES): entity_selector_without_own_entities( - cast(SchemaOptionsFlowHandler, handler.parent_handler), - selector.EntitySelectorConfig(domain=domain, multiple=True), - ), + vol.Required(CONF_ENTITIES): entity_selector, vol.Required(CONF_HIDE_MEMBERS, default=False): selector.BooleanSelector(), } ) @@ -63,7 +73,9 @@ def basic_group_config_schema(domain: str | list[str]) -> vol.Schema: ) -async def binary_sensor_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: +async def binary_sensor_options_schema( + handler: SchemaCommonFlowHandler | None, +) -> vol.Schema: """Generate options schema.""" return (await basic_group_options_schema("binary_sensor", handler)).extend( { @@ -96,7 +108,7 @@ SENSOR_OPTIONS = { async def sensor_options_schema( - domain: str, handler: SchemaCommonFlowHandler + domain: str, handler: SchemaCommonFlowHandler | None ) -> vol.Schema: """Generate options schema.""" return ( @@ -160,6 +172,7 @@ CONFIG_FLOW = { "binary_sensor": SchemaFlowFormStep( BINARY_SENSOR_CONFIG_SCHEMA, validate_user_input=set_group_type("binary_sensor"), + preview="group_binary_sensor", ), "cover": SchemaFlowFormStep( basic_group_config_schema("cover"), @@ -184,6 +197,7 @@ CONFIG_FLOW = { "sensor": SchemaFlowFormStep( SENSOR_CONFIG_SCHEMA, validate_user_input=set_group_type("sensor"), + preview="group_sensor", ), "switch": SchemaFlowFormStep( basic_group_config_schema("switch"), @@ -194,7 +208,10 @@ CONFIG_FLOW = { OPTIONS_FLOW = { "init": SchemaFlowFormStep(next_step=choose_options_step), - "binary_sensor": SchemaFlowFormStep(binary_sensor_options_schema), + "binary_sensor": SchemaFlowFormStep( + binary_sensor_options_schema, + preview="group_binary_sensor", + ), "cover": SchemaFlowFormStep(partial(basic_group_options_schema, "cover")), "fan": SchemaFlowFormStep(partial(basic_group_options_schema, "fan")), "light": SchemaFlowFormStep(partial(light_switch_options_schema, "light")), @@ -202,7 +219,10 @@ OPTIONS_FLOW = { "media_player": SchemaFlowFormStep( partial(basic_group_options_schema, "media_player") ), - "sensor": SchemaFlowFormStep(partial(sensor_options_schema, "sensor")), + "sensor": SchemaFlowFormStep( + partial(sensor_options_schema, "sensor"), + preview="group_sensor", + ), "switch": SchemaFlowFormStep(partial(light_switch_options_schema, "switch")), } @@ -241,6 +261,13 @@ class GroupConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): ) _async_hide_members(hass, options[CONF_ENTITIES], hidden_by) + @callback + @staticmethod + def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview WS API.""" + websocket_api.async_register_command(hass, ws_preview_sensor) + websocket_api.async_register_command(hass, ws_preview_binary_sensor) + def _async_hide_members( hass: HomeAssistant, members: list[str], hidden_by: er.RegistryEntryHider | None @@ -253,3 +280,129 @@ def _async_hide_members( if entity_id not in registry.entities: continue registry.async_update_entity(entity_id, hidden_by=hidden_by) + + +@callback +def _async_handle_ws_preview( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + config_schema: vol.Schema, + options_schema: vol.Schema, + create_preview_entity: Callable[ + [Literal["config_flow", "options_flow"], str, dict[str, Any]], + BinarySensorGroup | SensorGroup, + ], +) -> None: + """Generate a preview.""" + if msg["flow_type"] == "config_flow": + validated = config_schema(msg["user_input"]) + name = validated["name"] + else: + validated = options_schema(msg["user_input"]) + flow_status = hass.config_entries.options.async_get(msg["flow_id"]) + config_entry = hass.config_entries.async_get_entry(flow_status["handler"]) + if not config_entry: + raise HomeAssistantError + name = config_entry.options["name"] + + @callback + def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: + """Forward config entry state events to websocket.""" + connection.send_message( + websocket_api.event_message( + msg["id"], {"state": state, "attributes": attributes} + ) + ) + + preview_entity = create_preview_entity(msg["flow_type"], name, validated) + preview_entity.hass = hass + + connection.send_result(msg["id"]) + connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( + async_preview_updated + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "group/binary_sensor/start_preview", + vol.Required("flow_id"): str, + vol.Required("flow_type"): vol.Any("config_flow", "options_flow"), + vol.Required("user_input"): dict, + } +) +@websocket_api.async_response +async def ws_preview_binary_sensor( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Generate a preview.""" + + def create_preview_binary_sensor( + flow_type: Literal["config_flow", "options_flow"], + name: str, + validated_config: dict[str, Any], + ) -> BinarySensorGroup: + """Create a preview sensor.""" + return BinarySensorGroup( + None, + name, + None, + validated_config[CONF_ENTITIES], + validated_config[CONF_ALL], + ) + + _async_handle_ws_preview( + hass, + connection, + msg, + BINARY_SENSOR_CONFIG_SCHEMA, + await binary_sensor_options_schema(None), + create_preview_binary_sensor, + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "group/sensor/start_preview", + vol.Required("flow_id"): str, + vol.Required("flow_type"): vol.Any("config_flow", "options_flow"), + vol.Required("user_input"): dict, + } +) +@websocket_api.async_response +async def ws_preview_sensor( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Generate a preview.""" + + def create_preview_sensor( + flow_type: Literal["config_flow", "options_flow"], + name: str, + validated_config: dict[str, Any], + ) -> SensorGroup: + """Create a preview sensor.""" + ignore_non_numeric = ( + False + if flow_type == "config_flow" + else validated_config[CONF_IGNORE_NON_NUMERIC] + ) + return SensorGroup( + None, + name, + validated_config[CONF_ENTITIES], + ignore_non_numeric, + validated_config[CONF_TYPE], + None, + None, + None, + ) + + _async_handle_ws_preview( + hass, + connection, + msg, + SENSOR_CONFIG_SCHEMA, + await sensor_options_schema("sensor", None), + create_preview_sensor, + ) diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index d62447d9947..48175b55358 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -1,7 +1,7 @@ """Platform allowing several sensors to be grouped into one sensor to provide numeric combinations.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import datetime import logging import statistics @@ -33,7 +33,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, State, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -303,6 +303,26 @@ class SensorGroup(GroupEntity, SensorEntity): self._state_incorrect: set[str] = set() self._extra_state_attribute: dict[str, Any] = {} + @callback + def async_start_preview( + self, + preview_callback: Callable[[str, Mapping[str, Any]], None], + ) -> CALLBACK_TYPE: + """Render a preview.""" + + @callback + def async_state_changed_listener( + event: EventType[EventStateChangedData] | None, + ) -> None: + """Handle child updates.""" + self.async_update_group_state() + preview_callback(*self._async_generate_attributes()) + + async_state_changed_listener(None) + return async_track_state_change_event( + self.hass, self._entity_ids, async_state_changed_listener + ) + async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 0735f2645cc..5712f5d1bea 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -9,6 +9,7 @@ ATTR_ADMIN = "admin" ATTR_COMPRESSED = "compressed" ATTR_CONFIG = "config" ATTR_DATA = "data" +ATTR_SESSION_DATA_USER_ID = "user_id" ATTR_DISCOVERY = "discovery" ATTR_ENABLE = "enable" ATTR_ENDPOINT = "endpoint" diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index c8fefe65e1f..ac0395ebd9f 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -22,6 +22,7 @@ from .const import ( ATTR_ENDPOINT, ATTR_METHOD, ATTR_RESULT, + ATTR_SESSION_DATA_USER_ID, ATTR_TIMEOUT, ATTR_WS_EVENT, DOMAIN, @@ -115,12 +116,21 @@ async def websocket_supervisor_api( ): raise Unauthorized() supervisor: HassIO = hass.data[DOMAIN] + + command = msg[ATTR_ENDPOINT] + payload = msg.get(ATTR_DATA, {}) + + if command == "/ingress/session": + # Send user ID on session creation, so the supervisor can correlate session tokens with users + # for every request that is authenticated with the given ingress session token. + payload[ATTR_SESSION_DATA_USER_ID] = connection.user.id + try: result = await supervisor.send_command( - msg[ATTR_ENDPOINT], + command, method=msg[ATTR_METHOD], timeout=msg.get(ATTR_TIMEOUT, 10), - payload=msg.get(ATTR_DATA, {}), + payload=payload, source="core.websocket_api", ) diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index df43d8929e9..d3e9a0f13a6 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -23,6 +23,10 @@ from homeassistant.components.climate import ( DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, FAN_ON, SWING_OFF, SWING_VERTICAL, @@ -35,6 +39,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) from . import KNOWN_DEVICES from .connection import HKDevice @@ -86,6 +94,16 @@ SWING_MODE_HASS_TO_HOMEKIT = {v: k for k, v in SWING_MODE_HOMEKIT_TO_HASS.items( DEFAULT_MIN_STEP: Final = 1.0 +ROTATION_SPEED_LOW = 33 +ROTATION_SPEED_MEDIUM = 66 +ROTATION_SPEED_HIGH = 100 + +HASS_FAN_MODE_TO_HOMEKIT_ROTATION = { + FAN_LOW: ROTATION_SPEED_LOW, + FAN_MEDIUM: ROTATION_SPEED_MEDIUM, + FAN_HIGH: ROTATION_SPEED_HIGH, +} + async def async_setup_entry( hass: HomeAssistant, @@ -170,8 +188,45 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD, CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD, CharacteristicsTypes.SWING_MODE, + CharacteristicsTypes.ROTATION_SPEED, ] + def _get_rotation_speed_range(self) -> tuple[float, float]: + rotation_speed = self.service[CharacteristicsTypes.ROTATION_SPEED] + return round(rotation_speed.minValue or 0) + 1, round( + rotation_speed.maxValue or 100 + ) + + @property + def fan_modes(self) -> list[str]: + """Return the available fan modes.""" + return [FAN_OFF, FAN_LOW, FAN_MEDIUM, FAN_HIGH] + + @property + def fan_mode(self) -> str | None: + """Return the current fan mode.""" + speed_range = self._get_rotation_speed_range() + speed_percentage = ranged_value_to_percentage( + speed_range, self.service.value(CharacteristicsTypes.ROTATION_SPEED) + ) + # homekit value 0 33 66 100 + if speed_percentage > ROTATION_SPEED_MEDIUM: + return FAN_HIGH + if speed_percentage > ROTATION_SPEED_LOW: + return FAN_MEDIUM + if speed_percentage > 0: + return FAN_LOW + return FAN_OFF + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + rotation = HASS_FAN_MODE_TO_HOMEKIT_ROTATION.get(fan_mode, 0) + speed_range = self._get_rotation_speed_range() + speed = round(percentage_to_ranged_value(speed_range, rotation)) + await self.async_put_characteristics( + {CharacteristicsTypes.ROTATION_SPEED: speed} + ) + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temp = kwargs.get(ATTR_TEMPERATURE) @@ -387,6 +442,9 @@ class HomeKitHeaterCoolerEntity(HomeKitBaseClimateEntity): if self.service.has(CharacteristicsTypes.SWING_MODE): features |= ClimateEntityFeature.SWING_MODE + if self.service.has(CharacteristicsTypes.ROTATION_SPEED): + features |= ClimateEntityFeature.FAN_MODE + return features diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index aa07a5248cf..bb72c15cd46 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.15"] + "requirements": ["AIOSomecomfort==0.0.16"] } diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index f36b84170a9..9c9e509947d 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -44,7 +44,6 @@ from .const import ( DOMAIN, HYPERION_MANUFACTURER_NAME, HYPERION_MODEL_NAME, - NAME_SUFFIX_HYPERION_CAMERA, SIGNAL_ENTITY_REMOVE, TYPE_HYPERION_CAMERA, ) @@ -107,6 +106,9 @@ async def async_setup_entry( class HyperionCamera(Camera): """ComponentBinarySwitch switch class.""" + _attr_has_entity_name = True + _attr_name = None + def __init__( self, server_id: str, @@ -120,7 +122,6 @@ class HyperionCamera(Camera): self._unique_id = get_hyperion_unique_id( server_id, instance_num, TYPE_HYPERION_CAMERA ) - self._name = f"{instance_name} {NAME_SUFFIX_HYPERION_CAMERA}".strip() self._device_id = get_hyperion_device_id(server_id, instance_num) self._instance_name = instance_name self._client = hyperion_client @@ -140,11 +141,6 @@ class HyperionCamera(Camera): """Return a unique id for this instance.""" return self._unique_id - @property - def name(self) -> str: - """Return the name of the switch.""" - return self._name - @property def is_on(self) -> bool: """Return true if the camera is on.""" diff --git a/homeassistant/components/hyperion/const.py b/homeassistant/components/hyperion/const.py index 4585b8bedaa..77e16df4d72 100644 --- a/homeassistant/components/hyperion/const.py +++ b/homeassistant/components/hyperion/const.py @@ -21,9 +21,6 @@ HYPERION_MODEL_NAME = f"{HYPERION_MANUFACTURER_NAME}-NG" HYPERION_RELEASES_URL = "https://github.com/hyperion-project/hyperion.ng/releases" HYPERION_VERSION_WARN_CUTOFF = "2.0.0-alpha.9" -NAME_SUFFIX_HYPERION_COMPONENT_SWITCH = "Component" -NAME_SUFFIX_HYPERION_CAMERA = "" - SIGNAL_INSTANCE_ADD = f"{DOMAIN}_instance_add_signal.{{}}" SIGNAL_INSTANCE_REMOVE = f"{DOMAIN}_instance_remove_signal.{{}}" SIGNAL_ENTITY_REMOVE = f"{DOMAIN}_entity_remove_signal.{{}}" diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index 54f9a3a27ff..105e577efad 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -116,6 +116,8 @@ async def async_setup_entry( class HyperionLight(LightEntity): """A Hyperion light that acts as a client for the configured priority.""" + _attr_has_entity_name = True + _attr_name = None _attr_color_mode = ColorMode.HS _attr_should_poll = False _attr_supported_color_modes = {ColorMode.HS} @@ -131,7 +133,6 @@ class HyperionLight(LightEntity): ) -> None: """Initialize the light.""" self._unique_id = self._compute_unique_id(server_id, instance_num) - self._name = self._compute_name(instance_name) self._device_id = get_hyperion_device_id(server_id, instance_num) self._instance_name = instance_name self._options = options @@ -157,20 +158,11 @@ class HyperionLight(LightEntity): """Compute a unique id for this instance.""" return get_hyperion_unique_id(server_id, instance_num, TYPE_HYPERION_LIGHT) - def _compute_name(self, instance_name: str) -> str: - """Compute the name of the light.""" - return f"{instance_name}".strip() - @property def entity_registry_enabled_default(self) -> bool: """Whether or not the entity is enabled by default.""" return True - @property - def name(self) -> str: - """Return the name of the light.""" - return self._name - @property def brightness(self) -> int: """Return the brightness of this light between 0..255.""" diff --git a/homeassistant/components/hyperion/strings.json b/homeassistant/components/hyperion/strings.json index 54beb7704c9..a2f8838e2ea 100644 --- a/homeassistant/components/hyperion/strings.json +++ b/homeassistant/components/hyperion/strings.json @@ -50,5 +50,33 @@ } } } + }, + "entity": { + "switch": { + "all": { + "name": "Component all" + }, + "smoothing": { + "name": "Component smoothing" + }, + "blackbar_detection": { + "name": "Component blackbar detection" + }, + "forwarder": { + "name": "Component forwarder" + }, + "boblight_server": { + "name": "Component boblight server" + }, + "platform_capture": { + "name": "Component platform capture" + }, + "led_device": { + "name": "Component LED device" + }, + "usb_capture": { + "name": "Component USB capture" + } + } } } diff --git a/homeassistant/components/hyperion/switch.py b/homeassistant/components/hyperion/switch.py index 95f14b9b888..11e1dc199be 100644 --- a/homeassistant/components/hyperion/switch.py +++ b/homeassistant/components/hyperion/switch.py @@ -46,7 +46,6 @@ from .const import ( DOMAIN, HYPERION_MANUFACTURER_NAME, HYPERION_MODEL_NAME, - NAME_SUFFIX_HYPERION_COMPONENT_SWITCH, SIGNAL_ENTITY_REMOVE, TYPE_HYPERION_COMPONENT_SWITCH_BASE, ) @@ -74,13 +73,17 @@ def _component_to_unique_id(server_id: str, component: str, instance_num: int) - ) -def _component_to_switch_name(component: str, instance_name: str) -> str: - """Convert a component to a switch name.""" - return ( - f"{instance_name} " - f"{NAME_SUFFIX_HYPERION_COMPONENT_SWITCH} " - f"{KEY_COMPONENTID_TO_NAME.get(component, component.capitalize())}" - ) +def _component_to_translation_key(component: str) -> str: + return { + KEY_COMPONENTID_ALL: "all", + KEY_COMPONENTID_SMOOTHING: "smoothing", + KEY_COMPONENTID_BLACKBORDER: "blackbar_detection", + KEY_COMPONENTID_FORWARDER: "forwarder", + KEY_COMPONENTID_BOBLIGHTSERVER: "boblight_server", + KEY_COMPONENTID_GRABBER: "platform_capture", + KEY_COMPONENTID_LEDDEVICE: "led_device", + KEY_COMPONENTID_V4L: "usb_capture", + }[component] async def async_setup_entry( @@ -129,6 +132,7 @@ class HyperionComponentSwitch(SwitchEntity): _attr_entity_category = EntityCategory.CONFIG _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, @@ -143,7 +147,7 @@ class HyperionComponentSwitch(SwitchEntity): server_id, component_name, instance_num ) self._device_id = get_hyperion_device_id(server_id, instance_num) - self._name = _component_to_switch_name(component_name, instance_name) + self._attr_translation_key = _component_to_translation_key(component_name) self._instance_name = instance_name self._component_name = component_name self._client = hyperion_client @@ -162,11 +166,6 @@ class HyperionComponentSwitch(SwitchEntity): """Return a unique id for this instance.""" return self._unique_id - @property - def name(self) -> str: - """Return the name of the switch.""" - return self._name - @property def is_on(self) -> bool: """Return true if the switch is on.""" diff --git a/homeassistant/components/ipma/config_flow.py b/homeassistant/components/ipma/config_flow.py index d7b8b8cc003..cdea88bdbc0 100644 --- a/homeassistant/components/ipma/config_flow.py +++ b/homeassistant/components/ipma/config_flow.py @@ -1,53 +1,64 @@ """Config flow to configure IPMA component.""" +import logging +from typing import Any + +from pyipma import IPMAException +from pyipma.api import IPMA_API +from pyipma.location import Location import voluptuous as vol -from homeassistant import config_entries -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from .const import DOMAIN, HOME_LOCATION_NAME +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) -class IpmaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): +class IpmaFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for IPMA component.""" VERSION = 1 - def __init__(self): - """Init IpmaFlowHandler.""" - self._errors = {} - - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" - self._errors = {} + errors = {} if user_input is not None: - self._async_abort_entries_match( - { - CONF_LATITUDE: user_input[CONF_LATITUDE], - CONF_LONGITUDE: user_input[CONF_LONGITUDE], - } - ) + self._async_abort_entries_match(user_input) - return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) + api = IPMA_API(async_get_clientsession(self.hass)) - # default location is set hass configuration - return await self._show_config_form( - name=HOME_LOCATION_NAME, - latitude=self.hass.config.latitude, - longitude=self.hass.config.longitude, - ) + try: + location = await Location.get( + api, + user_input[CONF_LATITUDE], + user_input[CONF_LONGITUDE], + ) + except IPMAException as err: + _LOGGER.exception(err) + errors["base"] = "unknown" + else: + return self.async_create_entry(title=location.name, data=user_input) - async def _show_config_form(self, name=None, latitude=None, longitude=None): - """Show the configuration form to edit location data.""" return self.async_show_form( step_id="user", - data_schema=vol.Schema( + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + } + ), { - vol.Required(CONF_NAME, default=name): str, - vol.Required(CONF_LATITUDE, default=latitude): cv.latitude, - vol.Required(CONF_LONGITUDE, default=longitude): cv.longitude, - } + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + }, ), - errors=self._errors, + errors=errors, ) diff --git a/homeassistant/components/ipma/strings.json b/homeassistant/components/ipma/strings.json index 012550d8bd1..b9b672e77d9 100644 --- a/homeassistant/components/ipma/strings.json +++ b/homeassistant/components/ipma/strings.json @@ -12,6 +12,9 @@ } } }, + "error": { + "unknown": "[%key:common::config_flow::error::unknown%]" + }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" } diff --git a/homeassistant/components/ipp/__init__.py b/homeassistant/components/ipp/__init__.py index 9df377b939a..98870c44f5a 100644 --- a/homeassistant/components/ipp/__init__.py +++ b/homeassistant/components/ipp/__init__.py @@ -19,6 +19,10 @@ PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up IPP from a config entry.""" + # config flow sets this to either UUID, serial number or None + if (device_id := entry.unique_id) is None: + device_id = entry.entry_id + coordinator = IPPDataUpdateCoordinator( hass, host=entry.data[CONF_HOST], @@ -26,6 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: base_path=entry.data[CONF_BASE_PATH], tls=entry.data[CONF_SSL], verify_ssl=entry.data[CONF_VERIFY_SSL], + device_id=device_id, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/ipp/coordinator.py b/homeassistant/components/ipp/coordinator.py index abc97dd3dd2..8eb8c972fab 100644 --- a/homeassistant/components/ipp/coordinator.py +++ b/homeassistant/components/ipp/coordinator.py @@ -29,8 +29,10 @@ class IPPDataUpdateCoordinator(DataUpdateCoordinator[IPPPrinter]): base_path: str, tls: bool, verify_ssl: bool, + device_id: str, ) -> None: """Initialize global IPP data updater.""" + self.device_id = device_id self.ipp = IPP( host=host, port=port, diff --git a/homeassistant/components/ipp/entity.py b/homeassistant/components/ipp/entity.py index 2ce6b0f3fa0..05adf711fd9 100644 --- a/homeassistant/components/ipp/entity.py +++ b/homeassistant/components/ipp/entity.py @@ -2,6 +2,7 @@ from __future__ import annotations from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -11,32 +12,21 @@ from .coordinator import IPPDataUpdateCoordinator class IPPEntity(CoordinatorEntity[IPPDataUpdateCoordinator]): """Defines a base IPP entity.""" + _attr_has_entity_name = True + def __init__( self, - *, - entry_id: str, - device_id: str, coordinator: IPPDataUpdateCoordinator, - name: str, - icon: str, - enabled_default: bool = True, + description: EntityDescription, ) -> None: """Initialize the IPP entity.""" super().__init__(coordinator) - self._device_id = device_id - self._entry_id = entry_id - self._attr_name = name - self._attr_icon = icon - self._attr_entity_registry_enabled_default = enabled_default - @property - def device_info(self) -> DeviceInfo | None: - """Return device information about this IPP device.""" - if self._device_id is None: - return None + self.entity_description = description - return DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, + self._attr_unique_id = f"{coordinator.device_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_id)}, manufacturer=self.coordinator.data.info.manufacturer, model=self.coordinator.data.info.model, name=self.coordinator.data.info.name, diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index e8bd4425ef3..cedf0521f95 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["deepmerge", "pyipp"], "quality_scale": "platinum", - "requirements": ["pyipp==0.14.3"], + "requirements": ["pyipp==0.14.4"], "zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."] } diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index 5058f6d10a8..3bc7035e26b 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -1,14 +1,23 @@ """Support for IPP sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from pyipp import Marker, Printer + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_LOCATION, PERCENTAGE +from homeassistant.const import ATTR_LOCATION, PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from .const import ( @@ -27,6 +36,65 @@ from .coordinator import IPPDataUpdateCoordinator from .entity import IPPEntity +@dataclass +class IPPSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Printer], StateType | datetime] + + +@dataclass +class IPPSensorEntityDescription( + SensorEntityDescription, IPPSensorEntityDescriptionMixin +): + """Describes IPP sensor entity.""" + + attributes_fn: Callable[[Printer], dict[Any, StateType]] = lambda _: {} + + +def _get_marker_attributes_fn( + marker_index: int, attributes_fn: Callable[[Marker], dict[Any, StateType]] +) -> Callable[[Printer], dict[Any, StateType]]: + return lambda printer: attributes_fn(printer.markers[marker_index]) + + +def _get_marker_value_fn( + marker_index: int, value_fn: Callable[[Marker], StateType | datetime] +) -> Callable[[Printer], StateType | datetime]: + return lambda printer: value_fn(printer.markers[marker_index]) + + +PRINTER_SENSORS: tuple[IPPSensorEntityDescription, ...] = ( + IPPSensorEntityDescription( + key="printer", + name=None, + translation_key="printer", + icon="mdi:printer", + device_class=SensorDeviceClass.ENUM, + options=["idle", "printing", "stopped"], + attributes_fn=lambda printer: { + ATTR_INFO: printer.info.printer_info, + ATTR_SERIAL: printer.info.serial, + ATTR_LOCATION: printer.info.location, + ATTR_STATE_MESSAGE: printer.state.message, + ATTR_STATE_REASON: printer.state.reasons, + ATTR_COMMAND_SET: printer.info.command_set, + ATTR_URI_SUPPORTED: ",".join(printer.info.printer_uri_supported), + }, + value_fn=lambda printer: printer.state.printer_state, + ), + IPPSensorEntityDescription( + key="uptime", + translation_key="uptime", + icon="mdi:clock-outline", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda printer: (utcnow() - timedelta(seconds=printer.info.uptime)), + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -34,19 +102,34 @@ async def async_setup_entry( ) -> None: """Set up IPP sensor based on a config entry.""" coordinator: IPPDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + sensors: list[SensorEntity] = [ + IPPSensor( + coordinator, + description, + ) + for description in PRINTER_SENSORS + ] - # config flow sets this to either UUID, serial number or None - if (unique_id := entry.unique_id) is None: - unique_id = entry.entry_id - - sensors: list[SensorEntity] = [] - - sensors.append(IPPPrinterSensor(entry.entry_id, unique_id, coordinator)) - sensors.append(IPPUptimeSensor(entry.entry_id, unique_id, coordinator)) - - for marker_index in range(len(coordinator.data.markers)): + for index, marker in enumerate(coordinator.data.markers): sensors.append( - IPPMarkerSensor(entry.entry_id, unique_id, coordinator, marker_index) + IPPSensor( + coordinator, + IPPSensorEntityDescription( + key=f"marker_{index}", + name=marker.name, + icon="mdi:water", + native_unit_of_measurement=PERCENTAGE, + attributes_fn=_get_marker_attributes_fn( + index, + lambda marker: { + ATTR_MARKER_HIGH_LEVEL: marker.high_level, + ATTR_MARKER_LOW_LEVEL: marker.low_level, + ATTR_MARKER_TYPE: marker.marker_type, + }, + ), + value_fn=_get_marker_value_fn(index, lambda marker: marker.level), + ), + ) ) async_add_entities(sensors, True) @@ -55,146 +138,14 @@ async def async_setup_entry( class IPPSensor(IPPEntity, SensorEntity): """Defines an IPP sensor.""" - def __init__( - self, - *, - coordinator: IPPDataUpdateCoordinator, - enabled_default: bool = True, - entry_id: str, - unique_id: str, - icon: str, - key: str, - name: str, - unit_of_measurement: str | None = None, - translation_key: str | None = None, - ) -> None: - """Initialize IPP sensor.""" - self._key = key - self._attr_unique_id = f"{unique_id}_{key}" - self._attr_native_unit_of_measurement = unit_of_measurement - self._attr_translation_key = translation_key - - super().__init__( - entry_id=entry_id, - device_id=unique_id, - coordinator=coordinator, - name=name, - icon=icon, - enabled_default=enabled_default, - ) - - -class IPPMarkerSensor(IPPSensor): - """Defines an IPP marker sensor.""" - - def __init__( - self, - entry_id: str, - unique_id: str, - coordinator: IPPDataUpdateCoordinator, - marker_index: int, - ) -> None: - """Initialize IPP marker sensor.""" - self.marker_index = marker_index - - super().__init__( - coordinator=coordinator, - entry_id=entry_id, - unique_id=unique_id, - icon="mdi:water", - key=f"marker_{marker_index}", - name=( - f"{coordinator.data.info.name} {coordinator.data.markers[marker_index].name}" - ), - unit_of_measurement=PERCENTAGE, - ) + entity_description: IPPSensorEntityDescription @property - def extra_state_attributes(self) -> dict[str, Any] | None: + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the entity.""" - return { - ATTR_MARKER_HIGH_LEVEL: self.coordinator.data.markers[ - self.marker_index - ].high_level, - ATTR_MARKER_LOW_LEVEL: self.coordinator.data.markers[ - self.marker_index - ].low_level, - ATTR_MARKER_TYPE: self.coordinator.data.markers[ - self.marker_index - ].marker_type, - } + return self.entity_description.attributes_fn(self.coordinator.data) @property - def native_value(self) -> int | None: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" - level = self.coordinator.data.markers[self.marker_index].level - - if level >= 0: - return level - - return None - - -class IPPPrinterSensor(IPPSensor): - """Defines an IPP printer sensor.""" - - _attr_device_class = SensorDeviceClass.ENUM - _attr_options = ["idle", "printing", "stopped"] - - def __init__( - self, entry_id: str, unique_id: str, coordinator: IPPDataUpdateCoordinator - ) -> None: - """Initialize IPP printer sensor.""" - super().__init__( - coordinator=coordinator, - entry_id=entry_id, - unique_id=unique_id, - icon="mdi:printer", - key="printer", - name=coordinator.data.info.name, - unit_of_measurement=None, - translation_key="printer", - ) - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes of the entity.""" - return { - ATTR_INFO: self.coordinator.data.info.printer_info, - ATTR_SERIAL: self.coordinator.data.info.serial, - ATTR_LOCATION: self.coordinator.data.info.location, - ATTR_STATE_MESSAGE: self.coordinator.data.state.message, - ATTR_STATE_REASON: self.coordinator.data.state.reasons, - ATTR_COMMAND_SET: self.coordinator.data.info.command_set, - ATTR_URI_SUPPORTED: self.coordinator.data.info.printer_uri_supported, - } - - @property - def native_value(self) -> str: - """Return the state of the sensor.""" - return self.coordinator.data.state.printer_state - - -class IPPUptimeSensor(IPPSensor): - """Defines a IPP uptime sensor.""" - - _attr_device_class = SensorDeviceClass.TIMESTAMP - - def __init__( - self, entry_id: str, unique_id: str, coordinator: IPPDataUpdateCoordinator - ) -> None: - """Initialize IPP uptime sensor.""" - super().__init__( - coordinator=coordinator, - enabled_default=False, - entry_id=entry_id, - unique_id=unique_id, - icon="mdi:clock-outline", - key="uptime", - name=f"{coordinator.data.info.name} Uptime", - ) - - @property - def native_value(self) -> datetime: - """Return the state of the sensor.""" - return utcnow() - timedelta(seconds=self.coordinator.data.info.uptime) + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/ipp/strings.json b/homeassistant/components/ipp/strings.json index f3ea929c9ec..ac879ef0ab3 100644 --- a/homeassistant/components/ipp/strings.json +++ b/homeassistant/components/ipp/strings.json @@ -40,6 +40,9 @@ "idle": "[%key:common::state::idle%]", "stopped": "Stopped" } + }, + "uptime": { + "name": "Uptime" } } } diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index a85221108f8..5c8088823b2 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -27,9 +27,10 @@ DOMAIN = "kitchen_sink" COMPONENTS_WITH_DEMO_PLATFORM = [ - Platform.SENSOR, - Platform.LOCK, Platform.IMAGE, + Platform.LAWN_MOWER, + Platform.LOCK, + Platform.SENSOR, Platform.WEATHER, ] diff --git a/homeassistant/components/kitchen_sink/lawn_mower.py b/homeassistant/components/kitchen_sink/lawn_mower.py new file mode 100644 index 00000000000..119b37b7569 --- /dev/null +++ b/homeassistant/components/kitchen_sink/lawn_mower.py @@ -0,0 +1,100 @@ +"""Demo platform that has a couple fake lawn mowers.""" +from __future__ import annotations + +from homeassistant.components.lawn_mower import ( + LawnMowerActivity, + LawnMowerEntity, + LawnMowerEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Demo lawn mowers.""" + async_add_entities( + [ + DemoLawnMower( + "kitchen_sink_mower_001", + "Mower can mow", + LawnMowerActivity.DOCKED, + LawnMowerEntityFeature.START_MOWING, + ), + DemoLawnMower( + "kitchen_sink_mower_002", + "Mower can dock", + LawnMowerActivity.MOWING, + LawnMowerEntityFeature.DOCK | LawnMowerEntityFeature.START_MOWING, + ), + DemoLawnMower( + "kitchen_sink_mower_003", + "Mower can pause", + LawnMowerActivity.DOCKED, + LawnMowerEntityFeature.PAUSE | LawnMowerEntityFeature.START_MOWING, + ), + DemoLawnMower( + "kitchen_sink_mower_004", + "Mower can do all", + LawnMowerActivity.DOCKED, + LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.START_MOWING, + ), + DemoLawnMower( + "kitchen_sink_mower_005", + "Mower is paused", + LawnMowerActivity.PAUSED, + LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.START_MOWING, + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Everything but the Kitchen Sink config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoLawnMower(LawnMowerEntity): + """Representation of a Demo lawn mower.""" + + def __init__( + self, + unique_id: str, + name: str, + activity: LawnMowerActivity, + features: LawnMowerEntityFeature = LawnMowerEntityFeature(0), + ) -> None: + """Initialize the lawn mower.""" + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_supported_features = features + self._attr_activity = activity + + async def async_start_mowing(self) -> None: + """Start mowing.""" + self._attr_activity = LawnMowerActivity.MOWING + self.async_write_ha_state() + + async def async_dock(self) -> None: + """Start docking.""" + self._attr_activity = LawnMowerActivity.DOCKED + self.async_write_ha_state() + + async def async_pause(self) -> None: + """Pause mower.""" + self._attr_activity = LawnMowerActivity.PAUSED + self.async_write_ha_state() diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index 87ad8dc258f..a6c00e62b62 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -47,93 +47,93 @@ class KrakenSensorEntityDescription(SensorEntityDescription, KrakenRequiredKeysM SENSOR_TYPES: tuple[KrakenSensorEntityDescription, ...] = ( KrakenSensorEntityDescription( key="ask", - name="Ask", + translation_key="ask", value_fn=lambda x, y: x.data[y]["ask"][0], ), KrakenSensorEntityDescription( key="ask_volume", - name="Ask Volume", + translation_key="ask_volume", value_fn=lambda x, y: x.data[y]["ask"][1], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="bid", - name="Bid", + translation_key="bid", value_fn=lambda x, y: x.data[y]["bid"][0], ), KrakenSensorEntityDescription( key="bid_volume", - name="Bid Volume", + translation_key="bid_volume", value_fn=lambda x, y: x.data[y]["bid"][1], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="volume_today", - name="Volume Today", + translation_key="volume_today", value_fn=lambda x, y: x.data[y]["volume"][0], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="volume_last_24h", - name="Volume last 24h", + translation_key="volume_last_24h", value_fn=lambda x, y: x.data[y]["volume"][1], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="volume_weighted_average_today", - name="Volume weighted average today", + translation_key="volume_weighted_average_today", value_fn=lambda x, y: x.data[y]["volume_weighted_average"][0], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="volume_weighted_average_last_24h", - name="Volume weighted average last 24h", + translation_key="volume_weighted_average_last_24h", value_fn=lambda x, y: x.data[y]["volume_weighted_average"][1], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="number_of_trades_today", - name="Number of trades today", + translation_key="number_of_trades_today", value_fn=lambda x, y: x.data[y]["number_of_trades"][0], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="number_of_trades_last_24h", - name="Number of trades last 24h", + translation_key="number_of_trades_last_24h", value_fn=lambda x, y: x.data[y]["number_of_trades"][1], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="last_trade_closed", - name="Last trade closed", + translation_key="last_trade_closed", value_fn=lambda x, y: x.data[y]["last_trade_closed"][0], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="low_today", - name="Low today", + translation_key="low_today", value_fn=lambda x, y: x.data[y]["low"][0], ), KrakenSensorEntityDescription( key="low_last_24h", - name="Low last 24h", + translation_key="low_last_24h", value_fn=lambda x, y: x.data[y]["low"][1], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="high_today", - name="High today", + translation_key="high_today", value_fn=lambda x, y: x.data[y]["high"][0], ), KrakenSensorEntityDescription( key="high_last_24h", - name="High last 24h", + translation_key="high_last_24h", value_fn=lambda x, y: x.data[y]["high"][1], entity_registry_enabled_default=False, ), KrakenSensorEntityDescription( key="opening_price_today", - name="Opening price today", + translation_key="opening_price_today", value_fn=lambda x, y: x.data[y]["opening_price"], entity_registry_enabled_default=False, ), @@ -207,6 +207,9 @@ class KrakenSensor( entity_description: KrakenSensorEntityDescription + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_has_entity_name = True + def __init__( self, kraken_data: KrakenData, @@ -233,7 +236,6 @@ class KrakenSensor( ).lower() self._received_data_at_least_once = False self._available = True - self._attr_state_class = SensorStateClass.MEASUREMENT self._attr_device_info = DeviceInfo( configuration_url="https://www.kraken.com/", @@ -242,7 +244,6 @@ class KrakenSensor( manufacturer="Kraken.com", name=self._device_name, ) - self._attr_has_entity_name = True async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" diff --git a/homeassistant/components/kraken/strings.json b/homeassistant/components/kraken/strings.json index e8ad5ffb98c..c636dbf8d1f 100644 --- a/homeassistant/components/kraken/strings.json +++ b/homeassistant/components/kraken/strings.json @@ -18,5 +18,57 @@ } } } + }, + "entity": { + "sensor": { + "ask": { + "name": "Ask" + }, + "ask_volume": { + "name": "Ask volume" + }, + "bid": { + "name": "Bid" + }, + "bid_volume": { + "name": "Bid volume" + }, + "volume_today": { + "name": "Volume today" + }, + "volume_last_24h": { + "name": "Volume last 24h" + }, + "volume_weighted_average_today": { + "name": "Volume weighted average today" + }, + "volume_weighted_average_last_24h": { + "name": "Volume weighted average last 24h" + }, + "number_of_trades_today": { + "name": "Number of trades today" + }, + "number_of_trades_last_24h": { + "name": "Number of trades last 24h" + }, + "last_trade_closed": { + "name": "Last trade closed" + }, + "low_today": { + "name": "Low today" + }, + "low_last_24h": { + "name": "Low last 24h" + }, + "high_today": { + "name": "High today" + }, + "high_last_24h": { + "name": "High last 24h" + }, + "opening_price_today": { + "name": "Opening price today" + } + } } } diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index f0f3af3b672..40d6521bdc9 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -87,6 +87,8 @@ class LastFmSensor(CoordinatorEntity[LastFMDataUpdateCoordinator], SensorEntity) _attr_attribution = "Data provided by Last.fm" _attr_icon = "mdi:radio-fm" + _attr_has_entity_name = True + _attr_name = None def __init__( self, @@ -98,7 +100,6 @@ class LastFmSensor(CoordinatorEntity[LastFMDataUpdateCoordinator], SensorEntity) super().__init__(coordinator) self._username = username self._attr_unique_id = hashlib.sha256(username.encode("utf-8")).hexdigest() - self._attr_name = username self._attr_device_info = DeviceInfo( configuration_url="https://www.last.fm", entry_type=DeviceEntryType.SERVICE, diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py new file mode 100644 index 00000000000..5388463316f --- /dev/null +++ b/homeassistant/components/lawn_mower/__init__.py @@ -0,0 +1,120 @@ +"""The lawn mower integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import final + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType + +from .const import ( + DOMAIN, + SERVICE_DOCK, + SERVICE_PAUSE, + SERVICE_START_MOWING, + LawnMowerActivity, + LawnMowerEntityFeature, +) + +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the lawn_mower component.""" + component = hass.data[DOMAIN] = EntityComponent[LawnMowerEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_START_MOWING, + {}, + "async_start_mowing", + [LawnMowerEntityFeature.START_MOWING], + ) + component.async_register_entity_service( + SERVICE_PAUSE, {}, "async_pause", [LawnMowerEntityFeature.PAUSE] + ) + component.async_register_entity_service( + SERVICE_DOCK, {}, "async_dock", [LawnMowerEntityFeature.DOCK] + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up lawn mower devices.""" + component: EntityComponent[LawnMowerEntity] = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent[LawnMowerEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class LawnMowerEntityEntityDescription(EntityDescription): + """A class that describes lawn mower entities.""" + + +class LawnMowerEntity(Entity): + """Base class for lawn mower entities.""" + + entity_description: LawnMowerEntityEntityDescription + _attr_activity: LawnMowerActivity | None = None + _attr_supported_features: LawnMowerEntityFeature = LawnMowerEntityFeature(0) + + @final + @property + def state(self) -> str | None: + """Return the current state.""" + if (activity := self.activity) is None: + return None + return str(activity) + + @property + def activity(self) -> LawnMowerActivity | None: + """Return the current lawn mower activity.""" + return self._attr_activity + + @property + def supported_features(self) -> LawnMowerEntityFeature: + """Flag lawn mower features that are supported.""" + return self._attr_supported_features + + def start_mowing(self) -> None: + """Start or resume mowing.""" + raise NotImplementedError() + + async def async_start_mowing(self) -> None: + """Start or resume mowing.""" + await self.hass.async_add_executor_job(self.start_mowing) + + def dock(self) -> None: + """Dock the mower.""" + raise NotImplementedError() + + async def async_dock(self) -> None: + """Dock the mower.""" + await self.hass.async_add_executor_job(self.dock) + + def pause(self) -> None: + """Pause the lawn mower.""" + raise NotImplementedError() + + async def async_pause(self) -> None: + """Pause the lawn mower.""" + await self.hass.async_add_executor_job(self.pause) diff --git a/homeassistant/components/lawn_mower/const.py b/homeassistant/components/lawn_mower/const.py new file mode 100644 index 00000000000..706c9616450 --- /dev/null +++ b/homeassistant/components/lawn_mower/const.py @@ -0,0 +1,33 @@ +"""Constants for the lawn mower integration.""" +from enum import IntFlag, StrEnum + + +class LawnMowerActivity(StrEnum): + """Activity state of lawn mower devices.""" + + ERROR = "error" + """Device is in error state, needs assistance.""" + + PAUSED = "paused" + """Paused during activity.""" + + MOWING = "mowing" + """Device is mowing.""" + + DOCKED = "docked" + """Device is docked.""" + + +class LawnMowerEntityFeature(IntFlag): + """Supported features of the lawn mower entity.""" + + START_MOWING = 1 + PAUSE = 2 + DOCK = 4 + + +DOMAIN = "lawn_mower" + +SERVICE_START_MOWING = "start_mowing" +SERVICE_PAUSE = "pause" +SERVICE_DOCK = "dock" diff --git a/homeassistant/components/lawn_mower/manifest.json b/homeassistant/components/lawn_mower/manifest.json new file mode 100644 index 00000000000..43418a9440d --- /dev/null +++ b/homeassistant/components/lawn_mower/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "lawn_mower", + "name": "Lawn Mower", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/lawn_mower", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/lawn_mower/services.yaml b/homeassistant/components/lawn_mower/services.yaml new file mode 100644 index 00000000000..8c9a2f1adcc --- /dev/null +++ b/homeassistant/components/lawn_mower/services.yaml @@ -0,0 +1,22 @@ +# Describes the format for available lawn_mower services + +start_mowing: + target: + entity: + domain: lawn_mower + supported_features: + - lawn_mower.LawnMowerEntityFeature.START_MOWING + +dock: + target: + entity: + domain: lawn_mower + supported_features: + - lawn_mower.LawnMowerEntityFeature.DOCK + +pause: + target: + entity: + domain: lawn_mower + supported_features: + - lawn_mower.LawnMowerEntityFeature.PAUSE diff --git a/homeassistant/components/lawn_mower/strings.json b/homeassistant/components/lawn_mower/strings.json new file mode 100644 index 00000000000..caf2e15df77 --- /dev/null +++ b/homeassistant/components/lawn_mower/strings.json @@ -0,0 +1,28 @@ +{ + "title": "Lawn mower", + "entity_component": { + "_": { + "name": "[%key:component::lawn_mower::title%]", + "state": { + "error": "Error", + "paused": "Paused", + "mowing": "Mowing", + "docked": "Docked" + } + } + }, + "services": { + "start_mowing": { + "name": "Start mowing", + "description": "Starts the mowing task." + }, + "dock": { + "name": "Return to dock", + "description": "Stops the mowing task and returns to the dock." + }, + "pause": { + "name": "Pause", + "description": "Pauses the mowing task." + } + } +} diff --git a/homeassistant/components/lg_soundbar/media_player.py b/homeassistant/components/lg_soundbar/media_player.py index 577b4a8811a..54d9be78df9 100644 --- a/homeassistant/components/lg_soundbar/media_player.py +++ b/homeassistant/components/lg_soundbar/media_player.py @@ -11,8 +11,11 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .const import DOMAIN + async def async_setup_entry( hass: HomeAssistant, @@ -42,6 +45,8 @@ class LGDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SELECT_SOUND_MODE ) + _attr_has_entity_name = True + _attr_name = None def __init__(self, host, port, unique_id): """Initialize the LG speakers.""" @@ -66,6 +71,9 @@ class LGDevice(MediaPlayerEntity): self._bass = 0 self._treble = 0 self._device = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, name=host + ) async def async_added_to_hass(self) -> None: """Register the callback after hass is ready for it.""" diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py index f16e7215a22..27b4ce291fd 100644 --- a/homeassistant/components/life360/device_tracker.py +++ b/homeassistant/components/life360/device_tracker.py @@ -10,6 +10,7 @@ from homeassistant.components.device_tracker import SourceType, TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_BATTERY_CHARGING from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -111,6 +112,7 @@ class Life360DeviceTracker( self._prev_data = self._data self._attr_name = self._data.name + self._name = self._data.name self._attr_entity_picture = self._data.entity_picture # Server sends a pair of address values on alternate updates. Keep the pair of @@ -124,6 +126,11 @@ class Life360DeviceTracker( address = None self._addresses = [address] + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + return DeviceInfo(identifiers={(DOMAIN, self._attr_unique_id)}, name=self._name) + @property def _options(self) -> Mapping[str, Any]: """Shortcut to config entry options.""" diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 41aa58fb962..76d4b7e36c5 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -30,7 +30,7 @@ from .coordinator import LIFXUpdateCoordinator from .discovery import async_discover_devices, async_trigger_discovery from .manager import LIFXManager from .migration import async_migrate_entities_devices, async_migrate_legacy_entries -from .util import async_entry_is_legacy, async_get_legacy_entry +from .util import async_entry_is_legacy, async_get_legacy_entry, formatted_serial CONF_SERVER = "server" CONF_BROADCAST = "broadcast" @@ -218,6 +218,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: connection.async_stop() raise + serial = formatted_serial(coordinator.serial_number) + if serial != entry.unique_id: + # If the serial number of the device does not match the unique_id + # of the config entry, it likely means the DHCP lease has expired + # and the device has been assigned a new IP address. We need to + # wait for the next discovery to find the device at its new address + # and update the config entry so we do not mix up devices. + raise ConfigEntryNotReady( + f"Unexpected device found at {host}; expected {entry.unique_id}, found {serial}" + ) domain_data[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/litejet/__init__.py b/homeassistant/components/litejet/__init__.py index a7ea6ecd034..8c6d5ef4487 100644 --- a/homeassistant/components/litejet/__init__.py +++ b/homeassistant/components/litejet/__init__.py @@ -6,10 +6,9 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from .const import CONF_EXCLUDE_NAMES, CONF_INCLUDE_SWITCHES, DOMAIN, PLATFORMS @@ -37,27 +36,13 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LiteJet component.""" - if DOMAIN in config and not hass.config_entries.async_entries(DOMAIN): - # No config entry exists and configuration.yaml config exists, trigger the import flow. + if DOMAIN in config: + # Configuration.yaml config exists, trigger the import flow. hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] ) ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "LiteJet", - }, - ) return True diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py index c469d480ca6..1062e948090 100644 --- a/homeassistant/components/litejet/config_flow.py +++ b/homeassistant/components/litejet/config_flow.py @@ -9,9 +9,10 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PORT -from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, callback +from homeassistant.data_entry_flow import FlowResult, FlowResultType import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import CONF_DEFAULT_TRANSITION, DOMAIN @@ -53,6 +54,21 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Create a LiteJet config entry based upon user input.""" if self._async_current_entries(): + if self.context["source"] == config_entries.SOURCE_IMPORT: + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "LiteJet", + }, + ) return self.async_abort(reason="single_instance_allowed") errors = {} @@ -62,6 +78,20 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: system = await pylitejet.open(port) except SerialException: + if self.context["source"] == config_entries.SOURCE_IMPORT: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_serial_exception", + breaks_in_ha_version="2024.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.ERROR, + translation_key="deprecated_yaml_serial_exception", + translation_placeholders={ + "url": "/config/integrations/dashboard/add?domain=litejet" + }, + ) errors[CONF_PORT] = "open_failed" else: await system.close() @@ -78,7 +108,24 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_data: dict[str, Any]) -> FlowResult: """Import litejet config from configuration.yaml.""" - return self.async_create_entry(title=import_data[CONF_PORT], data=import_data) + new_data = {CONF_PORT: import_data[CONF_PORT]} + result = await self.async_step_user(new_data) + if result["type"] == FlowResultType.CREATE_ENTRY: + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "LiteJet", + }, + ) + return result @staticmethod @callback diff --git a/homeassistant/components/litejet/strings.json b/homeassistant/components/litejet/strings.json index 398f1a1e5aa..288e5f959a8 100644 --- a/homeassistant/components/litejet/strings.json +++ b/homeassistant/components/litejet/strings.json @@ -25,5 +25,11 @@ } } } + }, + "issues": { + "deprecated_yaml_serial_exception": { + "title": "The LiteJet YAML configuration import failed", + "description": "Configuring LiteJet using YAML is being removed but there was an error opening the serial port when importing your YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or manually continue to [set up the integration]({url})." + } } } diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 81375dd3a6c..9a3334cbaac 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "requirements": ["pylitterbot==2023.4.4"] + "requirements": ["pylitterbot==2023.4.5"] } diff --git a/homeassistant/components/logi_circle/camera.py b/homeassistant/components/logi_circle/camera.py index 027009669e5..77c0f2f24c8 100644 --- a/homeassistant/components/logi_circle/camera.py +++ b/homeassistant/components/logi_circle/camera.py @@ -53,7 +53,7 @@ async def async_setup_entry( devices = await hass.data[LOGI_CIRCLE_DOMAIN].cameras ffmpeg = get_ffmpeg_manager(hass) - cameras = [LogiCam(device, entry, ffmpeg) for device in devices] + cameras = [LogiCam(device, ffmpeg) for device in devices] async_add_entities(cameras, True) @@ -64,12 +64,13 @@ class LogiCam(Camera): _attr_attribution = ATTRIBUTION _attr_should_poll = True # Cameras default to False _attr_supported_features = CameraEntityFeature.ON_OFF + _attr_has_entity_name = True + _attr_name = None - def __init__(self, camera, device_info, ffmpeg): + def __init__(self, camera, ffmpeg): """Initialize Logi Circle camera.""" super().__init__() self._camera = camera - self._name = self._camera.name self._id = self._camera.mac_address self._has_battery = self._camera.supports_feature("battery_level") self._ffmpeg = ffmpeg diff --git a/homeassistant/components/logi_circle/sensor.py b/homeassistant/components/logi_circle/sensor.py index 148dd88b41a..32082b794b7 100644 --- a/homeassistant/components/logi_circle/sensor.py +++ b/homeassistant/components/logi_circle/sensor.py @@ -4,7 +4,11 @@ from __future__ import annotations import logging from typing import Any -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_CHARGING, @@ -17,7 +21,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import as_local @@ -29,34 +32,33 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="battery_level", - name="Battery", native_unit_of_measurement=PERCENTAGE, - icon="mdi:battery-50", + device_class=SensorDeviceClass.BATTERY, ), SensorEntityDescription( key="last_activity_time", - name="Last Activity", + translation_key="last_activity", icon="mdi:history", ), SensorEntityDescription( key="recording", - name="Recording Mode", + translation_key="recording_mode", icon="mdi:eye", ), SensorEntityDescription( key="signal_strength_category", - name="WiFi Signal Category", + translation_key="wifi_signal_category", icon="mdi:wifi", ), SensorEntityDescription( key="signal_strength_percentage", - name="WiFi Signal Strength", + translation_key="wifi_signal_strength", native_unit_of_measurement=PERCENTAGE, icon="mdi:wifi", ), SensorEntityDescription( key="streaming", - name="Streaming Mode", + translation_key="streaming_mode", icon="mdi:camera", ), ) @@ -95,13 +97,13 @@ class LogiSensor(SensorEntity): """A sensor implementation for a Logi Circle camera.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__(self, camera, time_zone, description: SensorEntityDescription) -> None: """Initialize a sensor for Logi Circle camera.""" self.entity_description = description self._camera = camera self._attr_unique_id = f"{camera.mac_address}-{description.key}" - self._attr_name = f"{camera.name} {description.name}" self._activity: dict[Any, Any] = {} self._tz = time_zone @@ -135,10 +137,6 @@ class LogiSensor(SensorEntity): def icon(self): """Icon to use in the frontend, if any.""" sensor_type = self.entity_description.key - if sensor_type == "battery_level" and self._attr_native_value is not None: - return icon_for_battery_level( - battery_level=int(self._attr_native_value), charging=False - ) if sensor_type == "recording_mode" and self._attr_native_value is not None: return "mdi:eye" if self._attr_native_value == STATE_ON else "mdi:eye-off" if sensor_type == "streaming_mode" and self._attr_native_value is not None: diff --git a/homeassistant/components/logi_circle/strings.json b/homeassistant/components/logi_circle/strings.json index 4f641238a49..188139e6c29 100644 --- a/homeassistant/components/logi_circle/strings.json +++ b/homeassistant/components/logi_circle/strings.json @@ -25,6 +25,25 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]" } }, + "entity": { + "sensor": { + "last_activity": { + "name": "Last activity" + }, + "recording_mode": { + "name": "Recording mode" + }, + "wifi_signal_category": { + "name": "Wi-Fi signal category" + }, + "wifi_signal_strength": { + "name": "Wi-Fi signal strength" + }, + "streaming_mode": { + "name": "Streaming mode" + } + } + }, "services": { "set_config": { "name": "Set config", diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index c16d7f34f0f..7656de8d385 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -104,6 +104,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (asyncio.TimeoutError, aiohttp.ClientError, NoUsableService) as ex: raise ConfigEntryNotReady from ex + if entry.unique_id != (found_uuid := lookin_device.id.upper()): + # If the uuid of the device does not match the unique_id + # of the config entry, it likely means the DHCP lease has expired + # and the device has been assigned a new IP address. We need to + # wait for the next discovery to find the device at its new address + # and update the config entry so we do not mix up devices. + raise ConfigEntryNotReady( + f"Unexpected device found at {host}; expected {entry.unique_id}, " + f"found {found_uuid}" + ) + push_coordinator = LookinPushCoordinator(entry.title) if lookin_device.model >= 2: diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index a407afaa207..3e83fedb72a 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -118,6 +118,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class LyricEntity(CoordinatorEntity[DataUpdateCoordinator[Lyric]]): """Defines a base Honeywell Lyric entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: DataUpdateCoordinator[Lyric], diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index df90ebcd6cf..ef662d061e8 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -138,6 +138,8 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): coordinator: DataUpdateCoordinator[Lyric] entity_description: ClimateEntityDescription + _attr_name = None + def __init__( self, coordinator: DataUpdateCoordinator[Lyric], diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index 1e15ff58b18..d628a108183 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -86,7 +86,7 @@ async def async_setup_entry( coordinator, LyricSensorEntityDescription( key=f"{device.macID}_indoor_temperature", - name="Indoor Temperature", + translation_key="indoor_temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=native_temperature_unit, @@ -102,7 +102,7 @@ async def async_setup_entry( coordinator, LyricSensorEntityDescription( key=f"{device.macID}_indoor_humidity", - name="Indoor Humidity", + translation_key="indoor_humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, @@ -123,7 +123,7 @@ async def async_setup_entry( coordinator, LyricSensorEntityDescription( key=f"{device.macID}_outdoor_temperature", - name="Outdoor Temperature", + translation_key="outdoor_temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=native_temperature_unit, @@ -139,7 +139,7 @@ async def async_setup_entry( coordinator, LyricSensorEntityDescription( key=f"{device.macID}_outdoor_humidity", - name="Outdoor Humidity", + translation_key="outdoor_humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, @@ -156,7 +156,7 @@ async def async_setup_entry( coordinator, LyricSensorEntityDescription( key=f"{device.macID}_next_period_time", - name="Next Period Time", + translation_key="next_period_time", device_class=SensorDeviceClass.TIMESTAMP, value=lambda device: get_datetime_from_future_time( device.changeableValues.nextPeriodTime @@ -172,7 +172,7 @@ async def async_setup_entry( coordinator, LyricSensorEntityDescription( key=f"{device.macID}_setpoint_status", - name="Setpoint Status", + translation_key="setpoint_status", icon="mdi:thermostat", value=lambda device: get_setpoint_status( device.changeableValues.thermostatSetpointStatus, diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 2271d4201f6..219530a9747 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -18,6 +18,28 @@ "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, + "entity": { + "sensor": { + "indoor_temperature": { + "name": "Indoor temperature" + }, + "indoor_humidity": { + "name": "Indoor humidity" + }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, + "outdoor_humidity": { + "name": "Outdoor humidity" + }, + "next_period_time": { + "name": "Next period time" + }, + "setpoint_status": { + "name": "Setpoint status" + } + } + }, "services": { "set_hold_time": { "name": "Set Hold Time", diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index e7aea21875a..c697befd01f 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -16,8 +16,8 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, + CoordinatorWeatherEntity, Forecast, - WeatherEntity, WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -30,11 +30,10 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.unit_system import METRIC_SYSTEM from . import MetDataUpdateCoordinator @@ -92,7 +91,7 @@ def format_condition(condition: str) -> str: return condition -class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): +class MetWeather(CoordinatorWeatherEntity[MetDataUpdateCoordinator]): """Implementation of a Met.no weather condition.""" _attr_attribution = ( @@ -148,15 +147,6 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): """Return if the entity should be enabled when first added to the entity registry.""" return not self._hourly - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - super()._handle_coordinator_update() - assert self.platform.config_entry - self.platform.config_entry.async_create_task( - self.hass, self.async_update_listeners(("daily", "hourly")) - ) - @property def condition(self) -> str | None: """Return the current condition.""" diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index c40c89892c9..a69c1f24c08 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -7,8 +7,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_FORECAST_TIME, DOMAIN as WEATHER_DOMAIN, + CoordinatorWeatherEntity, Forecast, - WeatherEntity, WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -21,14 +21,11 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util from . import MetEireannWeatherData @@ -78,7 +75,7 @@ def _calculate_unique_id(config: MappingProxyType[str, Any], hourly: bool) -> st class MetEireannWeather( - CoordinatorEntity[DataUpdateCoordinator[MetEireannWeatherData]], WeatherEntity + CoordinatorWeatherEntity[DataUpdateCoordinator[MetEireannWeatherData]] ): """Implementation of a Met Éireann weather condition.""" @@ -98,15 +95,6 @@ class MetEireannWeather( self._config = config self._hourly = hourly - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - super()._handle_coordinator_update() - assert self.platform.config_entry - self.platform.config_entry.async_create_task( - self.hass, self.async_update_listeners(("daily", "hourly")) - ) - @property def name(self): """Return the name of the sensor.""" diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index b2e33a0f1f1..f5f88ea5f59 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -124,8 +124,7 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: if count < regs_needed or (count % regs_needed) != 0: raise vol.Invalid( f"Error in sensor {name} swap({swap_type}) " - "not possible due to the registers " - f"count: {count}, needed: {regs_needed}" + f"impossible because datatype({data_type}) is too small" ) structure = f">{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" if slave_count > 1: diff --git a/homeassistant/components/modem_callerid/button.py b/homeassistant/components/modem_callerid/button.py index 4b149deece3..5f9e4cf489c 100644 --- a/homeassistant/components/modem_callerid/button.py +++ b/homeassistant/components/modem_callerid/button.py @@ -7,6 +7,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_KEY_API, DOMAIN @@ -32,13 +33,15 @@ class PhoneModemButton(ButtonEntity): """Implementation of USB modem caller ID button.""" _attr_icon = "mdi:phone-hangup" - _attr_name = "Phone Modem Reject" + _attr_translation_key = "phone_modem_reject" + _attr_has_entity_name = True def __init__(self, api: PhoneModem, device: str, server_unique_id: str) -> None: """Initialize the button.""" self.device = device self.api = api self._attr_unique_id = server_unique_id + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, server_unique_id)}) async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index 1cb1043a5e0..c7c4403300a 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -7,6 +7,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_IDLE from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CID, DATA_KEY_API, DOMAIN, ICON @@ -21,7 +22,6 @@ async def async_setup_entry( [ ModemCalleridSensor( api, - entry.title, entry.entry_id, ) ] @@ -42,11 +42,12 @@ class ModemCalleridSensor(SensorEntity): _attr_icon = ICON _attr_should_poll = False + _attr_has_entity_name = True + _attr_name = None - def __init__(self, api: PhoneModem, name: str, server_unique_id: str) -> None: + def __init__(self, api: PhoneModem, server_unique_id: str) -> None: """Initialize the sensor.""" self.api = api - self._attr_name = name self._attr_unique_id = server_unique_id self._attr_native_value = STATE_IDLE self._attr_extra_state_attributes = { @@ -54,6 +55,7 @@ class ModemCalleridSensor(SensorEntity): CID.CID_NUMBER: "", CID.CID_NAME: "", } + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, server_unique_id)}) async def async_added_to_hass(self) -> None: """Call when the modem sensor is added to Home Assistant.""" diff --git a/homeassistant/components/modem_callerid/strings.json b/homeassistant/components/modem_callerid/strings.json index 2e18ba3654f..dd0af40fac1 100644 --- a/homeassistant/components/modem_callerid/strings.json +++ b/homeassistant/components/modem_callerid/strings.json @@ -20,5 +20,12 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "no_devices_found": "No remaining devices found" } + }, + "entity": { + "button": { + "phone_modem_reject": { + "name": "Phone modem reject" + } + } } } diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index b95cacc2d08..d5bda57c2b3 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -45,6 +45,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_conversion import TemperatureConverter @@ -77,6 +78,7 @@ from .const import ( CONF_TEMP_STATE_TEMPLATE, CONF_TEMP_STATE_TOPIC, DEFAULT_OPTIMISTIC, + DOMAIN, PAYLOAD_NONE, ) from .debug_info import log_messages @@ -92,8 +94,13 @@ from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) +MQTT_CLIMATE_AUX_DOCS = "https://www.home-assistant.io/integrations/climate.mqtt/" + DEFAULT_NAME = "MQTT HVAC" +# Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC +# and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 +# Support will be removed in HA Core 2024.3 CONF_AUX_COMMAND_TOPIC = "aux_command_topic" CONF_AUX_STATE_TEMPLATE = "aux_state_template" CONF_AUX_STATE_TOPIC = "aux_state_topic" @@ -255,6 +262,9 @@ def valid_humidity_state_configuration(config: ConfigType) -> ConfigType: _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( { + # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC + # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 + # Support will be removed in HA Core 2024.3 vol.Optional(CONF_AUX_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_AUX_STATE_TEMPLATE): cv.template, vol.Optional(CONF_AUX_STATE_TOPIC): valid_subscribe_topic, @@ -353,6 +363,12 @@ PLATFORM_SCHEMA_MODERN = vol.All( # was removed in HA Core 2023.8 cv.removed(CONF_POWER_STATE_TEMPLATE), cv.removed(CONF_POWER_STATE_TOPIC), + # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC + # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 + # Support will be removed in HA Core 2024.3 + cv.deprecated(CONF_AUX_COMMAND_TOPIC), + cv.deprecated(CONF_AUX_STATE_TEMPLATE), + cv.deprecated(CONF_AUX_STATE_TOPIC), _PLATFORM_SCHEMA_BASE, valid_preset_mode_configuration, valid_humidity_range_configuration, @@ -667,6 +683,9 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): self._attr_swing_mode = SWING_OFF if self._topic[CONF_MODE_STATE_TOPIC] is None or self._optimistic: self._attr_hvac_mode = HVACMode.OFF + # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC + # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 + # Support will be removed in HA Core 2024.3 if self._topic[CONF_AUX_STATE_TOPIC] is None or self._optimistic: self._attr_is_aux_heat = False self._feature_preset_mode = CONF_PRESET_MODE_COMMAND_TOPIC in config @@ -738,12 +757,32 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): if self._feature_preset_mode: support |= ClimateEntityFeature.PRESET_MODE + # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC + # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 + # Support will be removed in HA Core 2024.3 if (self._topic[CONF_AUX_STATE_TOPIC] is not None) or ( self._topic[CONF_AUX_COMMAND_TOPIC] is not None ): support |= ClimateEntityFeature.AUX_HEAT self._attr_supported_features = support + async def mqtt_async_added_to_hass(self) -> None: + """Handle deprecation issues.""" + if self._attr_supported_features & ClimateEntityFeature.AUX_HEAT: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_climate_aux_property_{self.entity_id}", + breaks_in_ha_version="2024.3.0", + is_fixable=False, + translation_key="deprecated_climate_aux_property", + translation_placeholders={ + "entity_id": self.entity_id, + }, + learn_more_url=MQTT_CLIMATE_AUX_DOCS, + severity=IssueSeverity.WARNING, + ) + def _prepare_subscribe_topics(self) -> None: # noqa: C901 """(Re)Subscribe to topics.""" topics: dict[str, dict[str, Any]] = {} @@ -876,6 +915,9 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC + # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 + # Support will be removed in HA Core 2024.3 @callback @log_messages(self.hass, self.entity_id) def handle_aux_mode_received(msg: ReceiveMessage) -> None: @@ -986,6 +1028,9 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): return + # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC + # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 + # Support will be removed in HA Core 2024.3 async def _set_aux_heat(self, state: bool) -> None: await self._publish( CONF_AUX_COMMAND_TOPIC, diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index fc87971064e..97ba96f0207 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -1144,11 +1144,8 @@ class MqttEntity( ) elif (device_name := config[CONF_DEVICE][CONF_NAME]) == entity_name: self._attr_name = None - self._issue_key = ( - "entity_name_is_device_name_discovery" - if self._discovery - else "entity_name_is_device_name_yaml" - ) + if not self._discovery: + self._issue_key = "entity_name_is_device_name_yaml" _LOGGER.warning( "MQTT device name is equal to entity name in your config %s, " "this is not expected. Please correct your configuration. " @@ -1162,11 +1159,8 @@ class MqttEntity( if device_name[:1].isupper(): # Ensure a capital if the device name first char is a capital new_entity_name = new_entity_name[:1].upper() + new_entity_name[1:] - self._issue_key = ( - "entity_name_startswith_device_name_discovery" - if self._discovery - else "entity_name_startswith_device_name_yaml" - ) + if not self._discovery: + self._issue_key = "entity_name_startswith_device_name_yaml" _LOGGER.warning( "MQTT entity name starts with the device name in your config %s, " "this is not expected. Please correct your configuration. " diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 516672c88ab..d1b63b331ed 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -16,13 +16,9 @@ "title": "Manual configured MQTT entities with a name that starts with the device name", "description": "Some MQTT entities have an entity name that starts with the device name. This is not expected. To avoid a duplicate name the device name prefix is stripped off the entity name as a work-a-round. Please update your configuration and restart Home Assistant to fix this issue. \n\nList of affected entities:\n\n{config}" }, - "entity_name_is_device_name_discovery": { - "title": "Discovered MQTT entities with a name that is equal to the device name", - "description": "Some MQTT entities have an entity name equal to the device name. This is not expected. The entity name is set to `null` as a work-a-round to avoid a duplicate name. Please inform the maintainer of the software application that supplies the affected entities to fix this issue.\n\nList of affected entities:\n\n{config}" - }, - "entity_name_startswith_device_name_discovery": { - "title": "Discovered entities with a name that starts with the device name", - "description": "Some MQTT entities have an entity name that starts with the device name. This is not expected. To avoid a duplicate name the device name prefix is stripped off the entity name as a work-a-round. Please inform the maintainer of the software application that supplies the affected entities to fix this issue. \n\nList of affected entities:\n\n{config}" + "deprecated_climate_aux_property": { + "title": "MQTT entities with auxiliary heat support found", + "description": "Entity `{entity_id}` has auxiliary heat support enabled, which has been deprecated for MQTT climate devices. Please adjust your configuration and remove deperated config options from your configration and restart HA to fix this issue." } }, "config": { diff --git a/homeassistant/components/neato/button.py b/homeassistant/components/neato/button.py index 4bbd9196932..8b23bbe4681 100644 --- a/homeassistant/components/neato/button.py +++ b/homeassistant/components/neato/button.py @@ -7,10 +7,10 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import NEATO_DOMAIN, NEATO_ROBOTS +from .const import NEATO_ROBOTS +from .entity import NeatoEntity async def async_setup_entry( @@ -22,10 +22,9 @@ async def async_setup_entry( async_add_entities(entities, True) -class NeatoDismissAlertButton(ButtonEntity): +class NeatoDismissAlertButton(NeatoEntity, ButtonEntity): """Representation of a dismiss_alert button entity.""" - _attr_has_entity_name = True _attr_translation_key = "dismiss_alert" _attr_entity_category = EntityCategory.CONFIG @@ -34,12 +33,8 @@ class NeatoDismissAlertButton(ButtonEntity): robot: Robot, ) -> None: """Initialize a dismiss_alert Neato button entity.""" - self.robot = robot + super().__init__(robot) self._attr_unique_id = f"{robot.serial}_dismiss_alert" - self._attr_device_info = DeviceInfo( - identifiers={(NEATO_DOMAIN, robot.serial)}, - name=robot.name, - ) async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py index 5b13d12d37f..c1513bb1de6 100644 --- a/homeassistant/components/neato/camera.py +++ b/homeassistant/components/neato/camera.py @@ -12,16 +12,10 @@ from urllib3.response import HTTPResponse from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - NEATO_DOMAIN, - NEATO_LOGIN, - NEATO_MAP_DATA, - NEATO_ROBOTS, - SCAN_INTERVAL_MINUTES, -) +from .const import NEATO_LOGIN, NEATO_MAP_DATA, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES +from .entity import NeatoEntity from .hub import NeatoHub _LOGGER = logging.getLogger(__name__) @@ -48,18 +42,17 @@ async def async_setup_entry( async_add_entities(dev, True) -class NeatoCleaningMap(Camera): +class NeatoCleaningMap(NeatoEntity, Camera): """Neato cleaning map for last clean.""" - _attr_has_entity_name = True _attr_translation_key = "cleaning_map" def __init__( self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any] | None ) -> None: """Initialize Neato cleaning map.""" - super().__init__() - self.robot = robot + super().__init__(robot) + Camera.__init__(self) self.neato = neato self._mapdata = mapdata self._available = neato is not None @@ -126,14 +119,6 @@ class NeatoCleaningMap(Camera): """Return if the robot is available.""" return self._available - @property - def device_info(self) -> DeviceInfo: - """Device info for neato robot.""" - return DeviceInfo( - identifiers={(NEATO_DOMAIN, self._robot_serial)}, - name=self.robot.name, - ) - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" diff --git a/homeassistant/components/neato/entity.py b/homeassistant/components/neato/entity.py new file mode 100644 index 00000000000..43072f19693 --- /dev/null +++ b/homeassistant/components/neato/entity.py @@ -0,0 +1,27 @@ +"""Base entity for Neato.""" +from __future__ import annotations + +from pybotvac import Robot + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import NEATO_DOMAIN + + +class NeatoEntity(Entity): + """Base Neato entity.""" + + _attr_has_entity_name = True + + def __init__(self, robot: Robot) -> None: + """Initialize Neato entity.""" + self.robot = robot + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + return DeviceInfo( + identifiers={(NEATO_DOMAIN, self.robot.serial)}, + name=self.robot.name, + ) diff --git a/homeassistant/components/neato/sensor.py b/homeassistant/components/neato/sensor.py index 60aeb52af05..452f1bc3a9c 100644 --- a/homeassistant/components/neato/sensor.py +++ b/homeassistant/components/neato/sensor.py @@ -12,10 +12,10 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import NEATO_DOMAIN, NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES +from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES +from .entity import NeatoEntity from .hub import NeatoHub _LOGGER = logging.getLogger(__name__) @@ -41,14 +41,12 @@ async def async_setup_entry( async_add_entities(dev, True) -class NeatoSensor(SensorEntity): +class NeatoSensor(NeatoEntity, SensorEntity): """Neato sensor.""" - _attr_has_entity_name = True - def __init__(self, neato: NeatoHub, robot: Robot) -> None: """Initialize Neato sensor.""" - self.robot = robot + super().__init__(robot) self._available: bool = False self._robot_serial: str = self.robot.serial self._state: dict[str, Any] | None = None @@ -100,11 +98,3 @@ class NeatoSensor(SensorEntity): def native_unit_of_measurement(self) -> str: """Return unit of measurement.""" return PERCENTAGE - - @property - def device_info(self) -> DeviceInfo: - """Device info for neato robot.""" - return DeviceInfo( - identifiers={(NEATO_DOMAIN, self._robot_serial)}, - name=self.robot.name, - ) diff --git a/homeassistant/components/neato/switch.py b/homeassistant/components/neato/switch.py index f6d159fcc1b..a80d05eef23 100644 --- a/homeassistant/components/neato/switch.py +++ b/homeassistant/components/neato/switch.py @@ -12,10 +12,10 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import NEATO_DOMAIN, NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES +from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES +from .entity import NeatoEntity from .hub import NeatoHub _LOGGER = logging.getLogger(__name__) @@ -45,16 +45,15 @@ async def async_setup_entry( async_add_entities(dev, True) -class NeatoConnectedSwitch(SwitchEntity): +class NeatoConnectedSwitch(NeatoEntity, SwitchEntity): """Neato Connected Switches.""" - _attr_has_entity_name = True _attr_translation_key = "schedule" def __init__(self, neato: NeatoHub, robot: Robot, switch_type: str) -> None: """Initialize the Neato Connected switches.""" + super().__init__(robot) self.type = switch_type - self.robot = robot self._available = False self._state: dict[str, Any] | None = None self._schedule_state: str | None = None @@ -109,14 +108,6 @@ class NeatoConnectedSwitch(SwitchEntity): """Device entity category.""" return EntityCategory.CONFIG - @property - def device_info(self) -> DeviceInfo: - """Device info for neato robot.""" - return DeviceInfo( - identifiers={(NEATO_DOMAIN, self._robot_serial)}, - name=self.robot.name, - ) - def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" if self.type == SWITCH_TYPE_SCHEDULE: diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index f70e79f3fc0..ecc39e515c2 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -30,13 +30,13 @@ from .const import ( ALERTS, ERRORS, MODE, - NEATO_DOMAIN, NEATO_LOGIN, NEATO_MAP_DATA, NEATO_PERSISTENT_MAPS, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES, ) +from .entity import NeatoEntity from .hub import NeatoHub _LOGGER = logging.getLogger(__name__) @@ -91,7 +91,7 @@ async def async_setup_entry( ) -class NeatoConnectedVacuum(StateVacuumEntity): +class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): """Representation of a Neato Connected Vacuum.""" _attr_icon = "mdi:robot-vacuum-variant" @@ -106,7 +106,6 @@ class NeatoConnectedVacuum(StateVacuumEntity): | VacuumEntityFeature.MAP | VacuumEntityFeature.LOCATE ) - _attr_has_entity_name = True _attr_name = None def __init__( @@ -117,7 +116,7 @@ class NeatoConnectedVacuum(StateVacuumEntity): persistent_maps: dict[str, Any] | None, ) -> None: """Initialize the Neato Connected Vacuum.""" - self.robot = robot + super().__init__(robot) self._attr_available: bool = neato is not None self._mapdata = mapdata self._robot_has_map: bool = self.robot.has_persistent_maps @@ -300,14 +299,12 @@ class NeatoConnectedVacuum(StateVacuumEntity): @property def device_info(self) -> DeviceInfo: """Device info for neato robot.""" - stats = self._robot_stats - return DeviceInfo( - identifiers={(NEATO_DOMAIN, self._robot_serial)}, - manufacturer=stats["battery"]["vendor"] if stats else None, - model=stats["model"] if stats else None, - name=self.robot.name, - sw_version=stats["firmware"] if stats else None, - ) + device_info = super().device_info + if self._robot_stats: + device_info["manufacturer"] = self._robot_stats["battery"]["vendor"] + device_info["model"] = self._robot_stats["model"] + device_info["sw_version"] = self._robot_stats["firmware"] + return device_info def start(self) -> None: """Start cleaning or resume cleaning.""" diff --git a/homeassistant/components/nexia/binary_sensor.py b/homeassistant/components/nexia/binary_sensor.py index 1b398610ac2..02c3fef1162 100644 --- a/homeassistant/components/nexia/binary_sensor.py +++ b/homeassistant/components/nexia/binary_sensor.py @@ -23,7 +23,7 @@ async def async_setup_entry( thermostat = nexia_home.get_thermostat_by_id(thermostat_id) entities.append( NexiaBinarySensor( - coordinator, thermostat, "is_blower_active", "Blower Active" + coordinator, thermostat, "is_blower_active", "blower_active" ) ) if thermostat.has_emergency_heat(): @@ -32,7 +32,7 @@ async def async_setup_entry( coordinator, thermostat, "is_emergency_heat_active", - "Emergency Heat Active", + "emergency_heat_active", ) ) @@ -42,16 +42,16 @@ async def async_setup_entry( class NexiaBinarySensor(NexiaThermostatEntity, BinarySensorEntity): """Provices Nexia BinarySensor support.""" - def __init__(self, coordinator, thermostat, sensor_call, sensor_name): + def __init__(self, coordinator, thermostat, sensor_call, translation_key): """Initialize the nexia sensor.""" super().__init__( coordinator, thermostat, - name=f"{thermostat.get_name()} {sensor_name}", unique_id=f"{thermostat.thermostat_id}_{sensor_call}", ) self._call = sensor_call self._state = None + self._attr_translation_key = translation_key @property def is_on(self): diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index fe31263a86c..e331108f6ba 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -150,13 +150,13 @@ async def async_setup_entry( class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): """Provides Nexia Climate support.""" + _attr_name = None + def __init__( self, coordinator: NexiaDataUpdateCoordinator, zone: NexiaThermostatZone ) -> None: """Initialize the thermostat.""" - super().__init__( - coordinator, zone, name=zone.get_name(), unique_id=zone.zone_id - ) + super().__init__(coordinator, zone, zone.zone_id) unit = self._thermostat.get_unit() min_humidity, max_humidity = self._thermostat.get_humidity_setpoint_limits() min_setpoint, max_setpoint = self._thermostat.get_setpoint_limits() diff --git a/homeassistant/components/nexia/entity.py b/homeassistant/components/nexia/entity.py index 2a09ec877c8..dfb2366d34a 100644 --- a/homeassistant/components/nexia/entity.py +++ b/homeassistant/components/nexia/entity.py @@ -31,21 +31,20 @@ class NexiaEntity(CoordinatorEntity[NexiaDataUpdateCoordinator]): _attr_attribution = ATTRIBUTION - def __init__( - self, coordinator: NexiaDataUpdateCoordinator, name: str, unique_id: str - ) -> None: + def __init__(self, coordinator: NexiaDataUpdateCoordinator, unique_id: str) -> None: """Initialize the entity.""" super().__init__(coordinator) self._attr_unique_id = unique_id - self._attr_name = name class NexiaThermostatEntity(NexiaEntity): """Base class for nexia devices attached to a thermostat.""" - def __init__(self, coordinator, thermostat, name, unique_id): + _attr_has_entity_name = True + + def __init__(self, coordinator, thermostat, unique_id): """Initialize the entity.""" - super().__init__(coordinator, name, unique_id) + super().__init__(coordinator, unique_id) self._thermostat: NexiaThermostat = thermostat self._attr_device_info = DeviceInfo( configuration_url=self.coordinator.nexia_home.root_url, @@ -89,9 +88,9 @@ class NexiaThermostatEntity(NexiaEntity): class NexiaThermostatZoneEntity(NexiaThermostatEntity): """Base class for nexia devices attached to a thermostat.""" - def __init__(self, coordinator, zone, name, unique_id): + def __init__(self, coordinator, zone, unique_id): """Initialize the entity.""" - super().__init__(coordinator, zone.thermostat, name, unique_id) + super().__init__(coordinator, zone.thermostat, unique_id) self._zone: NexiaThermostatZone = zone zone_name = self._zone.get_name() self._attr_device_info |= { diff --git a/homeassistant/components/nexia/number.py b/homeassistant/components/nexia/number.py index acb99c2ed01..b44c6a4c48f 100644 --- a/homeassistant/components/nexia/number.py +++ b/homeassistant/components/nexia/number.py @@ -42,6 +42,7 @@ class NexiaFanSpeedEntity(NexiaThermostatEntity, NumberEntity): _attr_native_unit_of_measurement = PERCENTAGE _attr_icon = "mdi:fan" + _attr_translation_key = "fan_speed" def __init__( self, @@ -53,7 +54,6 @@ class NexiaFanSpeedEntity(NexiaThermostatEntity, NumberEntity): super().__init__( coordinator, thermostat, - name=f"{thermostat.get_name()} Fan speed", unique_id=f"{thermostat.thermostat_id}_fan_speed_setpoint", ) min_value, max_value = valid_range diff --git a/homeassistant/components/nexia/scene.py b/homeassistant/components/nexia/scene.py index 941785f8221..3a21c61badd 100644 --- a/homeassistant/components/nexia/scene.py +++ b/homeassistant/components/nexia/scene.py @@ -43,9 +43,9 @@ class NexiaAutomationScene(NexiaEntity, Scene): """Initialize the automation scene.""" super().__init__( coordinator, - name=automation.name, - unique_id=automation.automation_id, + automation.automation_id, ) + self._attr_name = automation.name self._automation: NexiaAutomation = automation self._attr_extra_state_attributes = {ATTR_DESCRIPTION: automation.description} diff --git a/homeassistant/components/nexia/sensor.py b/homeassistant/components/nexia/sensor.py index a67ac681199..79e07bc71b4 100644 --- a/homeassistant/components/nexia/sensor.py +++ b/homeassistant/components/nexia/sensor.py @@ -40,7 +40,7 @@ async def async_setup_entry( coordinator, thermostat, "get_system_status", - "System Status", + "system_status", None, None, None, @@ -52,7 +52,7 @@ async def async_setup_entry( coordinator, thermostat, "get_air_cleaner_mode", - "Air Cleaner Mode", + "air_cleaner_mode", None, None, None, @@ -65,7 +65,7 @@ async def async_setup_entry( coordinator, thermostat, "get_current_compressor_speed", - "Current Compressor Speed", + "current_compressor_speed", None, PERCENTAGE, SensorStateClass.MEASUREMENT, @@ -77,7 +77,7 @@ async def async_setup_entry( coordinator, thermostat, "get_requested_compressor_speed", - "Requested Compressor Speed", + "requested_compressor_speed", None, PERCENTAGE, SensorStateClass.MEASUREMENT, @@ -95,7 +95,7 @@ async def async_setup_entry( coordinator, thermostat, "get_outdoor_temperature", - "Outdoor Temperature", + "outdoor_temperature", SensorDeviceClass.TEMPERATURE, unit, SensorStateClass.MEASUREMENT, @@ -108,7 +108,7 @@ async def async_setup_entry( coordinator, thermostat, "get_relative_humidity", - "Relative Humidity", + None, SensorDeviceClass.HUMIDITY, PERCENTAGE, SensorStateClass.MEASUREMENT, @@ -129,7 +129,7 @@ async def async_setup_entry( coordinator, zone, "get_temperature", - "Temperature", + None, SensorDeviceClass.TEMPERATURE, unit, SensorStateClass.MEASUREMENT, @@ -139,7 +139,7 @@ async def async_setup_entry( # Zone Status entities.append( NexiaThermostatZoneSensor( - coordinator, zone, "get_status", "Zone Status", None, None, None + coordinator, zone, "get_status", "zone_status", None, None, None ) ) # Setpoint Status @@ -148,7 +148,7 @@ async def async_setup_entry( coordinator, zone, "get_setpoint_status", - "Zone Setpoint Status", + "zone_setpoint_status", None, None, None, @@ -166,7 +166,7 @@ class NexiaThermostatSensor(NexiaThermostatEntity, SensorEntity): coordinator, thermostat, sensor_call, - sensor_name, + translation_key, sensor_class, sensor_unit, state_class, @@ -176,7 +176,6 @@ class NexiaThermostatSensor(NexiaThermostatEntity, SensorEntity): super().__init__( coordinator, thermostat, - name=f"{thermostat.get_name()} {sensor_name}", unique_id=f"{thermostat.thermostat_id}_{sensor_call}", ) self._call = sensor_call @@ -184,6 +183,8 @@ class NexiaThermostatSensor(NexiaThermostatEntity, SensorEntity): self._attr_device_class = sensor_class self._attr_native_unit_of_measurement = sensor_unit self._attr_state_class = state_class + if translation_key is not None: + self._attr_translation_key = translation_key @property def native_value(self): @@ -204,7 +205,7 @@ class NexiaThermostatZoneSensor(NexiaThermostatZoneEntity, SensorEntity): coordinator, zone, sensor_call, - sensor_name, + translation_key, sensor_class, sensor_unit, state_class, @@ -215,7 +216,6 @@ class NexiaThermostatZoneSensor(NexiaThermostatZoneEntity, SensorEntity): super().__init__( coordinator, zone, - name=f"{zone.get_name()} {sensor_name}", unique_id=f"{zone.zone_id}_{sensor_call}", ) self._call = sensor_call @@ -223,6 +223,8 @@ class NexiaThermostatZoneSensor(NexiaThermostatZoneEntity, SensorEntity): self._attr_device_class = sensor_class self._attr_native_unit_of_measurement = sensor_unit self._attr_state_class = state_class + if translation_key is not None: + self._attr_translation_key = translation_key @property def native_value(self): diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index f3d343ffda3..9e49f4bb793 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -18,6 +18,49 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "entity": { + "binary_sensor": { + "blower_active": { + "name": "Blower active" + }, + "emergency_heat_active": { + "name": "Emergency heat active" + } + }, + "number": { + "fan_speed": { + "name": "Fan speed" + } + }, + "sensor": { + "system_status": { + "name": "System status" + }, + "air_cleaner_mode": { + "name": "Air cleaner mode" + }, + "current_compressor_speed": { + "name": "Current compressor speed" + }, + "requested_compressor_speed": { + "name": "Requested compressor speed" + }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, + "zone_status": { + "name": "Zone status" + }, + "zone_setpoint_status": { + "name": "Zone setpoint status" + } + }, + "switch": { + "hold": { + "name": "Hold" + } + } + }, "services": { "set_aircleaner_mode": { "name": "Set air cleaner mode", diff --git a/homeassistant/components/nexia/switch.py b/homeassistant/components/nexia/switch.py index 643a4d585c4..7f191d39c73 100644 --- a/homeassistant/components/nexia/switch.py +++ b/homeassistant/components/nexia/switch.py @@ -39,13 +39,14 @@ async def async_setup_entry( class NexiaHoldSwitch(NexiaThermostatZoneEntity, SwitchEntity): """Provides Nexia hold switch support.""" + _attr_translation_key = "hold" + def __init__( self, coordinator: NexiaDataUpdateCoordinator, zone: NexiaThermostatZone ) -> None: """Initialize the hold mode switch.""" - switch_name = f"{zone.get_name()} Hold" zone_id = zone.zone_id - super().__init__(coordinator, zone, name=switch_name, unique_id=zone_id) + super().__init__(coordinator, zone, zone_id) @property def is_on(self) -> bool: diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index dfb556deeb5..4ac2518ffb6 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -16,6 +16,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import ( _LOGGER, + ALL_MATCH_REGEX, + CONF_AREA_FILTER, CONF_FILTER_CORONA, CONF_HEADLINE_FILTER, CONF_REGIONS, @@ -42,8 +44,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_data.pop(CONF_FILTER_CORONA, None) hass.config_entries.async_update_entry(entry, data=new_data) + if CONF_AREA_FILTER not in entry.data: + new_data = {**entry.data, CONF_AREA_FILTER: ALL_MATCH_REGEX} + hass.config_entries.async_update_entry(entry, data=new_data) + coordinator = NINADataUpdateCoordinator( - hass, regions, entry.data[CONF_HEADLINE_FILTER] + hass, + regions, + entry.data[CONF_HEADLINE_FILTER], + entry.data[CONF_AREA_FILTER], ) await coordinator.async_config_entry_first_refresh() @@ -77,6 +86,7 @@ class NinaWarningData: sender: str severity: str recommended_actions: str + affected_areas: str sent: str start: str expires: str @@ -89,12 +99,17 @@ class NINADataUpdateCoordinator( """Class to manage fetching NINA data API.""" def __init__( - self, hass: HomeAssistant, regions: dict[str, str], headline_filter: str + self, + hass: HomeAssistant, + regions: dict[str, str], + headline_filter: str, + area_filter: str, ) -> None: """Initialize.""" self._regions: dict[str, str] = regions self._nina: Nina = Nina(async_get_clientsession(hass)) self.headline_filter: str = headline_filter + self.area_filter: str = area_filter for region in regions: self._nina.addRegion(region) @@ -147,6 +162,21 @@ class NINADataUpdateCoordinator( if re.search( self.headline_filter, raw_warn.headline, flags=re.IGNORECASE ): + _LOGGER.debug( + f"Ignore warning ({raw_warn.id}) by headline filter ({self.headline_filter}) with headline: {raw_warn.headline}" + ) + continue + + affected_areas_string: str = ", ".join( + [str(area) for area in raw_warn.affected_areas] + ) + + if not re.search( + self.area_filter, affected_areas_string, flags=re.IGNORECASE + ): + _LOGGER.debug( + f"Ignore warning ({raw_warn.id}) by area filter ({self.area_filter}) with area: {affected_areas_string}" + ) continue warning_data: NinaWarningData = NinaWarningData( @@ -156,6 +186,7 @@ class NINADataUpdateCoordinator( raw_warn.sender, raw_warn.severity, " ".join([str(action) for action in raw_warn.recommended_actions]), + affected_areas_string, raw_warn.sent or "", raw_warn.start or "", raw_warn.expires or "", diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index 24d6d35d0e8..19f802f1cec 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -14,6 +14,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NINADataUpdateCoordinator from .const import ( + ATTR_AFFECTED_AREAS, ATTR_DESCRIPTION, ATTR_EXPIRES, ATTR_HEADLINE, @@ -73,7 +74,7 @@ class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEnti @property def is_on(self) -> bool: """Return the state of the sensor.""" - if not len(self.coordinator.data[self._region]) > self._warning_index: + if len(self.coordinator.data[self._region]) <= self._warning_index: return False data = self.coordinator.data[self._region][self._warning_index] @@ -94,6 +95,7 @@ class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEnti ATTR_SENDER: data.sender, ATTR_SEVERITY: data.severity, ATTR_RECOMMENDED_ACTIONS: data.recommended_actions, + ATTR_AFFECTED_AREAS: data.affected_areas, ATTR_ID: data.id, ATTR_SENT: data.sent, ATTR_START: data.start, diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index d41fa6dee3e..9c6de40ac6b 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -18,6 +18,7 @@ from homeassistant.helpers.entity_registry import ( from .const import ( _LOGGER, + CONF_AREA_FILTER, CONF_HEADLINE_FILTER, CONF_MESSAGE_SLOTS, CONF_REGIONS, @@ -263,6 +264,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_HEADLINE_FILTER, default=self.data[CONF_HEADLINE_FILTER], ): cv.string, + vol.Optional( + CONF_AREA_FILTER, + default=self.data[CONF_AREA_FILTER], + ): cv.string, } ), errors=errors, diff --git a/homeassistant/components/nina/const.py b/homeassistant/components/nina/const.py index 36096d97dc1..198e21c2689 100644 --- a/homeassistant/components/nina/const.py +++ b/homeassistant/components/nina/const.py @@ -12,17 +12,20 @@ SCAN_INTERVAL: timedelta = timedelta(minutes=5) DOMAIN: str = "nina" NO_MATCH_REGEX: str = "/(?!)/" +ALL_MATCH_REGEX: str = ".*" CONF_REGIONS: str = "regions" CONF_MESSAGE_SLOTS: str = "slots" CONF_FILTER_CORONA: str = "corona_filter" # deprecated CONF_HEADLINE_FILTER: str = "headline_filter" +CONF_AREA_FILTER: str = "area_filter" ATTR_HEADLINE: str = "headline" ATTR_DESCRIPTION: str = "description" ATTR_SENDER: str = "sender" ATTR_SEVERITY: str = "severity" ATTR_RECOMMENDED_ACTIONS: str = "recommended_actions" +ATTR_AFFECTED_AREAS: str = "affected_areas" ATTR_ID: str = "id" ATTR_SENT: str = "sent" ATTR_START: str = "start" diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json index e145f5ea8ca..5e0393d024f 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -36,7 +36,8 @@ "_r_to_u": "[%key:component::nina::config::step::user::data::_r_to_u%]", "_v_to_z": "[%key:component::nina::config::step::user::data::_v_to_z%]", "slots": "[%key:component::nina::config::step::user::data::slots%]", - "headline_filter": "[%key:component::nina::config::step::user::data::headline_filter%]" + "headline_filter": "[%key:component::nina::config::step::user::data::headline_filter%]", + "area_filter": "Whitelist regex to filter warnings based on affected areas" } } }, diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index f0f2a12cfec..a6af045776f 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -90,7 +90,6 @@ class NwsDataUpdateCoordinator(DataUpdateCoordinator[None]): # the base class allows None, but this one doesn't assert self.update_interval is not None update_interval = self.update_interval - self.last_update_success_time = utcnow() else: update_interval = self.failed_update_interval self._unsub_refresh = async_track_point_in_utc_time( @@ -99,6 +98,23 @@ class NwsDataUpdateCoordinator(DataUpdateCoordinator[None]): utcnow().replace(microsecond=0) + update_interval, ) + async def _async_refresh( + self, + log_failures: bool = True, + raise_on_auth_failed: bool = False, + scheduled: bool = False, + raise_on_entry_error: bool = False, + ) -> None: + """Refresh data.""" + await super()._async_refresh( + log_failures, + raise_on_auth_failed, + scheduled, + raise_on_entry_error, + ) + if self.last_update_success: + self.last_update_success_time = utcnow() + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a National Weather Service entry.""" diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index 5db541106b9..1e028649d89 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from typing import Final from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, @@ -25,7 +26,7 @@ CONF_STATION = "station" ATTRIBUTION = "Data from National Weather Service/NOAA" -ATTR_FORECAST_DETAILED_DESCRIPTION = "detailed_description" +ATTR_FORECAST_DETAILED_DESCRIPTION: Final = "detailed_description" CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_EXCEPTIONAL: [ diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 0e5fd412e0c..dec7e9bf3b3 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -1,8 +1,9 @@ """Support for NWS weather service.""" from __future__ import annotations +from collections.abc import Callable from types import MappingProxyType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal, cast from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, @@ -16,8 +17,10 @@ from homeassistant.components.weather import ( ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, + DOMAIN as WEATHER_DOMAIN, Forecast, WeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -29,13 +32,19 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter -from homeassistant.util.unit_system import UnitSystem -from . import NWSData, base_unique_id, device_info +from . import ( + DEFAULT_SCAN_INTERVAL, + NWSData, + NwsDataUpdateCoordinator, + base_unique_id, + device_info, +) from .const import ( ATTR_FORECAST_DETAILED_DESCRIPTION, ATTRIBUTION, @@ -80,15 +89,20 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the NWS weather platform.""" + entity_registry = er.async_get(hass) nws_data: NWSData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - [ - NWSWeather(entry.data, nws_data, DAYNIGHT, hass.config.units), - NWSWeather(entry.data, nws_data, HOURLY, hass.config.units), - ], - False, - ) + entities = [NWSWeather(entry.data, nws_data, DAYNIGHT)] + + # Add hourly entity to legacy config entries + if entity_registry.async_get_entity_id( + WEATHER_DOMAIN, + DOMAIN, + _calculate_unique_id(entry.data, HOURLY), + ): + entities.append(NWSWeather(entry.data, nws_data, HOURLY)) + + async_add_entities(entities, False) if TYPE_CHECKING: @@ -99,34 +113,51 @@ if TYPE_CHECKING: detailed_description: str | None +def _calculate_unique_id(entry_data: MappingProxyType[str, Any], mode: str) -> str: + """Calculate unique ID.""" + latitude = entry_data[CONF_LATITUDE] + longitude = entry_data[CONF_LONGITUDE] + return f"{base_unique_id(latitude, longitude)}_{mode}" + + class NWSWeather(WeatherEntity): """Representation of a weather condition.""" _attr_attribution = ATTRIBUTION _attr_should_poll = False + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_HOURLY | WeatherEntityFeature.FORECAST_TWICE_DAILY + ) def __init__( self, entry_data: MappingProxyType[str, Any], nws_data: NWSData, mode: str, - units: UnitSystem, ) -> None: """Initialise the platform with a data instance and station name.""" self.nws = nws_data.api self.latitude = entry_data[CONF_LATITUDE] self.longitude = entry_data[CONF_LONGITUDE] + self.coordinator_forecast_hourly = nws_data.coordinator_forecast_hourly + self.coordinator_forecast_twice_daily = nws_data.coordinator_forecast self.coordinator_observation = nws_data.coordinator_observation if mode == DAYNIGHT: - self.coordinator_forecast = nws_data.coordinator_forecast + self.coordinator_forecast_legacy = nws_data.coordinator_forecast else: - self.coordinator_forecast = nws_data.coordinator_forecast_hourly + self.coordinator_forecast_legacy = nws_data.coordinator_forecast_hourly self.station = self.nws.station + self._unsub_hourly_forecast: Callable[[], None] | None = None + self._unsub_twice_daily_forecast: Callable[[], None] | None = None self.mode = mode - self.observation = None - self._forecast = None + self.observation: dict[str, Any] | None = None + self._forecast_hourly: list[dict[str, Any]] | None = None + self._forecast_legacy: list[dict[str, Any]] | None = None + self._forecast_twice_daily: list[dict[str, Any]] | None = None + + self._attr_unique_id = _calculate_unique_id(entry_data, mode) async def async_added_to_hass(self) -> None: """Set up a listener and load data.""" @@ -134,20 +165,72 @@ class NWSWeather(WeatherEntity): self.coordinator_observation.async_add_listener(self._update_callback) ) self.async_on_remove( - self.coordinator_forecast.async_add_listener(self._update_callback) + self.coordinator_forecast_legacy.async_add_listener(self._update_callback) ) + self.async_on_remove(self._remove_hourly_forecast_listener) + self.async_on_remove(self._remove_twice_daily_forecast_listener) self._update_callback() + def _remove_hourly_forecast_listener(self) -> None: + """Remove hourly forecast listener.""" + if self._unsub_hourly_forecast: + self._unsub_hourly_forecast() + self._unsub_hourly_forecast = None + + def _remove_twice_daily_forecast_listener(self) -> None: + """Remove hourly forecast listener.""" + if self._unsub_twice_daily_forecast: + self._unsub_twice_daily_forecast() + self._unsub_twice_daily_forecast = None + + @callback + def _async_subscription_started( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + ) -> None: + """Start subscription to forecast_type.""" + if forecast_type == "hourly" and self.mode == DAYNIGHT: + self._unsub_hourly_forecast = ( + self.coordinator_forecast_hourly.async_add_listener( + self._update_callback + ) + ) + return + if forecast_type == "twice_daily" and self.mode == HOURLY: + self._unsub_twice_daily_forecast = ( + self.coordinator_forecast_twice_daily.async_add_listener( + self._update_callback + ) + ) + return + + @callback + def _async_subscription_ended( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + ) -> None: + """End subscription to forecast_type.""" + if forecast_type == "hourly" and self.mode == DAYNIGHT: + self._remove_hourly_forecast_listener() + if forecast_type == "twice_daily" and self.mode == HOURLY: + self._remove_twice_daily_forecast_listener() + @callback def _update_callback(self) -> None: """Load data from integration.""" self.observation = self.nws.observation + self._forecast_hourly = self.nws.forecast_hourly + self._forecast_twice_daily = self.nws.forecast if self.mode == DAYNIGHT: - self._forecast = self.nws.forecast + self._forecast_legacy = self.nws.forecast else: - self._forecast = self.nws.forecast_hourly + self._forecast_legacy = self.nws.forecast_hourly self.async_write_ha_state() + assert self.platform.config_entry + self.platform.config_entry.async_create_task( + self.hass, self.async_update_listeners(("hourly", "twice_daily")) + ) @property def name(self) -> str: @@ -210,7 +293,7 @@ class NWSWeather(WeatherEntity): weather = None if self.observation: weather = self.observation.get("iconWeather") - time = self.observation.get("iconTime") + time = cast(str, self.observation.get("iconTime")) if weather: return convert_condition(time, weather) @@ -228,18 +311,19 @@ class NWSWeather(WeatherEntity): """Return visibility unit.""" return UnitOfLength.METERS - @property - def forecast(self) -> list[Forecast] | None: + def _forecast( + self, nws_forecast: list[dict[str, Any]] | None, mode: str + ) -> list[Forecast] | None: """Return forecast.""" - if self._forecast is None: + if nws_forecast is None: return None - forecast: list[NWSForecast] = [] - for forecast_entry in self._forecast: - data = { + forecast: list[Forecast] = [] + for forecast_entry in nws_forecast: + data: NWSForecast = { ATTR_FORECAST_DETAILED_DESCRIPTION: forecast_entry.get( "detailedForecast" ), - ATTR_FORECAST_TIME: forecast_entry.get("startTime"), + ATTR_FORECAST_TIME: cast(str, forecast_entry.get("startTime")), } if (temp := forecast_entry.get("temperature")) is not None: @@ -262,7 +346,7 @@ class NWSWeather(WeatherEntity): data[ATTR_FORECAST_HUMIDITY] = forecast_entry.get("relativeHumidity") - if self.mode == DAYNIGHT: + if mode == DAYNIGHT: data[ATTR_FORECAST_IS_DAYTIME] = forecast_entry.get("isDaytime") time = forecast_entry.get("iconTime") @@ -285,25 +369,56 @@ class NWSWeather(WeatherEntity): return forecast @property - def unique_id(self) -> str: - """Return a unique_id for this entity.""" - return f"{base_unique_id(self.latitude, self.longitude)}_{self.mode}" + def forecast(self) -> list[Forecast] | None: + """Return forecast.""" + return self._forecast(self._forecast_legacy, self.mode) + + async def _async_forecast( + self, + coordinator: NwsDataUpdateCoordinator, + nws_forecast: list[dict[str, Any]] | None, + mode: str, + ) -> list[Forecast] | None: + """Refresh stale forecast and return it in native units.""" + if ( + not (last_success_time := coordinator.last_update_success_time) + or utcnow() - last_success_time >= DEFAULT_SCAN_INTERVAL + ): + await coordinator.async_refresh() + if ( + not (last_success_time := coordinator.last_update_success_time) + or utcnow() - last_success_time >= FORECAST_VALID_TIME + ): + return None + return self._forecast(nws_forecast, mode) + + async def async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + coordinator = self.coordinator_forecast_hourly + return await self._async_forecast(coordinator, self._forecast_hourly, HOURLY) + + async def async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the twice daily forecast in native units.""" + coordinator = self.coordinator_forecast_twice_daily + return await self._async_forecast( + coordinator, self._forecast_twice_daily, DAYNIGHT + ) @property def available(self) -> bool: """Return if state is available.""" last_success = ( self.coordinator_observation.last_update_success - and self.coordinator_forecast.last_update_success + and self.coordinator_forecast_legacy.last_update_success ) if ( self.coordinator_observation.last_update_success_time - and self.coordinator_forecast.last_update_success_time + and self.coordinator_forecast_legacy.last_update_success_time ): last_success_time = ( utcnow() - self.coordinator_observation.last_update_success_time < OBSERVATION_VALID_TIME - and utcnow() - self.coordinator_forecast.last_update_success_time + and utcnow() - self.coordinator_forecast_legacy.last_update_success_time < FORECAST_VALID_TIME ) else: @@ -316,7 +431,7 @@ class NWSWeather(WeatherEntity): Only used by the generic entity update service. """ await self.coordinator_observation.async_request_refresh() - await self.coordinator_forecast.async_request_refresh() + await self.coordinator_forecast_legacy.async_request_refresh() @property def entity_registry_enabled_default(self) -> bool: diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index 6d94ef35456..7f4d31c3adf 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -13,6 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from . import NZBGetEntity @@ -32,7 +33,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="AverageDownloadRate", name="Average Speed", device_class=SensorDeviceClass.DATA_RATE, - native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, ), SensorEntityDescription( key="DownloadPaused", @@ -42,7 +44,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="DownloadRate", name="Speed", device_class=SensorDeviceClass.DATA_RATE, - native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, ), SensorEntityDescription( key="DownloadedSizeMB", @@ -80,7 +83,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="DownloadLimit", name="Speed Limit", device_class=SensorDeviceClass.DATA_RATE, - native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, ), ) @@ -121,30 +125,17 @@ class NZBGetSensor(NZBGetEntity, SensorEntity): self.entity_description = description self._attr_unique_id = f"{entry_id}_{description.key}" - self._native_value: datetime | None = None @property - def native_value(self): + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" sensor_type = self.entity_description.key value = self.coordinator.data["status"].get(sensor_type) - if value is None: - _LOGGER.warning("Unable to locate value for %s", sensor_type) - self._native_value = None - elif "DownloadRate" in sensor_type and value > 0: - # Convert download rate from Bytes/s to MBytes/s - self._native_value = round(value / 2**20, 2) - elif "DownloadLimit" in sensor_type and value > 0: - # Convert download rate from Bytes/s to MBytes/s - self._native_value = round(value / 2**20, 2) - elif "UpTimeSec" in sensor_type and value > 0: + if value is not None and "UpTimeSec" in sensor_type and value > 0: uptime = utcnow().replace(microsecond=0) - timedelta(seconds=value) if not isinstance(self._attr_native_value, datetime) or abs( uptime - self._attr_native_value ) > timedelta(seconds=5): - self._native_value = uptime - else: - self._native_value = value - - return self._native_value + return uptime + return value diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index 1ca0dc1f5d5..07b2fa1a15d 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -5,6 +5,7 @@ from datetime import timedelta import logging from typing import cast +import aiohttp from pyoctoprintapi import ApiError, OctoprintClient, PrinterOffline from pyoctoprintapi.exceptions import UnauthorizedException import voluptuous as vol @@ -22,11 +23,11 @@ from homeassistant.const import ( CONF_SENSORS, CONF_SSL, CONF_VERIFY_SSL, + EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType @@ -163,14 +164,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data = {**entry.data, CONF_VERIFY_SSL: True} hass.config_entries.async_update_entry(entry, data=data) - verify_ssl = entry.data[CONF_VERIFY_SSL] - websession = async_get_clientsession(hass, verify_ssl=verify_ssl) + connector = aiohttp.TCPConnector( + force_close=True, + ssl=False if not entry.data[CONF_VERIFY_SSL] else None, + ) + session = aiohttp.ClientSession(connector=connector) + + @callback + def _async_close_websession(event: Event) -> None: + """Close websession.""" + session.detach() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close_websession) + client = OctoprintClient( - entry.data[CONF_HOST], - websession, - entry.data[CONF_PORT], - entry.data[CONF_SSL], - entry.data[CONF_PATH], + host=entry.data[CONF_HOST], + session=session, + port=entry.data[CONF_PORT], + ssl=entry.data[CONF_SSL], + path=entry.data[CONF_PATH], ) client.set_api_key(entry.data[CONF_API_KEY]) diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 33aaff8976e..09ac53ecf5b 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -6,6 +6,7 @@ from collections.abc import Mapping import logging from typing import Any +import aiohttp from pyoctoprintapi import ApiError, OctoprintClient, OctoprintException import voluptuous as vol from yarl import URL @@ -22,7 +23,6 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from .const import DOMAIN @@ -58,6 +58,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for OctoPrint.""" self.discovery_schema = None self._user_input = None + self._sessions: list[aiohttp.ClientSession] = [] async def async_step_user(self, user_input=None): """Handle the initial step.""" @@ -260,14 +261,26 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def _get_octoprint_client(self, user_input: dict) -> OctoprintClient: """Build an octoprint client from the user_input.""" verify_ssl = user_input.get(CONF_VERIFY_SSL, True) - session = async_get_clientsession(self.hass, verify_ssl=verify_ssl) - return OctoprintClient( - user_input[CONF_HOST], - session, - user_input[CONF_PORT], - user_input[CONF_SSL], - user_input[CONF_PATH], + + connector = aiohttp.TCPConnector( + force_close=True, + ssl=False if not verify_ssl else None, ) + session = aiohttp.ClientSession(connector=connector) + self._sessions.append(session) + + return OctoprintClient( + host=user_input[CONF_HOST], + session=session, + port=user_input[CONF_PORT], + ssl=user_input[CONF_SSL], + path=user_input[CONF_PATH], + ) + + def async_remove(self): + """Detach the session.""" + for session in self._sessions: + session.detach() class CannotConnect(exceptions.HomeAssistantError): diff --git a/homeassistant/components/octoprint/manifest.json b/homeassistant/components/octoprint/manifest.json index e4bc70e5d86..005cf5305d9 100644 --- a/homeassistant/components/octoprint/manifest.json +++ b/homeassistant/components/octoprint/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/octoprint", "iot_class": "local_polling", "loggers": ["pyoctoprintapi"], - "requirements": ["pyoctoprintapi==0.1.11"], + "requirements": ["pyoctoprintapi==0.1.12"], "ssdp": [ { "manufacturer": "The OctoPrint Project", diff --git a/homeassistant/components/oncue/entity.py b/homeassistant/components/oncue/entity.py index 0572cf6fb99..6d988d4aaaf 100644 --- a/homeassistant/components/oncue/entity.py +++ b/homeassistant/components/oncue/entity.py @@ -20,6 +20,8 @@ class OncueEntity( ): """Representation of an Oncue entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: DataUpdateCoordinator[dict[str, OncueDevice]], @@ -33,7 +35,7 @@ class OncueEntity( self.entity_description = description self._device_id = device_id self._attr_unique_id = f"{device_id}_{description.key}" - self._attr_name = f"{device.name} {sensor.display_name}" + self._attr_name = sensor.display_name self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device_id)}, name=device.name, diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py index b1785ed0ef5..b874e066031 100644 --- a/homeassistant/components/open_meteo/weather.py +++ b/homeassistant/components/open_meteo/weather.py @@ -3,16 +3,17 @@ from __future__ import annotations from open_meteo import Forecast as OpenMeteoForecast -from homeassistant.components.weather import Forecast, WeatherEntity +from homeassistant.components.weather import ( + CoordinatorWeatherEntity, + Forecast, + WeatherEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPrecipitationDepth, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, WMO_TO_HA_CONDITION_MAP @@ -28,7 +29,7 @@ async def async_setup_entry( class OpenMeteoWeatherEntity( - CoordinatorEntity[DataUpdateCoordinator[OpenMeteoForecast]], WeatherEntity + CoordinatorWeatherEntity[DataUpdateCoordinator[OpenMeteoForecast]] ): """Defines an Open-Meteo weather entity.""" @@ -37,6 +38,7 @@ class OpenMeteoWeatherEntity( _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_supported_features = WeatherEntityFeature.FORECAST_DAILY def __init__( self, @@ -121,3 +123,7 @@ class OpenMeteoWeatherEntity( forecasts.append(forecast) return forecasts + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return self.forecast diff --git a/homeassistant/components/openexchangerates/sensor.py b/homeassistant/components/openexchangerates/sensor.py index c7806fd90d8..70f2f670de8 100644 --- a/homeassistant/components/openexchangerates/sensor.py +++ b/homeassistant/components/openexchangerates/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_QUOTE +from homeassistant.const import CONF_QUOTE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -21,14 +21,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Open Exchange Rates sensor.""" - # Only YAML imported configs have name and quote in config entry data. - name: str | None = config_entry.data.get(CONF_NAME) quote: str = config_entry.data.get(CONF_QUOTE, "EUR") coordinator = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( OpenexchangeratesSensor( - config_entry, coordinator, name, rate_quote, rate_quote == quote + config_entry, coordinator, rate_quote, rate_quote == quote ) for rate_quote in coordinator.data.rates ) @@ -39,13 +37,13 @@ class OpenexchangeratesSensor( ): """Representation of an Open Exchange Rates sensor.""" + _attr_has_entity_name = True _attr_attribution = ATTRIBUTION def __init__( self, config_entry: ConfigEntry, coordinator: OpenexchangeratesCoordinator, - name: str | None, quote: str, enabled: bool, ) -> None: @@ -58,14 +56,7 @@ class OpenexchangeratesSensor( name=f"Open Exchange Rates {coordinator.base}", ) self._attr_entity_registry_enabled_default = enabled - if name and enabled: - # name is legacy imported from YAML config - # this block can be removed when removing import from YAML - self._attr_name = name - self._attr_has_entity_name = False - else: - self._attr_name = quote - self._attr_has_entity_name = True + self._attr_name = quote self._attr_native_unit_of_measurement = quote self._attr_unique_id = f"{config_entry.entry_id}_{quote}" self._quote = quote diff --git a/homeassistant/components/opengarage/binary_sensor.py b/homeassistant/components/opengarage/binary_sensor.py index 64bc7c83d20..22f118ca804 100644 --- a/homeassistant/components/opengarage/binary_sensor.py +++ b/homeassistant/components/opengarage/binary_sensor.py @@ -22,6 +22,7 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="vehicle", + translation_key="vehicle", ), ) @@ -66,9 +67,6 @@ class OpenGarageBinarySensor(OpenGarageEntity, BinarySensorEntity): @callback def _update_attr(self) -> None: """Handle updated data from the coordinator.""" - self._attr_name = ( - f'{self.coordinator.data["name"]} {self.entity_description.key}' - ) state = self.coordinator.data.get(self.entity_description.key) if state == 1: self._attr_is_on = True diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 15669a41736..3f3f6b11acf 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -37,6 +37,7 @@ class OpenGarageCover(OpenGarageEntity, CoverEntity): _attr_device_class = CoverDeviceClass.GARAGE _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + _attr_name = None def __init__( self, coordinator: OpenGarageDataUpdateCoordinator, device_id: str @@ -89,7 +90,6 @@ class OpenGarageCover(OpenGarageEntity, CoverEntity): """Update the state and attributes.""" status = self.coordinator.data - self._attr_name = status["name"] state = STATES_MAP.get(status.get("door")) # type: ignore[arg-type] if self._state_before_move is not None: if self._state_before_move != state: diff --git a/homeassistant/components/opengarage/entity.py b/homeassistant/components/opengarage/entity.py index 678f43afb6e..c8380ea9244 100644 --- a/homeassistant/components/opengarage/entity.py +++ b/homeassistant/components/opengarage/entity.py @@ -12,6 +12,8 @@ from . import DOMAIN, OpenGarageDataUpdateCoordinator class OpenGarageEntity(CoordinatorEntity[OpenGarageDataUpdateCoordinator]): """Representation of a OpenGarage entity.""" + _attr_has_entity_name = True + def __init__( self, open_garage_data_coordinator: OpenGarageDataUpdateCoordinator, diff --git a/homeassistant/components/opengarage/sensor.py b/homeassistant/components/opengarage/sensor.py index 796192b406f..b1d6cb921fa 100644 --- a/homeassistant/components/opengarage/sensor.py +++ b/homeassistant/components/opengarage/sensor.py @@ -83,7 +83,4 @@ class OpenGarageSensor(OpenGarageEntity, SensorEntity): @callback def _update_attr(self) -> None: """Handle updated data from the coordinator.""" - self._attr_name = ( - f'{self.coordinator.data["name"]} {self.entity_description.key}' - ) self._attr_native_value = self.coordinator.data.get(self.entity_description.key) diff --git a/homeassistant/components/opengarage/strings.json b/homeassistant/components/opengarage/strings.json index 26f2f94ff9f..ba4521d4dcf 100644 --- a/homeassistant/components/opengarage/strings.json +++ b/homeassistant/components/opengarage/strings.json @@ -18,5 +18,12 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "binary_sensor": { + "vehicle": { + "name": "Vehicle" + } + } } } diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 1e3654958ab..efc6ab37f21 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -106,18 +106,11 @@ class OpenhomeDevice(MediaPlayerEntity): """Initialise the Openhome device.""" self.hass = hass self._device = device - self._track_information = {} - self._in_standby = None - self._transport_state = None - self._volume_level = None - self._volume_muted = None + self._attr_unique_id = device.uuid() self._attr_supported_features = SUPPORT_OPENHOME - self._source_names = [] self._source_index = {} - self._source = {} - self._name = None self._attr_state = MediaPlayerState.PLAYING - self._available = True + self._attr_available = True @property def device_info(self): @@ -131,47 +124,47 @@ class OpenhomeDevice(MediaPlayerEntity): name=self._device.friendly_name(), ) - @property - def available(self): - """Device is available.""" - return self._available - async def async_update(self) -> None: """Update state of device.""" try: - self._in_standby = await self._device.is_in_standby() - self._transport_state = await self._device.transport_state() - self._track_information = await self._device.track_info() - self._source = await self._device.source() - self._name = await self._device.room() + self._attr_name = await self._device.room() self._attr_supported_features = SUPPORT_OPENHOME source_index = {} source_names = [] + track_information = await self._device.track_info() + self._attr_media_image_url = track_information.get("albumArtwork") + self._attr_media_album_name = track_information.get("albumTitle") + self._attr_media_title = track_information.get("title") + if artists := track_information.get("artist"): + self._attr_media_artist = artists[0] + if self._device.volume_enabled: self._attr_supported_features |= ( MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_SET ) - self._volume_level = await self._device.volume() / 100.0 - self._volume_muted = await self._device.is_muted() + self._attr_volume_level = await self._device.volume() / 100.0 + self._attr_is_volume_muted = await self._device.is_muted() for source in await self._device.sources(): source_names.append(source["name"]) source_index[source["name"]] = source["index"] + source = await self._device.source() + self._attr_source = source.get("name") self._source_index = source_index - self._source_names = source_names + self._attr_source_list = source_names - if self._source["type"] == "Radio": + if source["type"] == "Radio": self._attr_supported_features |= ( MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.BROWSE_MEDIA ) - if self._source["type"] in ("Playlist", "Spotify"): + if source["type"] in ("Playlist", "Spotify"): self._attr_supported_features |= ( MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK @@ -181,21 +174,23 @@ class OpenhomeDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.BROWSE_MEDIA ) - if self._in_standby: + in_standby = await self._device.is_in_standby() + transport_state = await self._device.transport_state() + if in_standby: self._attr_state = MediaPlayerState.OFF - elif self._transport_state == "Paused": + elif transport_state == "Paused": self._attr_state = MediaPlayerState.PAUSED - elif self._transport_state in ("Playing", "Buffering"): + elif transport_state in ("Playing", "Buffering"): self._attr_state = MediaPlayerState.PLAYING - elif self._transport_state == "Stopped": + elif transport_state == "Stopped": self._attr_state = MediaPlayerState.IDLE else: # Device is playing an external source with no transport controls self._attr_state = MediaPlayerState.PLAYING - self._available = True + self._attr_available = True except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError): - self._available = False + self._attr_available = False @catch_request_errors() async def async_turn_on(self) -> None: @@ -273,57 +268,6 @@ class OpenhomeDevice(MediaPlayerEntity): except UpnpError: _LOGGER.error("Error invoking pin %s", pin) - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def unique_id(self): - """Return a unique ID.""" - return self._device.uuid() - - @property - def source_list(self): - """List of available input sources.""" - return self._source_names - - @property - def media_image_url(self): - """Image url of current playing media.""" - return self._track_information.get("albumArtwork") - - @property - def media_artist(self): - """Artist of current playing media, music track only.""" - if artists := self._track_information.get("artist"): - return artists[0] - - @property - def media_album_name(self): - """Album name of current playing media, music track only.""" - return self._track_information.get("albumTitle") - - @property - def media_title(self): - """Title of current playing media.""" - return self._track_information.get("title") - - @property - def source(self): - """Name of the current input source.""" - return self._source.get("name") - - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - return self._volume_level - - @property - def is_volume_muted(self): - """Return true if volume is muted.""" - return self._volume_muted - @catch_request_errors() async def async_volume_up(self) -> None: """Volume up media player.""" diff --git a/homeassistant/components/opensky/__init__.py b/homeassistant/components/opensky/__init__.py index 81f348b5911..cb9c6173694 100644 --- a/homeassistant/components/opensky/__init__.py +++ b/homeassistant/components/opensky/__init__.py @@ -1,13 +1,17 @@ """The opensky component.""" from __future__ import annotations +from aiohttp import BasicAuth from python_opensky import OpenSky +from python_opensky.exceptions import OpenSkyUnauthenticatedError from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, PLATFORMS +from .const import CONF_CONTRIBUTING_USER, DOMAIN, PLATFORMS from .coordinator import OpenSkyDataUpdateCoordinator @@ -15,11 +19,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up opensky from a config entry.""" client = OpenSky(session=async_get_clientsession(hass)) + if CONF_USERNAME in entry.options and CONF_PASSWORD in entry.options: + try: + await client.authenticate( + BasicAuth( + login=entry.options[CONF_USERNAME], + password=entry.options[CONF_PASSWORD], + ), + contributing_user=entry.options.get(CONF_CONTRIBUTING_USER, False), + ) + except OpenSkyUnauthenticatedError as exc: + raise ConfigEntryNotReady from exc + coordinator = OpenSkyDataUpdateCoordinator(hass, client) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -28,3 +45,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload opensky config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py index 12827dfd6ba..a0cd6bc54c2 100644 --- a/homeassistant/components/opensky/config_flow.py +++ b/homeassistant/components/opensky/config_flow.py @@ -3,21 +3,45 @@ from __future__ import annotations from typing import Any +from aiohttp import BasicAuth +from python_opensky import OpenSky +from python_opensky.exceptions import OpenSkyUnauthenticatedError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_RADIUS +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlowWithConfigEntry, +) +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + CONF_PASSWORD, + CONF_RADIUS, + CONF_USERNAME, +) +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DEFAULT_NAME, DOMAIN +from .const import CONF_CONTRIBUTING_USER, DEFAULT_NAME, DOMAIN from .sensor import CONF_ALTITUDE, DEFAULT_ALTITUDE class OpenSkyConfigFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow handler for OpenSky.""" + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OpenSkyOptionsFlowHandler: + """Get the options flow for this handler.""" + return OpenSkyOptionsFlowHandler(config_entry) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -70,3 +94,57 @@ class OpenSkyConfigFlowHandler(ConfigFlow, domain=DOMAIN): CONF_ALTITUDE: import_config.get(CONF_ALTITUDE, DEFAULT_ALTITUDE), }, ) + + +class OpenSkyOptionsFlowHandler(OptionsFlowWithConfigEntry): + """OpenSky Options flow handler.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Initialize form.""" + errors: dict[str, str] = {} + if user_input is not None: + authentication = CONF_USERNAME in user_input or CONF_PASSWORD in user_input + if authentication and CONF_USERNAME not in user_input: + errors["base"] = "username_missing" + if authentication and CONF_PASSWORD not in user_input: + errors["base"] = "password_missing" + if user_input[CONF_CONTRIBUTING_USER] and not authentication: + errors["base"] = "no_authentication" + if authentication and not errors: + async with OpenSky( + session=async_get_clientsession(self.hass) + ) as opensky: + try: + await opensky.authenticate( + BasicAuth( + login=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ), + contributing_user=user_input[CONF_CONTRIBUTING_USER], + ) + except OpenSkyUnauthenticatedError: + errors["base"] = "invalid_auth" + if not errors: + return self.async_create_entry( + title=self.options.get(CONF_NAME, "OpenSky"), + data=user_input, + ) + + return self.async_show_form( + step_id="init", + errors=errors, + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_RADIUS): vol.Coerce(float), + vol.Optional(CONF_ALTITUDE): vol.Coerce(float), + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Optional(CONF_CONTRIBUTING_USER, default=False): bool, + } + ), + user_input or self.options, + ), + ) diff --git a/homeassistant/components/opensky/const.py b/homeassistant/components/opensky/const.py index 4f4eb8a142c..7fe26b424d3 100644 --- a/homeassistant/components/opensky/const.py +++ b/homeassistant/components/opensky/const.py @@ -10,6 +10,7 @@ DEFAULT_NAME = "OpenSky" DOMAIN = "opensky" MANUFACTURER = "OpenSky Network" CONF_ALTITUDE = "altitude" +CONF_CONTRIBUTING_USER = "contributing_user" ATTR_ICAO24 = "icao24" ATTR_CALLSIGN = "callsign" ATTR_ALTITUDE = "altitude" diff --git a/homeassistant/components/opensky/coordinator.py b/homeassistant/components/opensky/coordinator.py index 1c3d10e0c33..d85924737a1 100644 --- a/homeassistant/components/opensky/coordinator.py +++ b/homeassistant/components/opensky/coordinator.py @@ -41,8 +41,10 @@ class OpenSkyDataUpdateCoordinator(DataUpdateCoordinator[int]): hass, LOGGER, name=DOMAIN, - # OpenSky free user has 400 credits, with 4 credits per API call. 100/24 = ~4 requests per hour - update_interval=timedelta(minutes=15), + update_interval={ + True: timedelta(seconds=90), + False: timedelta(minutes=15), + }.get(opensky.is_authenticated), ) self._opensky = opensky self._previously_tracked: set[str] | None = None diff --git a/homeassistant/components/opensky/strings.json b/homeassistant/components/opensky/strings.json index c5746ffdb46..4b4dc908b14 100644 --- a/homeassistant/components/opensky/strings.json +++ b/homeassistant/components/opensky/strings.json @@ -11,5 +11,25 @@ } } } + }, + "options": { + "step": { + "init": { + "description": "You can login to your OpenSky account to increase the update frequency.", + "data": { + "radius": "[%key:component::opensky::config::step::user::data::radius%]", + "altitude": "[%key:component::opensky::config::step::user::data::altitude%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "contributing_user": "I'm contributing to OpenSky" + } + } + }, + "error": { + "username_missing": "Username is missing", + "password_missing": "Password is missing", + "no_authentication": "You need to authenticate to be contributing", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + } } } diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index 631a4cceb0b..c6f95555954 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -29,6 +29,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_API_CLOUDS, @@ -95,7 +96,7 @@ async def async_setup_entry( async_add_entities([owm_weather], False) -class OpenWeatherMapWeather(WeatherEntity): +class OpenWeatherMapWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): """Implementation of an OpenWeatherMap sensor.""" _attr_attribution = ATTRIBUTION @@ -113,6 +114,7 @@ class OpenWeatherMapWeather(WeatherEntity): weather_coordinator: WeatherUpdateCoordinator, ) -> None: """Initialize the sensor.""" + super().__init__(weather_coordinator) self._attr_name = name self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( @@ -121,62 +123,61 @@ class OpenWeatherMapWeather(WeatherEntity): manufacturer=MANUFACTURER, name=DEFAULT_NAME, ) - self._weather_coordinator = weather_coordinator @property def condition(self) -> str | None: """Return the current condition.""" - return self._weather_coordinator.data[ATTR_API_CONDITION] + return self.coordinator.data[ATTR_API_CONDITION] @property def cloud_coverage(self) -> float | None: """Return the Cloud coverage in %.""" - return self._weather_coordinator.data[ATTR_API_CLOUDS] + return self.coordinator.data[ATTR_API_CLOUDS] @property def native_apparent_temperature(self) -> float | None: """Return the apparent temperature.""" - return self._weather_coordinator.data[ATTR_API_FEELS_LIKE_TEMPERATURE] + return self.coordinator.data[ATTR_API_FEELS_LIKE_TEMPERATURE] @property def native_temperature(self) -> float | None: """Return the temperature.""" - return self._weather_coordinator.data[ATTR_API_TEMPERATURE] + return self.coordinator.data[ATTR_API_TEMPERATURE] @property def native_pressure(self) -> float | None: """Return the pressure.""" - return self._weather_coordinator.data[ATTR_API_PRESSURE] + return self.coordinator.data[ATTR_API_PRESSURE] @property def humidity(self) -> float | None: """Return the humidity.""" - return self._weather_coordinator.data[ATTR_API_HUMIDITY] + return self.coordinator.data[ATTR_API_HUMIDITY] @property def native_dew_point(self) -> float | None: """Return the dew point.""" - return self._weather_coordinator.data[ATTR_API_DEW_POINT] + return self.coordinator.data[ATTR_API_DEW_POINT] @property def native_wind_gust_speed(self) -> float | None: """Return the wind gust speed.""" - return self._weather_coordinator.data[ATTR_API_WIND_GUST] + return self.coordinator.data[ATTR_API_WIND_GUST] @property def native_wind_speed(self) -> float | None: """Return the wind speed.""" - return self._weather_coordinator.data[ATTR_API_WIND_SPEED] + return self.coordinator.data[ATTR_API_WIND_SPEED] @property def wind_bearing(self) -> float | str | None: """Return the wind bearing.""" - return self._weather_coordinator.data[ATTR_API_WIND_BEARING] + return self.coordinator.data[ATTR_API_WIND_BEARING] @property def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" - api_forecasts = self._weather_coordinator.data[ATTR_API_FORECAST] + api_forecasts = self.coordinator.data[ATTR_API_FORECAST] forecasts = [ { ha_key: forecast[api_key] @@ -186,18 +187,3 @@ class OpenWeatherMapWeather(WeatherEntity): for forecast in api_forecasts ] return cast(list[Forecast], forecasts) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._weather_coordinator.last_update_success - - async def async_added_to_hass(self) -> None: - """Connect to dispatcher listening for entity data notifications.""" - self.async_on_remove( - self._weather_coordinator.async_add_listener(self.async_write_ha_state) - ) - - async def async_update(self) -> None: - """Get the latest data from OWM and updates the states.""" - await self._weather_coordinator.async_request_refresh() diff --git a/homeassistant/components/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py index 99dd02a36a1..f9547fc3493 100644 --- a/homeassistant/components/ovo_energy/__init__.py +++ b/homeassistant/components/ovo_energy/__init__.py @@ -98,6 +98,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class OVOEnergyEntity(CoordinatorEntity[DataUpdateCoordinator[OVODailyUsage]]): """Defines a base OVO Energy entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: DataUpdateCoordinator[OVODailyUsage], diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index 2a4005e748f..b32a17f0323 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -43,7 +43,7 @@ class OVOEnergySensorEntityDescription(SensorEntityDescription): SENSOR_TYPES_ELECTRICITY: tuple[OVOEnergySensorEntityDescription, ...] = ( OVOEnergySensorEntityDescription( key="last_electricity_reading", - name="OVO Last Electricity Reading", + translation_key="last_electricity_reading", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -51,7 +51,7 @@ SENSOR_TYPES_ELECTRICITY: tuple[OVOEnergySensorEntityDescription, ...] = ( ), OVOEnergySensorEntityDescription( key=KEY_LAST_ELECTRICITY_COST, - name="OVO Last Electricity Cost", + translation_key=KEY_LAST_ELECTRICITY_COST, device_class=SensorDeviceClass.MONETARY, state_class=SensorStateClass.TOTAL_INCREASING, value=lambda usage: usage.electricity[-1].cost.amount @@ -60,14 +60,14 @@ SENSOR_TYPES_ELECTRICITY: tuple[OVOEnergySensorEntityDescription, ...] = ( ), OVOEnergySensorEntityDescription( key="last_electricity_start_time", - name="OVO Last Electricity Start Time", + translation_key="last_electricity_start_time", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, value=lambda usage: dt_util.as_utc(usage.electricity[-1].interval.start), ), OVOEnergySensorEntityDescription( key="last_electricity_end_time", - name="OVO Last Electricity End Time", + translation_key="last_electricity_end_time", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, value=lambda usage: dt_util.as_utc(usage.electricity[-1].interval.end), @@ -77,7 +77,7 @@ SENSOR_TYPES_ELECTRICITY: tuple[OVOEnergySensorEntityDescription, ...] = ( SENSOR_TYPES_GAS: tuple[OVOEnergySensorEntityDescription, ...] = ( OVOEnergySensorEntityDescription( key="last_gas_reading", - name="OVO Last Gas Reading", + translation_key="last_gas_reading", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -86,7 +86,7 @@ SENSOR_TYPES_GAS: tuple[OVOEnergySensorEntityDescription, ...] = ( ), OVOEnergySensorEntityDescription( key=KEY_LAST_GAS_COST, - name="OVO Last Gas Cost", + translation_key=KEY_LAST_GAS_COST, device_class=SensorDeviceClass.MONETARY, state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:cash-multiple", @@ -96,14 +96,14 @@ SENSOR_TYPES_GAS: tuple[OVOEnergySensorEntityDescription, ...] = ( ), OVOEnergySensorEntityDescription( key="last_gas_start_time", - name="OVO Last Gas Start Time", + translation_key="last_gas_start_time", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, value=lambda usage: dt_util.as_utc(usage.gas[-1].interval.start), ), OVOEnergySensorEntityDescription( key="last_gas_end_time", - name="OVO Last Gas End Time", + translation_key="last_gas_end_time", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, value=lambda usage: dt_util.as_utc(usage.gas[-1].interval.end), diff --git a/homeassistant/components/ovo_energy/strings.json b/homeassistant/components/ovo_energy/strings.json index 810602b1412..fda0c2996dc 100644 --- a/homeassistant/components/ovo_energy/strings.json +++ b/homeassistant/components/ovo_energy/strings.json @@ -24,5 +24,33 @@ "title": "Reauthentication" } } + }, + "entity": { + "sensor": { + "last_electricity_reading": { + "name": "Last electricity reading" + }, + "last_electricity_cost": { + "name": "Last electricity cost" + }, + "last_electricity_start_time": { + "name": "Last electricity start time" + }, + "last_electricity_end_time": { + "name": "Last electricity end time" + }, + "last_gas_reading": { + "name": "Last gas reading" + }, + "last_gas_cost": { + "name": "Last gas cost" + }, + "last_gas_start_time": { + "name": "Last gas start time" + }, + "last_gas_end_time": { + "name": "Last gas end time" + } + } } } diff --git a/homeassistant/components/peco/sensor.py b/homeassistant/components/peco/sensor.py index 5afc300bfa8..5be41f7c7e1 100644 --- a/homeassistant/components/peco/sensor.py +++ b/homeassistant/components/peco/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -42,7 +43,7 @@ PARALLEL_UPDATES: Final = 0 SENSOR_LIST: tuple[PECOSensorEntityDescription, ...] = ( PECOSensorEntityDescription( key="customers_out", - name="Customers Out", + translation_key="customers_out", value_fn=lambda data: int(data.outages.customers_out), attribute_fn=lambda data: {}, icon="mdi:power-plug-off", @@ -50,7 +51,7 @@ SENSOR_LIST: tuple[PECOSensorEntityDescription, ...] = ( ), PECOSensorEntityDescription( key="percent_customers_out", - name="Percent Customers Out", + translation_key="percent_customers_out", native_unit_of_measurement=PERCENTAGE, value_fn=lambda data: int(data.outages.percent_customers_out), attribute_fn=lambda data: {}, @@ -59,7 +60,7 @@ SENSOR_LIST: tuple[PECOSensorEntityDescription, ...] = ( ), PECOSensorEntityDescription( key="outage_count", - name="Outage Count", + translation_key="outage_count", value_fn=lambda data: int(data.outages.outage_count), attribute_fn=lambda data: {}, icon="mdi:power-plug-off", @@ -67,7 +68,7 @@ SENSOR_LIST: tuple[PECOSensorEntityDescription, ...] = ( ), PECOSensorEntityDescription( key="customers_served", - name="Customers Served", + translation_key="customers_served", value_fn=lambda data: int(data.outages.customers_served), attribute_fn=lambda data: {}, icon="mdi:power-plug-off", @@ -75,7 +76,7 @@ SENSOR_LIST: tuple[PECOSensorEntityDescription, ...] = ( ), PECOSensorEntityDescription( key="map_alert", - name="Map Alert", + translation_key="map_alert", value_fn=lambda data: str(data.alerts.alert_title), attribute_fn=lambda data: {ATTR_CONTENT: data.alerts.alert_content}, icon="mdi:alert", @@ -104,6 +105,8 @@ class PecoSensor( entity_description: PECOSensorEntityDescription + _attr_has_entity_name = True + def __init__( self, description: PECOSensorEntityDescription, @@ -112,8 +115,10 @@ class PecoSensor( ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._attr_name = f"{county.capitalize()} {description.name}" self._attr_unique_id = f"{county}-{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, county)}, name=county.capitalize() + ) self.entity_description = description @property diff --git a/homeassistant/components/peco/strings.json b/homeassistant/components/peco/strings.json index 54208b12d93..059b2ba71a7 100644 --- a/homeassistant/components/peco/strings.json +++ b/homeassistant/components/peco/strings.json @@ -10,5 +10,24 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } + }, + "entity": { + "sensor": { + "customers_out": { + "name": "Customers out" + }, + "percent_customers_out": { + "name": "Percent customers out" + }, + "outage_count": { + "name": "Outage count" + }, + "customers_served": { + "name": "Customers served" + }, + "map_alert": { + "name": "Map alert" + } + } } } diff --git a/homeassistant/components/powerwall/binary_sensor.py b/homeassistant/components/powerwall/binary_sensor.py index 0bb089898d1..084ec0ea8a6 100644 --- a/homeassistant/components/powerwall/binary_sensor.py +++ b/homeassistant/components/powerwall/binary_sensor.py @@ -44,7 +44,7 @@ async def async_setup_entry( class PowerWallRunningSensor(PowerWallEntity, BinarySensorEntity): """Representation of an Powerwall running sensor.""" - _attr_name = "Powerwall Status" + _attr_translation_key = "status" _attr_device_class = BinarySensorDeviceClass.POWER @property @@ -61,7 +61,7 @@ class PowerWallRunningSensor(PowerWallEntity, BinarySensorEntity): class PowerWallConnectedSensor(PowerWallEntity, BinarySensorEntity): """Representation of an Powerwall connected sensor.""" - _attr_name = "Powerwall Connected to Tesla" + _attr_translation_key = "connected_to_tesla" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY @property @@ -78,7 +78,7 @@ class PowerWallConnectedSensor(PowerWallEntity, BinarySensorEntity): class PowerWallGridServicesActiveSensor(PowerWallEntity, BinarySensorEntity): """Representation of a Powerwall grid services active sensor.""" - _attr_name = "Grid Services Active" + _attr_translation_key = "grid_services_active" _attr_device_class = BinarySensorDeviceClass.POWER @property @@ -95,7 +95,7 @@ class PowerWallGridServicesActiveSensor(PowerWallEntity, BinarySensorEntity): class PowerWallGridStatusSensor(PowerWallEntity, BinarySensorEntity): """Representation of an Powerwall grid status sensor.""" - _attr_name = "Grid Status" + _attr_translation_key = "grid_status" _attr_device_class = BinarySensorDeviceClass.POWER @property @@ -112,7 +112,6 @@ class PowerWallGridStatusSensor(PowerWallEntity, BinarySensorEntity): class PowerWallChargingStatusSensor(PowerWallEntity, BinarySensorEntity): """Representation of an Powerwall charging status sensor.""" - _attr_name = "Powerwall Charging" _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING @property diff --git a/homeassistant/components/powerwall/entity.py b/homeassistant/components/powerwall/entity.py index db1f5997e3e..f0cfec2cbc5 100644 --- a/homeassistant/components/powerwall/entity.py +++ b/homeassistant/components/powerwall/entity.py @@ -20,6 +20,8 @@ from .models import PowerwallData, PowerwallRuntimeData class PowerWallEntity(CoordinatorEntity[DataUpdateCoordinator[PowerwallData]]): """Base class for powerwall entities.""" + _attr_has_entity_name = True + def __init__(self, powerwall_data: PowerwallRuntimeData) -> None: """Initialize the entity.""" base_info = powerwall_data[POWERWALL_BASE_INFO] diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index cf20e51314f..3f02c925f9d 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -69,7 +69,7 @@ def _get_meter_average_voltage(meter: Meter) -> float: POWERWALL_INSTANT_SENSORS = ( PowerwallSensorEntityDescription( key="instant_power", - name="Now", + translation_key="instant_power", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -77,7 +77,7 @@ POWERWALL_INSTANT_SENSORS = ( ), PowerwallSensorEntityDescription( key="instant_frequency", - name="Frequency Now", + translation_key="instant_frequency", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.FREQUENCY, native_unit_of_measurement=UnitOfFrequency.HERTZ, @@ -86,7 +86,7 @@ POWERWALL_INSTANT_SENSORS = ( ), PowerwallSensorEntityDescription( key="instant_current", - name="Average Current Now", + translation_key="instant_current", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -95,7 +95,7 @@ POWERWALL_INSTANT_SENSORS = ( ), PowerwallSensorEntityDescription( key="instant_voltage", - name="Average Voltage Now", + translation_key="instant_voltage", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -136,7 +136,7 @@ async def async_setup_entry( class PowerWallChargeSensor(PowerWallEntity, SensorEntity): """Representation of an Powerwall charge sensor.""" - _attr_name = "Powerwall Charge" + _attr_translation_key = "charge" _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = PERCENTAGE _attr_device_class = SensorDeviceClass.BATTERY @@ -167,10 +167,8 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity): self.entity_description = description super().__init__(powerwall_data) self._meter = meter - self._attr_name = f"Powerwall {self._meter.value.title()} {description.name}" - self._attr_unique_id = ( - f"{self.base_unique_id}_{self._meter.value}_{description.key}" - ) + self._attr_translation_key = f"{meter.value}_{description.translation_key}" + self._attr_unique_id = f"{self.base_unique_id}_{meter.value}_{description.key}" @property def native_value(self) -> float: @@ -181,7 +179,7 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity): class PowerWallBackupReserveSensor(PowerWallEntity, SensorEntity): """Representation of the Powerwall backup reserve setting.""" - _attr_name = "Powerwall Backup Reserve" + _attr_translation_key = "backup_reserve" _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = PERCENTAGE _attr_device_class = SensorDeviceClass.BATTERY @@ -215,7 +213,7 @@ class PowerWallEnergyDirectionSensor(PowerWallEntity, SensorEntity): """Initialize the sensor.""" super().__init__(powerwall_data) self._meter = meter - self._attr_name = f"Powerwall {meter.value.title()} {meter_direction.title()}" + self._attr_translation_key = f"{meter.value}_{meter_direction}" self._attr_unique_id = f"{self.base_unique_id}_{meter.value}_{meter_direction}" @property diff --git a/homeassistant/components/powerwall/strings.json b/homeassistant/components/powerwall/strings.json index 6306d52838e..dacf63a68dd 100644 --- a/homeassistant/components/powerwall/strings.json +++ b/homeassistant/components/powerwall/strings.json @@ -33,5 +33,142 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "binary_sensor": { + "status": { + "name": "Status" + }, + "connected_to_tesla": { + "name": "Connected to Tesla" + }, + "grid_status": { + "name": "Grid status" + }, + "grid_services_active": { + "name": "Grid services active" + } + }, + "sensor": { + "charge": { + "name": "Charge" + }, + "solar_instant_power": { + "name": "Solar power" + }, + "solar_instant_frequency": { + "name": "Solar frequency" + }, + "solar_instant_current": { + "name": "Solar current" + }, + "solar_instant_voltage": { + "name": "Solar voltage" + }, + "site_instant_power": { + "name": "Site power" + }, + "site_instant_frequency": { + "name": "Site frequency" + }, + "site_instant_current": { + "name": "Site current" + }, + "site_instant_voltage": { + "name": "Site voltage" + }, + "battery_instant_power": { + "name": "Battery power" + }, + "battery_instant_frequency": { + "name": "Battery frequency" + }, + "battery_instant_current": { + "name": "Battery current" + }, + "battery_instant_voltage": { + "name": "Battery voltage" + }, + "load_instant_power": { + "name": "Load power" + }, + "load_instant_frequency": { + "name": "Load frequency" + }, + "load_instant_current": { + "name": "Load current" + }, + "load_instant_voltage": { + "name": "Load voltage" + }, + "generator_instant_power": { + "name": "Generator power" + }, + "generator_instant_frequency": { + "name": "Generator frequency" + }, + "generator_instant_current": { + "name": "Generator current" + }, + "generator_instant_voltage": { + "name": "Generator voltage" + }, + "busway_instant_power": { + "name": "Busway power" + }, + "busway_instant_frequency": { + "name": "Busway frequency" + }, + "busway_instant_current": { + "name": "Busway current" + }, + "busway_instant_voltage": { + "name": "Busway voltage" + }, + "backup_reserve": { + "name": "Backup reserve" + }, + "solar_import": { + "name": "Solar import" + }, + "solar_export": { + "name": "Solar export" + }, + "site_import": { + "name": "Site import" + }, + "site_export": { + "name": "Site export" + }, + "battery_import": { + "name": "Battery import" + }, + "battery_export": { + "name": "Battery export" + }, + "load_import": { + "name": "Load import" + }, + "load_export": { + "name": "Load export" + }, + "generator_import": { + "name": "Generator import" + }, + "generator_export": { + "name": "Generator export" + }, + "busway_import": { + "name": "Busway import" + }, + "busway_export": { + "name": "Busway export" + } + }, + "switch": { + "off_grid_operation": { + "name": "Off-grid operation" + } + } } } diff --git a/homeassistant/components/powerwall/switch.py b/homeassistant/components/powerwall/switch.py index 48db62df97a..8516890d633 100644 --- a/homeassistant/components/powerwall/switch.py +++ b/homeassistant/components/powerwall/switch.py @@ -34,8 +34,7 @@ async def async_setup_entry( class PowerwallOffGridEnabledEntity(PowerWallEntity, SwitchEntity): """Representation of a Switch entity for Powerwall Off-grid operation.""" - _attr_name = "Off-Grid operation" - _attr_has_entity_name = True + _attr_translation_key = "off_grid_operation" _attr_entity_category = EntityCategory.CONFIG _attr_device_class = SwitchDeviceClass.SWITCH diff --git a/homeassistant/components/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py index 8d1f087bfff..77cdb5e11a2 100644 --- a/homeassistant/components/prosegur/alarm_control_panel.py +++ b/homeassistant/components/prosegur/alarm_control_panel.py @@ -47,6 +47,8 @@ class ProsegurAlarm(alarm.AlarmControlPanelEntity): AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_HOME ) + _attr_has_entity_name = True + _attr_name = None _installation: Installation def __init__(self, contract: str, auth: Auth) -> None: @@ -57,14 +59,13 @@ class ProsegurAlarm(alarm.AlarmControlPanelEntity): self._auth = auth self._attr_code_arm_required = False - self._attr_name = f"contract {self.contract}" - self._attr_unique_id = self.contract + self._attr_unique_id = contract self._attr_device_info = DeviceInfo( - name="Prosegur Alarm", + name=f"Contract {contract}", manufacturer="Prosegur", model="smart", - identifiers={(DOMAIN, self.contract)}, + identifiers={(DOMAIN, contract)}, configuration_url="https://smart.prosegur.com", ) diff --git a/homeassistant/components/prosegur/camera.py b/homeassistant/components/prosegur/camera.py index bdd265d1e42..c711ca2eac6 100644 --- a/homeassistant/components/prosegur/camera.py +++ b/homeassistant/components/prosegur/camera.py @@ -50,6 +50,8 @@ async def async_setup_entry( class ProsegurCamera(Camera): """Representation of a Smart Prosegur Camera.""" + _attr_has_entity_name = True + def __init__( self, installation: Installation, camera: InstallationCamera, auth: Auth ) -> None: @@ -59,14 +61,14 @@ class ProsegurCamera(Camera): self._installation = installation self._camera = camera self._auth = auth + self._attr_unique_id = f"{installation.contract} {camera.id}" self._attr_name = camera.description - self._attr_unique_id = f"{self._installation.contract} {camera.id}" self._attr_device_info = DeviceInfo( - name=self._camera.description, + name=f"Contract {installation.contract}", manufacturer="Prosegur", - model="smart camera", - identifiers={(DOMAIN, self._installation.contract)}, + model="smart", + identifiers={(DOMAIN, installation.contract)}, configuration_url="https://smart.prosegur.com", ) diff --git a/homeassistant/components/qnap_qsw/manifest.json b/homeassistant/components/qnap_qsw/manifest.json index 17825110490..1b9ba097b36 100644 --- a/homeassistant/components/qnap_qsw/manifest.json +++ b/homeassistant/components/qnap_qsw/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/qnap_qsw", "iot_class": "local_polling", "loggers": ["aioqsw"], - "requirements": ["aioqsw==0.3.2"] + "requirements": ["aioqsw==0.3.3"] } diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index 367e302d56f..803b6de44a4 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from copy import deepcopy from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any, Generic from aiopyarr import Diskspace, RootFolder, SystemStatus @@ -88,7 +88,7 @@ SENSOR_TYPES: dict[str, RadarrSensorEntityDescription[Any]] = { device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda data, _: data.startTime.replace(tzinfo=timezone.utc), + value_fn=lambda data, _: data.startTime.replace(tzinfo=UTC), ), } diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 996f2c6b3ab..49e964e2b3f 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -49,26 +49,25 @@ class ReolinkBinarySensorEntityDescription( BINARY_SENSORS = ( ReolinkBinarySensorEntityDescription( key="motion", - name="Motion", device_class=BinarySensorDeviceClass.MOTION, value=lambda api, ch: api.motion_detected(ch), ), ReolinkBinarySensorEntityDescription( key=FACE_DETECTION_TYPE, - name="Face", + translation_key="face", icon="mdi:face-recognition", value=lambda api, ch: api.ai_detected(ch, FACE_DETECTION_TYPE), supported=lambda api, ch: api.ai_supported(ch, FACE_DETECTION_TYPE), ), ReolinkBinarySensorEntityDescription( key=PERSON_DETECTION_TYPE, - name="Person", + translation_key="person", value=lambda api, ch: api.ai_detected(ch, PERSON_DETECTION_TYPE), supported=lambda api, ch: api.ai_supported(ch, PERSON_DETECTION_TYPE), ), ReolinkBinarySensorEntityDescription( key=VEHICLE_DETECTION_TYPE, - name="Vehicle", + translation_key="vehicle", icon="mdi:car", icon_off="mdi:car-off", value=lambda api, ch: api.ai_detected(ch, VEHICLE_DETECTION_TYPE), @@ -76,7 +75,7 @@ BINARY_SENSORS = ( ), ReolinkBinarySensorEntityDescription( key=PET_DETECTION_TYPE, - name="Pet", + translation_key="pet", icon="mdi:dog-side", icon_off="mdi:dog-side-off", value=lambda api, ch: api.ai_detected(ch, PET_DETECTION_TYPE), @@ -84,7 +83,7 @@ BINARY_SENSORS = ( ), ReolinkBinarySensorEntityDescription( key="visitor", - name="Visitor", + translation_key="visitor", icon="mdi:bell-ring-outline", icon_off="mdi:doorbell", value=lambda api, ch: api.visitor_detected(ch), @@ -130,7 +129,11 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt self.entity_description = entity_description if self._host.api.model in DUAL_LENS_DUAL_MOTION_MODELS: - self._attr_name = f"{entity_description.name} lens {self._channel}" + if entity_description.translation_key is not None: + key = entity_description.translation_key + else: + key = entity_description.key + self._attr_translation_key = f"{key}_lens_{self._channel}" self._attr_unique_id = ( f"{self._host.unique_id}_{self._channel}_{entity_description.key}" diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 7a6e2486c71..f1797527914 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -36,6 +36,7 @@ class ReolinkButtonEntityDescription( """A class that describes button entities for a camera channel.""" supported: Callable[[Host, int], bool] = lambda api, ch: True + enabled_default: Callable[[Host, int], bool] | None = None @dataclass @@ -57,42 +58,60 @@ class ReolinkHostButtonEntityDescription( BUTTON_ENTITIES = ( ReolinkButtonEntityDescription( key="ptz_stop", - name="PTZ stop", + translation_key="ptz_stop", icon="mdi:pan", - supported=lambda api, ch: api.supported(ch, "pan_tilt"), + enabled_default=lambda api, ch: api.supported(ch, "pan_tilt"), + supported=lambda api, ch: api.supported(ch, "pan_tilt") + or api.supported(ch, "zoom_basic"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.stop.value), ), ReolinkButtonEntityDescription( key="ptz_left", - name="PTZ left", + translation_key="ptz_left", icon="mdi:pan", supported=lambda api, ch: api.supported(ch, "pan"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.left.value), ), ReolinkButtonEntityDescription( key="ptz_right", - name="PTZ right", + translation_key="ptz_right", icon="mdi:pan", supported=lambda api, ch: api.supported(ch, "pan"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.right.value), ), ReolinkButtonEntityDescription( key="ptz_up", - name="PTZ up", + translation_key="ptz_up", icon="mdi:pan", supported=lambda api, ch: api.supported(ch, "tilt"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.up.value), ), ReolinkButtonEntityDescription( key="ptz_down", - name="PTZ down", + translation_key="ptz_down", icon="mdi:pan", supported=lambda api, ch: api.supported(ch, "tilt"), method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.down.value), ), + ReolinkButtonEntityDescription( + key="ptz_zoom_in", + translation_key="ptz_zoom_in", + icon="mdi:magnify", + entity_registry_enabled_default=False, + supported=lambda api, ch: api.supported(ch, "zoom_basic"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.zoomin.value), + ), + ReolinkButtonEntityDescription( + key="ptz_zoom_out", + translation_key="ptz_zoom_out", + icon="mdi:magnify", + entity_registry_enabled_default=False, + supported=lambda api, ch: api.supported(ch, "zoom_basic"), + method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.zoomout.value), + ), ReolinkButtonEntityDescription( key="ptz_calibrate", - name="PTZ calibrate", + translation_key="ptz_calibrate", icon="mdi:pan", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "ptz_callibrate"), @@ -100,14 +119,14 @@ BUTTON_ENTITIES = ( ), ReolinkButtonEntityDescription( key="guard_go_to", - name="Guard go to", + translation_key="guard_go_to", icon="mdi:crosshairs-gps", supported=lambda api, ch: api.supported(ch, "ptz_guard"), method=lambda api, ch: api.set_ptz_guard(ch, command=GuardEnum.goto.value), ), ReolinkButtonEntityDescription( key="guard_set", - name="Guard set current position", + translation_key="guard_set", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "ptz_guard"), @@ -169,6 +188,10 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): self._attr_unique_id = ( f"{self._host.unique_id}_{channel}_{entity_description.key}" ) + if entity_description.enabled_default is not None: + self._attr_entity_registry_enabled_default = ( + entity_description.enabled_default(self._host.api, self._channel) + ) async def async_press(self) -> None: """Execute the button action.""" diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index 0f80215d506..4ac8166410f 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -45,7 +45,7 @@ class ReolinkLightEntityDescription( LIGHT_ENTITIES = ( ReolinkLightEntityDescription( key="floodlight", - name="Floodlight", + translation_key="floodlight", icon="mdi:spotlight-beam", supported_fn=lambda api, ch: api.supported(ch, "floodLight"), is_on_fn=lambda api, ch: api.whiteled_state(ch), @@ -55,7 +55,7 @@ LIGHT_ENTITIES = ( ), ReolinkLightEntityDescription( key="ir_lights", - name="Infra red lights in night mode", + translation_key="ir_lights", icon="mdi:led-off", entity_category=EntityCategory.CONFIG, supported_fn=lambda api, ch: api.supported(ch, "ir_lights"), @@ -64,7 +64,7 @@ LIGHT_ENTITIES = ( ), ReolinkLightEntityDescription( key="status_led", - name="Status LED", + translation_key="status_led", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, supported_fn=lambda api, ch: api.supported(ch, "power_led"), diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index bb19974114d..24e5d1bd72b 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -45,7 +45,7 @@ class ReolinkNumberEntityDescription( NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="zoom", - name="Zoom", + translation_key="zoom", icon="mdi:magnify", mode=NumberMode.SLIDER, native_step=1, @@ -57,7 +57,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="focus", - name="Focus", + translation_key="focus", icon="mdi:focus-field", mode=NumberMode.SLIDER, native_step=1, @@ -72,7 +72,7 @@ NUMBER_ENTITIES = ( # or when using the "light.floodlight" entity. ReolinkNumberEntityDescription( key="floodlight_brightness", - name="Floodlight turn on brightness", + translation_key="floodlight_brightness", icon="mdi:spotlight-beam", entity_category=EntityCategory.CONFIG, native_step=1, @@ -84,7 +84,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="volume", - name="Volume", + translation_key="volume", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, native_step=1, @@ -96,7 +96,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="guard_return_time", - name="Guard return time", + translation_key="guard_return_time", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, native_step=1, @@ -109,7 +109,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="motion_sensitivity", - name="Motion sensitivity", + translation_key="motion_sensitivity", icon="mdi:motion-sensor", entity_category=EntityCategory.CONFIG, native_step=1, @@ -121,7 +121,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_face_sensititvity", - name="AI face sensitivity", + translation_key="ai_face_sensititvity", icon="mdi:face-recognition", entity_category=EntityCategory.CONFIG, native_step=1, @@ -135,7 +135,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_person_sensititvity", - name="AI person sensitivity", + translation_key="ai_person_sensititvity", icon="mdi:account", entity_category=EntityCategory.CONFIG, native_step=1, @@ -149,7 +149,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_vehicle_sensititvity", - name="AI vehicle sensitivity", + translation_key="ai_vehicle_sensititvity", icon="mdi:car", entity_category=EntityCategory.CONFIG, native_step=1, @@ -163,7 +163,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_sensititvity", - name="AI pet sensitivity", + translation_key="ai_pet_sensititvity", icon="mdi:dog-side", entity_category=EntityCategory.CONFIG, native_step=1, @@ -175,9 +175,73 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.ai_sensitivity(ch, "dog_cat"), method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "dog_cat"), ), + ReolinkNumberEntityDescription( + key="ai_face_delay", + translation_key="ai_face_delay", + icon="mdi:face-recognition", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=0, + native_max_value=8, + supported=lambda api, ch: ( + api.supported(ch, "ai_delay") and api.ai_supported(ch, "face") + ), + value=lambda api, ch: api.ai_delay(ch, "face"), + method=lambda api, ch, value: api.set_ai_delay(ch, int(value), "face"), + ), + ReolinkNumberEntityDescription( + key="ai_person_delay", + translation_key="ai_person_delay", + icon="mdi:account", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=0, + native_max_value=8, + supported=lambda api, ch: ( + api.supported(ch, "ai_delay") and api.ai_supported(ch, "people") + ), + value=lambda api, ch: api.ai_delay(ch, "people"), + method=lambda api, ch, value: api.set_ai_delay(ch, int(value), "people"), + ), + ReolinkNumberEntityDescription( + key="ai_vehicle_delay", + translation_key="ai_vehicle_delay", + icon="mdi:car", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=0, + native_max_value=8, + supported=lambda api, ch: ( + api.supported(ch, "ai_delay") and api.ai_supported(ch, "vehicle") + ), + value=lambda api, ch: api.ai_delay(ch, "vehicle"), + method=lambda api, ch, value: api.set_ai_delay(ch, int(value), "vehicle"), + ), + ReolinkNumberEntityDescription( + key="ai_pet_delay", + translation_key="ai_pet_delay", + icon="mdi:dog-side", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=0, + native_max_value=8, + supported=lambda api, ch: ( + api.supported(ch, "ai_delay") and api.ai_supported(ch, "dog_cat") + ), + value=lambda api, ch: api.ai_delay(ch, "dog_cat"), + method=lambda api, ch, value: api.set_ai_delay(ch, int(value), "dog_cat"), + ), ReolinkNumberEntityDescription( key="auto_quick_reply_time", - name="Auto quick reply time", + translation_key="auto_quick_reply_time", icon="mdi:message-reply-text-outline", entity_category=EntityCategory.CONFIG, native_step=1, @@ -190,7 +254,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_limit_left", - name="Auto track limit left", + translation_key="auto_track_limit_left", icon="mdi:angle-acute", mode=NumberMode.SLIDER, entity_category=EntityCategory.CONFIG, @@ -203,7 +267,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_limit_right", - name="Auto track limit right", + translation_key="auto_track_limit_right", icon="mdi:angle-acute", mode=NumberMode.SLIDER, entity_category=EntityCategory.CONFIG, @@ -216,7 +280,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_disappear_time", - name="Auto track disappear time", + translation_key="auto_track_disappear_time", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, native_step=1, @@ -231,7 +295,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_stop_time", - name="Auto track stop time", + translation_key="auto_track_stop_time", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, native_step=1, diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 2ae3442278e..e9dc151f33b 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -45,10 +45,9 @@ class ReolinkSelectEntityDescription( SELECT_ENTITIES = ( ReolinkSelectEntityDescription( key="floodlight_mode", - name="Floodlight mode", + translation_key="floodlight_mode", icon="mdi:spotlight-beam", entity_category=EntityCategory.CONFIG, - translation_key="floodlight_mode", get_options=lambda api, ch: api.whiteled_mode_list(ch), supported=lambda api, ch: api.supported(ch, "floodLight"), value=lambda api, ch: SpotlightModeEnum(api.whiteled_mode(ch)).name, @@ -56,10 +55,9 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="day_night_mode", - name="Day night mode", + translation_key="day_night_mode", icon="mdi:theme-light-dark", entity_category=EntityCategory.CONFIG, - translation_key="day_night_mode", get_options=[mode.name for mode in DayNightEnum], supported=lambda api, ch: api.supported(ch, "dayNight"), value=lambda api, ch: DayNightEnum(api.daynight_state(ch)).name, @@ -67,7 +65,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="ptz_preset", - name="PTZ preset", + translation_key="ptz_preset", icon="mdi:pan", get_options=lambda api, ch: list(api.ptz_presets(ch)), supported=lambda api, ch: api.supported(ch, "ptz_presets"), @@ -75,9 +73,8 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="auto_quick_reply_message", - name="Auto quick reply message", - icon="mdi:message-reply-text-outline", translation_key="auto_quick_reply_message", + icon="mdi:message-reply-text-outline", get_options=lambda api, ch: list(api.quick_reply_dict(ch).values()), supported=lambda api, ch: api.supported(ch, "quick_reply"), value=lambda api, ch: api.quick_reply_dict(ch)[api.quick_reply_file(ch)], @@ -87,9 +84,8 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="auto_track_method", - name="Auto track method", - icon="mdi:target-account", translation_key="auto_track_method", + icon="mdi:target-account", entity_category=EntityCategory.CONFIG, get_options=[method.name for method in TrackMethodEnum], supported=lambda api, ch: api.supported(ch, "auto_track_method"), @@ -98,9 +94,8 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="status_led", - name="Status LED", - icon="mdi:lightning-bolt-circle", translation_key="status_led", + icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, get_options=[state.name for state in StatusLedEnum], supported=lambda api, ch: api.supported(ch, "doorbell_led"), diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index 9dba3b840ea..c91f633ecab 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -33,7 +33,7 @@ class ReolinkSirenEntityDescription(SirenEntityDescription): SIREN_ENTITIES = ( ReolinkSirenEntityDescription( key="siren", - name="Siren", + translation_key="siren", icon="mdi:alarm-light", supported=lambda api, ch: api.supported(ch, "siren_play"), ), diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index cdaeb7d0656..95aa26a1ff5 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -62,8 +62,164 @@ } }, "entity": { + "binary_sensor": { + "face": { + "name": "Face" + }, + "person": { + "name": "Person" + }, + "vehicle": { + "name": "Vehicle" + }, + "pet": { + "name": "Pet" + }, + "visitor": { + "name": "Visitor" + }, + "motion_lens_0": { + "name": "Motion lens 0" + }, + "face_lens_0": { + "name": "Face lens 0" + }, + "person_lens_0": { + "name": "Person lens 0" + }, + "vehicle_lens_0": { + "name": "Vehicle lens 0" + }, + "pet_lens_0": { + "name": "Pet lens 0" + }, + "visitor_lens_0": { + "name": "Visitor lens 0" + }, + "motion_lens_1": { + "name": "Motion lens 1" + }, + "face_lens_1": { + "name": "Face lens 1" + }, + "person_lens_1": { + "name": "Person lens 1" + }, + "vehicle_lens_1": { + "name": "Vehicle lens 1" + }, + "pet_lens_1": { + "name": "Pet lens 1" + }, + "visitor_lens_1": { + "name": "Visitor lens 1" + } + }, + "button": { + "ptz_stop": { + "name": "PTZ stop" + }, + "ptz_left": { + "name": "PTZ left" + }, + "ptz_right": { + "name": "PTZ right" + }, + "ptz_up": { + "name": "PTZ up" + }, + "ptz_down": { + "name": "PTZ down" + }, + "ptz_zoom_in": { + "name": "PTZ zoom in" + }, + "ptz_zoom_out": { + "name": "PTZ zoom out" + }, + "ptz_calibrate": { + "name": "PTZ calibrate" + }, + "guard_go_to": { + "name": "Guard go to" + }, + "guard_set": { + "name": "Guard set current position" + } + }, + "light": { + "floodlight": { + "name": "Floodlight" + }, + "ir_lights": { + "name": "Infra red lights in night mode" + }, + "status_led": { + "name": "Status LED" + } + }, + "number": { + "zoom": { + "name": "Zoom" + }, + "focus": { + "name": "Focus" + }, + "floodlight_brightness": { + "name": "Floodlight turn on brightness" + }, + "volume": { + "name": "Volume" + }, + "guard_return_time": { + "name": "Guard return time" + }, + "motion_sensitivity": { + "name": "Motion sensitivity" + }, + "ai_face_sensititvity": { + "name": "AI face sensitivity" + }, + "ai_person_sensititvity": { + "name": "AI person sensitivity" + }, + "ai_vehicle_sensititvity": { + "name": "AI vehicle sensitivity" + }, + "ai_pet_sensititvity": { + "name": "AI pet sensitivity" + }, + "ai_face_delay": { + "name": "AI face delay" + }, + "ai_person_delay": { + "name": "AI person delay" + }, + "ai_vehicle_delay": { + "name": "AI vehicle delay" + }, + "ai_pet_delay": { + "name": "AI pet delay" + }, + "auto_quick_reply_time": { + "name": "Auto quick reply time" + }, + "auto_track_limit_left": { + "name": "Auto track limit left" + }, + "auto_track_limit_right": { + "name": "Auto track limit right" + }, + "auto_track_disappear_time": { + "name": "Auto track disappear time" + }, + "auto_track_stop_time": { + "name": "Auto track stop time" + } + }, "select": { "floodlight_mode": { + "name": "Floodlight mode", "state": { "off": "[%key:common::state::off%]", "auto": "Auto", @@ -73,18 +229,24 @@ } }, "day_night_mode": { + "name": "Day night mode", "state": { "auto": "Auto", "color": "Color", "blackwhite": "Black&White" } }, + "ptz_preset": { + "name": "PTZ preset" + }, "auto_quick_reply_message": { + "name": "Auto quick reply message", "state": { "off": "[%key:common::state::off%]" } }, "auto_track_method": { + "name": "Auto track method", "state": { "digital": "Digital", "digitalfirst": "Digital first", @@ -92,6 +254,7 @@ } }, "status_led": { + "name": "Status LED", "state": { "stayoff": "Stay off", "auto": "Auto", @@ -106,6 +269,46 @@ "ptz_pan_position": { "name": "PTZ pan position" } + }, + "siren": { + "siren": { + "name": "[%key:component::siren::title%]" + } + }, + "switch": { + "record_audio": { + "name": "Record audio" + }, + "siren_on_event": { + "name": "Siren on event" + }, + "auto_tracking": { + "name": "Auto tracking" + }, + "auto_focus": { + "name": "Auto focus" + }, + "gaurd_return": { + "name": "Guard return" + }, + "email": { + "name": "Email on event" + }, + "ftp_upload": { + "name": "FTP upload" + }, + "push_notifications": { + "name": "Push notifications" + }, + "record": { + "name": "Record" + }, + "buzzer": { + "name": "Buzzer on event" + }, + "doorbell_button_sound": { + "name": "Doorbell button sound" + } } } } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index aa121911758..4a5b415a144 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -55,7 +55,7 @@ class ReolinkNVRSwitchEntityDescription( SWITCH_ENTITIES = ( ReolinkSwitchEntityDescription( key="record_audio", - name="Record audio", + translation_key="record_audio", icon="mdi:microphone", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "audio"), @@ -64,7 +64,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="siren_on_event", - name="Siren on event", + translation_key="siren_on_event", icon="mdi:alarm-light", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "siren"), @@ -73,7 +73,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="auto_tracking", - name="Auto tracking", + translation_key="auto_tracking", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "auto_track"), @@ -82,7 +82,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="auto_focus", - name="Auto focus", + translation_key="auto_focus", icon="mdi:focus-field", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "auto_focus"), @@ -91,7 +91,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="gaurd_return", - name="Guard return", + translation_key="gaurd_return", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "ptz_guard"), @@ -100,7 +100,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="email", - name="Email on event", + translation_key="email", icon="mdi:email", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "email") and api.is_nvr, @@ -109,7 +109,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="ftp_upload", - name="FTP upload", + translation_key="ftp_upload", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "ftp") and api.is_nvr, @@ -118,7 +118,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="push_notifications", - name="Push notifications", + translation_key="push_notifications", icon="mdi:message-badge", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "push") and api.is_nvr, @@ -127,7 +127,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="record", - name="Record", + translation_key="record", icon="mdi:record-rec", supported=lambda api, ch: api.supported(ch, "recording") and api.is_nvr, value=lambda api, ch: api.recording_enabled(ch), @@ -135,7 +135,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="buzzer", - name="Buzzer on event", + translation_key="buzzer", icon="mdi:room-service", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "buzzer") and api.is_nvr, @@ -144,7 +144,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="doorbell_button_sound", - name="Doorbell button sound", + translation_key="doorbell_button_sound", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "doorbell_button_sound"), @@ -156,7 +156,7 @@ SWITCH_ENTITIES = ( NVR_SWITCH_ENTITIES = ( ReolinkNVRSwitchEntityDescription( key="email", - name="Email on event", + translation_key="email", icon="mdi:email", entity_category=EntityCategory.CONFIG, supported=lambda api: api.supported(None, "email"), @@ -165,7 +165,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="ftp_upload", - name="FTP upload", + translation_key="ftp_upload", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, supported=lambda api: api.supported(None, "ftp"), @@ -174,7 +174,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="push_notifications", - name="Push notifications", + translation_key="push_notifications", icon="mdi:message-badge", entity_category=EntityCategory.CONFIG, supported=lambda api: api.supported(None, "push"), @@ -183,7 +183,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="record", - name="Record", + translation_key="record", icon="mdi:record-rec", supported=lambda api: api.supported(None, "recording"), value=lambda api: api.recording_enabled(), @@ -191,7 +191,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="buzzer", - name="Buzzer on event", + translation_key="buzzer", icon="mdi:room-service", entity_category=EntityCategory.CONFIG, supported=lambda api: api.supported(None, "buzzer"), diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index fbbb037080b..57efe1d9e92 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -41,7 +41,6 @@ class ReolinkUpdateEntity( _attr_device_class = UpdateDeviceClass.FIRMWARE _attr_release_url = "https://reolink.com/download-center/" - _attr_name = "Update" def __init__( self, diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index 583d26a4a5b..f31a07feb29 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -22,7 +22,12 @@ PLATFORMS = [ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Roku from a config entry.""" - coordinator = RokuDataUpdateCoordinator(hass, host=entry.data[CONF_HOST]) + if (device_id := entry.unique_id) is None: + device_id = entry.entry_id + + coordinator = RokuDataUpdateCoordinator( + hass, host=entry.data[CONF_HOST], device_id=device_id + ) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/roku/binary_sensor.py b/homeassistant/components/roku/binary_sensor.py index 4bc36d0f7e5..b08933dcd91 100644 --- a/homeassistant/components/roku/binary_sensor.py +++ b/homeassistant/components/roku/binary_sensor.py @@ -36,27 +36,27 @@ class RokuBinarySensorEntityDescription( BINARY_SENSORS: tuple[RokuBinarySensorEntityDescription, ...] = ( RokuBinarySensorEntityDescription( key="headphones_connected", - name="Headphones connected", + translation_key="headphones_connected", icon="mdi:headphones", value_fn=lambda device: device.info.headphones_connected, ), RokuBinarySensorEntityDescription( key="supports_airplay", - name="Supports AirPlay", + translation_key="supports_airplay", icon="mdi:cast-variant", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.info.supports_airplay, ), RokuBinarySensorEntityDescription( key="supports_ethernet", - name="Supports ethernet", + translation_key="supports_ethernet", icon="mdi:ethernet", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.info.ethernet_support, ), RokuBinarySensorEntityDescription( key="supports_find_remote", - name="Supports find remote", + translation_key="supports_find_remote", icon="mdi:remote", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.info.supports_find_remote, @@ -71,10 +71,9 @@ async def async_setup_entry( ) -> None: """Set up a Roku binary sensors based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - unique_id = coordinator.data.info.serial_number + async_add_entities( RokuBinarySensorEntity( - device_id=unique_id, coordinator=coordinator, description=description, ) diff --git a/homeassistant/components/roku/coordinator.py b/homeassistant/components/roku/coordinator.py index f084302841e..a0bd9df238c 100644 --- a/homeassistant/components/roku/coordinator.py +++ b/homeassistant/components/roku/coordinator.py @@ -32,8 +32,10 @@ class RokuDataUpdateCoordinator(DataUpdateCoordinator[Device]): hass: HomeAssistant, *, host: str, + device_id: str, ) -> None: """Initialize global Roku data updater.""" + self.device_id = device_id self.roku = Roku(host=host, session=async_get_clientsession(hass)) self.full_update_interval = timedelta(minutes=15) diff --git a/homeassistant/components/roku/entity.py b/homeassistant/components/roku/entity.py index b6343d0dae1..b783831d4ec 100644 --- a/homeassistant/components/roku/entity.py +++ b/homeassistant/components/roku/entity.py @@ -12,45 +12,37 @@ from .const import DOMAIN class RokuEntity(CoordinatorEntity[RokuDataUpdateCoordinator]): """Defines a base Roku entity.""" + _attr_has_entity_name = True + def __init__( self, *, - device_id: str | None, coordinator: RokuDataUpdateCoordinator, description: EntityDescription | None = None, ) -> None: """Initialize the Roku entity.""" super().__init__(coordinator) - self._device_id = device_id if description is not None: self.entity_description = description + self._attr_unique_id = f"{coordinator.device_id}_{description.key}" + else: + self._attr_unique_id = coordinator.device_id - if device_id is None: - self._attr_name = f"{coordinator.data.info.name} {description.name}" - - if device_id is not None: - self._attr_has_entity_name = True - - if description is not None: - self._attr_unique_id = f"{device_id}_{description.key}" - else: - self._attr_unique_id = device_id - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_id)}, - connections={ - (CONNECTION_NETWORK_MAC, mac_address) - for mac_address in ( - self.coordinator.data.info.wifi_mac, - self.coordinator.data.info.ethernet_mac, - ) - if mac_address is not None - }, - name=self.coordinator.data.info.name, - manufacturer=self.coordinator.data.info.brand, - model=self.coordinator.data.info.model_name, - hw_version=self.coordinator.data.info.model_number, - sw_version=self.coordinator.data.info.version, - suggested_area=self.coordinator.data.info.device_location, - ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.device_id)}, + connections={ + (CONNECTION_NETWORK_MAC, mac_address) + for mac_address in ( + self.coordinator.data.info.wifi_mac, + self.coordinator.data.info.ethernet_mac, + ) + if mac_address is not None + }, + name=self.coordinator.data.info.name, + manufacturer=self.coordinator.data.info.brand, + model=self.coordinator.data.info.model_name, + hw_version=self.coordinator.data.info.model_number, + sw_version=self.coordinator.data.info.version, + suggested_area=self.coordinator.data.info.device_location, + ) diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index a8c1cf4698c..05f782b37c4 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -85,11 +85,10 @@ async def async_setup_entry( ) -> None: """Set up the Roku config entry.""" coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - unique_id = coordinator.data.info.serial_number + async_add_entities( [ RokuMediaPlayer( - device_id=unique_id, coordinator=coordinator, ) ], diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py index 0271e4a0f73..ef5350eb741 100644 --- a/homeassistant/components/roku/remote.py +++ b/homeassistant/components/roku/remote.py @@ -22,11 +22,10 @@ async def async_setup_entry( ) -> None: """Load Roku remote based on a config entry.""" coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - unique_id = coordinator.data.info.serial_number + async_add_entities( [ RokuRemote( - device_id=unique_id, coordinator=coordinator, ) ], diff --git a/homeassistant/components/roku/select.py b/homeassistant/components/roku/select.py index e11748114d1..430133b7f77 100644 --- a/homeassistant/components/roku/select.py +++ b/homeassistant/components/roku/select.py @@ -95,7 +95,7 @@ class RokuSelectEntityDescription( ENTITIES: tuple[RokuSelectEntityDescription, ...] = ( RokuSelectEntityDescription( key="application", - name="Application", + translation_key="application", icon="mdi:application", set_fn=_launch_application, value_fn=_get_application_name, @@ -106,7 +106,7 @@ ENTITIES: tuple[RokuSelectEntityDescription, ...] = ( CHANNEL_ENTITY = RokuSelectEntityDescription( key="channel", - name="Channel", + translation_key="channel", icon="mdi:television", set_fn=_tune_channel, value_fn=_get_channel_name, @@ -122,14 +122,12 @@ async def async_setup_entry( """Set up Roku select based on a config entry.""" coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] device: RokuDevice = coordinator.data - unique_id = device.info.serial_number entities: list[RokuSelectEntity] = [] for description in ENTITIES: entities.append( RokuSelectEntity( - device_id=unique_id, coordinator=coordinator, description=description, ) @@ -138,7 +136,6 @@ async def async_setup_entry( if len(device.channels) > 0: entities.append( RokuSelectEntity( - device_id=unique_id, coordinator=coordinator, description=CHANNEL_ENTITY, ) diff --git a/homeassistant/components/roku/sensor.py b/homeassistant/components/roku/sensor.py index 0f0e87205b9..69b8c34d312 100644 --- a/homeassistant/components/roku/sensor.py +++ b/homeassistant/components/roku/sensor.py @@ -34,14 +34,14 @@ class RokuSensorEntityDescription( SENSORS: tuple[RokuSensorEntityDescription, ...] = ( RokuSensorEntityDescription( key="active_app", - name="Active App", + translation_key="active_app", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:application", value_fn=lambda device: device.app.name if device.app else None, ), RokuSensorEntityDescription( key="active_app_id", - name="Active App ID", + translation_key="active_app_id", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:application-cog", value_fn=lambda device: device.app.app_id if device.app else None, @@ -56,10 +56,9 @@ async def async_setup_entry( ) -> None: """Set up Roku sensor based on a config entry.""" coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - unique_id = coordinator.data.info.serial_number + async_add_entities( RokuSensorEntity( - device_id=unique_id, coordinator=coordinator, description=description, ) diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json index 3510a43c604..818b43930f4 100644 --- a/homeassistant/components/roku/strings.json +++ b/homeassistant/components/roku/strings.json @@ -21,6 +21,38 @@ "unknown": "[%key:common::config_flow::error::unknown%]" } }, + "entity": { + "binary_sensor": { + "headphones_connected": { + "name": "Headphones connected" + }, + "supports_airplay": { + "name": "Supports AirPlay" + }, + "supports_ethernet": { + "name": "Supports ethernet" + }, + "supports_find_remote": { + "name": "Supports find remote" + } + }, + "select": { + "application": { + "name": "Application" + }, + "channel": { + "name": "Channel" + } + }, + "sensor": { + "active_app": { + "name": "Active app" + }, + "active_app_id": { + "name": "Active app ID" + } + } + }, "services": { "search": { "name": "Search", diff --git a/homeassistant/components/route53/manifest.json b/homeassistant/components/route53/manifest.json index c04a193d2e1..644dcd499a0 100644 --- a/homeassistant/components/route53/manifest.json +++ b/homeassistant/components/route53/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/route53", "iot_class": "cloud_push", "loggers": ["boto3", "botocore", "s3transfer"], - "requirements": ["boto3==1.20.24"] + "requirements": ["boto3==1.28.17"] } diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 3ca13e56b29..dc0254cc642 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -24,6 +24,7 @@ from homeassistant.const import ( CONF_METHOD, CONF_NAME, CONF_PASSWORD, + CONF_PAYLOAD, CONF_RESOURCE, CONF_TIMEOUT, CONF_UNIQUE_ID, @@ -77,6 +78,7 @@ RESOURCE_SETUP = { vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): SelectSelector( SelectSelectorConfig(options=METHODS, mode=SelectSelectorMode.DROPDOWN) ), + vol.Optional(CONF_PAYLOAD): ObjectSelector(), vol.Optional(CONF_AUTHENTICATION): SelectSelector( SelectSelectorConfig( options=[HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION], @@ -104,7 +106,7 @@ SENSOR_SETUP = { ), vol.Optional(CONF_ATTRIBUTE): TextSelector(), vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), - vol.Required(CONF_DEVICE_CLASS): SelectSelector( + vol.Required(CONF_DEVICE_CLASS, default=NONE_SENTINEL): SelectSelector( SelectSelectorConfig( options=[NONE_SENTINEL] + sorted( @@ -118,14 +120,14 @@ SENSOR_SETUP = { translation_key="device_class", ) ), - vol.Required(CONF_STATE_CLASS): SelectSelector( + vol.Required(CONF_STATE_CLASS, default=NONE_SENTINEL): SelectSelector( SelectSelectorConfig( options=[NONE_SENTINEL] + sorted([cls.value for cls in SensorStateClass]), mode=SelectSelectorMode.DROPDOWN, translation_key="state_class", ) ), - vol.Required(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + vol.Required(CONF_UNIT_OF_MEASUREMENT, default=NONE_SENTINEL): SelectSelector( SelectSelectorConfig( options=[NONE_SENTINEL] + sorted([cls.value for cls in UnitOfTemperature]), custom_value=True, diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 4301bb7d5a0..fc2d83dada4 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -16,6 +16,7 @@ "password": "[%key:common::config_flow::data::password%]", "headers": "Headers", "method": "Method", + "payload": "Payload", "timeout": "Timeout", "encoding": "Character encoding" }, @@ -25,7 +26,8 @@ "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed", "headers": "Headers to use for the web request", "timeout": "Timeout for connection to website", - "encoding": "Character encoding to use. Defaults to UTF-8" + "encoding": "Character encoding to use. Defaults to UTF-8", + "payload": "Payload to use when method is POST" } }, "sensor": { @@ -107,6 +109,7 @@ "data": { "resource": "[%key:component::scrape::config::step::user::data::resource%]", "method": "[%key:component::scrape::config::step::user::data::method%]", + "payload": "[%key:component::scrape::config::step::user::data::payload%]", "authentication": "[%key:component::scrape::config::step::user::data::authentication%]", "username": "[%key:component::scrape::config::step::user::data::username%]", "password": "[%key:component::scrape::config::step::user::data::password%]", @@ -121,7 +124,8 @@ "headers": "[%key:component::scrape::config::step::user::data_description::headers%]", "verify_ssl": "[%key:component::scrape::config::step::user::data_description::verify_ssl%]", "timeout": "[%key:component::scrape::config::step::user::data_description::timeout%]", - "encoding": "[%key:component::scrape::config::step::user::data_description::encoding%]" + "encoding": "[%key:component::scrape::config::step::user::data_description::encoding%]", + "payload": "[%key:component::scrape::config::step::user::data_description::payload%]" } } } diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index cbdaa24ec83..b8151256519 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -5,12 +5,10 @@ import asyncio from collections.abc import Mapping from contextlib import suppress from dataclasses import dataclass -from datetime import date, datetime, timedelta, timezone +from datetime import UTC, date, datetime, timedelta from decimal import Decimal, InvalidOperation as DecimalInvalidOperation import logging -from math import ceil, floor, log10 -import re -import sys +from math import ceil, floor, isfinite, log10 from typing import Any, Final, Self, cast, final from homeassistant.config_entries import ConfigEntry @@ -89,10 +87,6 @@ _LOGGER: Final = logging.getLogger(__name__) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" -NEGATIVE_ZERO_PATTERN = re.compile(r"^-(0\.?0*)$") - -PY_311 = sys.version_info >= (3, 11, 0) - SCAN_INTERVAL: Final = timedelta(seconds=30) __all__ = [ @@ -534,8 +528,8 @@ class SensorEntity(Entity): "which is missing timezone information" ) - if value.tzinfo != timezone.utc: - value = value.astimezone(timezone.utc) + if value.tzinfo != UTC: + value = value.astimezone(UTC) return value.isoformat(timespec="seconds") except (AttributeError, OverflowError, TypeError) as err: @@ -588,7 +582,11 @@ class SensorEntity(Entity): if not isinstance(value, (int, float, Decimal)): try: if isinstance(value, str) and "." not in value and "e" not in value: - numerical_value = int(value) + try: + numerical_value = int(value) + except ValueError: + # Handle nan, inf + numerical_value = float(value) else: numerical_value = float(value) # type:ignore[arg-type] except (TypeError, ValueError) as err: @@ -602,6 +600,15 @@ class SensorEntity(Entity): else: numerical_value = value + if not isfinite(numerical_value): + raise ValueError( + f"Sensor {self.entity_id} has device class '{device_class}', " + f"state class '{state_class}' unit '{unit_of_measurement}' and " + f"suggested precision '{suggested_precision}' thus indicating it " + f"has a numeric value; however, it has the non-finite value: " + f"'{numerical_value}'" + ) + if native_unit_of_measurement != unit_of_measurement and ( converter := UNIT_CONVERTERS.get(device_class) ): @@ -636,12 +643,7 @@ class SensorEntity(Entity): ) precision = precision + floor(ratio_log) - if PY_311: - value = f"{converted_numerical_value:z.{precision}f}" - else: - value = f"{converted_numerical_value:.{precision}f}" - if value.startswith("-0") and NEGATIVE_ZERO_PATTERN.match(value): - value = value[1:] + value = f"{converted_numerical_value:z.{precision}f}" else: value = converted_numerical_value @@ -903,11 +905,6 @@ def async_rounded_state(hass: HomeAssistant, entity_id: str, state: State) -> st with suppress(TypeError, ValueError): numerical_value = float(value) - if PY_311: - value = f"{numerical_value:z.{precision}f}" - else: - value = f"{numerical_value:.{precision}f}" - if value.startswith("-0") and NEGATIVE_ZERO_PATTERN.match(value): - value = value[1:] + value = f"{numerical_value:z.{precision}f}" return value diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index e5e90bf19af..09d9e3655f0 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -4,10 +4,14 @@ from __future__ import annotations import contextlib from typing import Any, Final -from aioshelly.block_device import BlockDevice +from aioshelly.block_device import BlockDevice, BlockUpdateType from aioshelly.common import ConnectionOptions -from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError -from aioshelly.rpc_device import RpcDevice, UpdateType +from aioshelly.exceptions import ( + DeviceConnectionError, + InvalidAuthError, + MacAddressMismatchError, +) +from aioshelly.rpc_device import RpcDevice, RpcUpdateType import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -168,7 +172,7 @@ async def _async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(entry, platforms) @callback - def _async_device_online(_: Any) -> None: + def _async_device_online(_: Any, update_type: BlockUpdateType) -> None: LOGGER.debug("Device %s is online, resuming setup", entry.title) shelly_entry_data.device = None @@ -185,7 +189,7 @@ async def _async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> b LOGGER.debug("Setting up online block device %s", entry.title) try: await device.initialize() - except DeviceConnectionError as err: + except (DeviceConnectionError, MacAddressMismatchError) as err: raise ConfigEntryNotReady(repr(err)) from err except InvalidAuthError as err: raise ConfigEntryAuthFailed(repr(err)) from err @@ -253,7 +257,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo await hass.config_entries.async_forward_entry_setups(entry, platforms) @callback - def _async_device_online(_: Any, update_type: UpdateType) -> None: + def _async_device_online(_: Any, update_type: RpcUpdateType) -> None: LOGGER.debug("Device %s is online, resuming setup", entry.title) shelly_entry_data.device = None @@ -271,7 +275,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo LOGGER.debug("Setting up online RPC device %s", entry.title) try: await device.initialize() - except DeviceConnectionError as err: + except (DeviceConnectionError, MacAddressMismatchError) as err: raise ConfigEntryNotReady(repr(err)) from err except InvalidAuthError as err: raise ConfigEntryAuthFailed(repr(err)) from err diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index cc82f0ad700..33b4caa5034 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -179,3 +179,5 @@ MAX_PUSH_UPDATE_FAILURES = 5 PUSH_UPDATE_ISSUE_ID = "push_update_{unique}" NOT_CALIBRATED_ISSUE_ID = "not_calibrated_{unique}" + +GAS_VALVE_OPEN_STATES = ("opening", "opened") diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 0d4a091b729..d645b09799f 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -9,9 +9,9 @@ from typing import Any, Generic, TypeVar, cast import aioshelly from aioshelly.ble import async_ensure_ble_enabled, async_stop_scanner -from aioshelly.block_device import BlockDevice +from aioshelly.block_device import BlockDevice, BlockUpdateType from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError -from aioshelly.rpc_device import RpcDevice, UpdateType +from aioshelly.rpc_device import RpcDevice, RpcUpdateType from awesomeversion import AwesomeVersion from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -274,8 +274,23 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): except InvalidAuthError: self.entry.async_start_reauth(self.hass) else: + device_update_info(self.hass, self.device, self.entry) + + @callback + def _async_handle_update( + self, device_: BlockDevice, update_type: BlockUpdateType + ) -> None: + """Handle device update.""" + if update_type == BlockUpdateType.COAP_PERIODIC: + self._push_update_failures = 0 + ir.async_delete_issue( + self.hass, + DOMAIN, + PUSH_UPDATE_ISSUE_ID.format(unique=self.mac), + ) + elif update_type == BlockUpdateType.COAP_REPLY: self._push_update_failures += 1 - if self._push_update_failures > MAX_PUSH_UPDATE_FAILURES: + if self._push_update_failures == MAX_PUSH_UPDATE_FAILURES: LOGGER.debug( "Creating issue %s", PUSH_UPDATE_ISSUE_ID.format(unique=self.mac) ) @@ -293,12 +308,15 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): "ip_address": self.device.ip_address, }, ) - device_update_info(self.hass, self.device, self.entry) + LOGGER.debug( + "Push update failures for %s: %s", self.name, self._push_update_failures + ) + self.async_set_updated_data(None) def async_setup(self) -> None: """Set up the coordinator.""" super().async_setup() - self.device.subscribe_updates(self.async_set_updated_data) + self.device.subscribe_updates(self._async_handle_update) def shutdown(self) -> None: """Shutdown the coordinator.""" @@ -535,16 +553,18 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ) @callback - def _async_handle_update(self, device_: RpcDevice, update_type: UpdateType) -> None: + def _async_handle_update( + self, device_: RpcDevice, update_type: RpcUpdateType + ) -> None: """Handle device update.""" - if update_type is UpdateType.INITIALIZED: + if update_type is RpcUpdateType.INITIALIZED: self.hass.async_create_task(self._async_connected()) self.async_set_updated_data(None) - elif update_type is UpdateType.DISCONNECTED: + elif update_type is RpcUpdateType.DISCONNECTED: self.hass.async_create_task(self._async_disconnected()) - elif update_type is UpdateType.STATUS: + elif update_type is RpcUpdateType.STATUS: self.async_set_updated_data(None) - elif update_type is UpdateType.EVENT and (event := self.device.event): + elif update_type is RpcUpdateType.EVENT and (event := self.device.event): self._async_device_event_handler(event) def async_setup(self) -> None: diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 6031b2dcc82..c76e2102fa1 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==5.4.0"], + "requirements": ["aioshelly==6.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index cd9980921c8..abcca888005 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -312,6 +312,24 @@ SENSORS: Final = { value=lambda value: value, extra_state_attributes=lambda block: {"self_test": block.selfTest}, ), + ("valve", "valve"): BlockSensorDescription( + key="valve|valve", + name="Valve status", + translation_key="valve_status", + icon="mdi:valve", + device_class=SensorDeviceClass.ENUM, + options=[ + "checking", + "closed", + "closing", + "failure", + "opened", + "opening", + "unknown", + ], + entity_category=EntityCategory.DIAGNOSTIC, + removal_condition=lambda _, block: block.valve == "not_connected", + ), } REST_SENSORS: Final = { diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 6ff48f5b85b..043ff419742 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -116,6 +116,17 @@ } } } + }, + "valve_status": { + "state": { + "checking": "Checking", + "closed": "Closed", + "closing": "Closing", + "failure": "Failure", + "opened": "Opened", + "opening": "Opening", + "unknown": "[%key:component::shelly::entity::sensor::operation::state::unknown%]" + } } } }, diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 3f5186a2017..395b386993a 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -1,17 +1,25 @@ """Switch for Shelly.""" from __future__ import annotations +from dataclasses import dataclass from typing import Any, cast from aioshelly.block_device import Block -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .const import GAS_VALVE_OPEN_STATES from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data -from .entity import ShellyBlockEntity, ShellyRpcEntity +from .entity import ( + BlockEntityDescription, + ShellyBlockAttributeEntity, + ShellyBlockEntity, + ShellyRpcEntity, + async_setup_block_attribute_entities, +) from .utils import ( async_remove_shelly_entity, get_device_entry_gen, @@ -21,6 +29,19 @@ from .utils import ( ) +@dataclass +class BlockSwitchDescription(BlockEntityDescription, SwitchEntityDescription): + """Class to describe a BLOCK switch.""" + + +GAS_VALVE_SWITCH = BlockSwitchDescription( + key="valve|valve", + name="Valve", + available=lambda block: block.valve not in ("failure", "checking"), + removal_condition=lambda _, block: block.valve in ("not_connected", "unknown"), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -43,6 +64,17 @@ def async_setup_block_entry( coordinator = get_entry_data(hass)[config_entry.entry_id].block assert coordinator + # Add Shelly Gas Valve as a switch + if coordinator.model == "SHGS-1": + async_setup_block_attribute_entities( + hass, + async_add_entities, + coordinator, + {("valve", "valve"): GAS_VALVE_SWITCH}, + BlockValveSwitch, + ) + return + # In roller mode the relay blocks exist but do not contain required info if ( coordinator.model in ["SHSW-21", "SHSW-25"] @@ -94,6 +126,53 @@ def async_setup_rpc_entry( async_add_entities(RpcRelaySwitch(coordinator, id_) for id_ in switch_ids) +class BlockValveSwitch(ShellyBlockAttributeEntity, SwitchEntity): + """Entity that controls a Gas Valve on Block based Shelly devices.""" + + entity_description: BlockSwitchDescription + + def __init__( + self, + coordinator: ShellyBlockCoordinator, + block: Block, + attribute: str, + description: BlockSwitchDescription, + ) -> None: + """Initialize valve.""" + super().__init__(coordinator, block, attribute, description) + self.control_result: dict[str, Any] | None = None + + @property + def is_on(self) -> bool: + """If valve is open.""" + if self.control_result: + return self.control_result["state"] in GAS_VALVE_OPEN_STATES + + return self.attribute_value in GAS_VALVE_OPEN_STATES + + @property + def icon(self) -> str: + """Return the icon.""" + return "mdi:valve-open" if self.is_on else "mdi:valve-closed" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Open valve.""" + self.control_result = await self.set_state(go="open") + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Close valve.""" + self.control_result = await self.set_state(go="close") + self.async_write_ha_state() + + @callback + def _update_callback(self) -> None: + """When device updates, clear control result that overrides state.""" + self.control_result = None + + super()._update_callback() + + class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): """Entity that controls a relay on Block based Shelly devices.""" diff --git a/homeassistant/components/spc/manifest.json b/homeassistant/components/spc/manifest.json index 82f6ed62029..a707e1a7804 100644 --- a/homeassistant/components/spc/manifest.json +++ b/homeassistant/components/spc/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/spc", "iot_class": "local_push", "loggers": ["pyspcwebgw"], - "requirements": ["pyspcwebgw==0.4.0"] + "requirements": ["pyspcwebgw==0.7.0"] } diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index 9290ebeacd5..4e0cbb9d2b9 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from typing import Final, cast from homeassistant.components.sensor import ( @@ -146,9 +146,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( name="Boot time", device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:av-timer", - value=lambda data: datetime.fromtimestamp( - data.system.boot_time, tz=timezone.utc - ), + value=lambda data: datetime.fromtimestamp(data.system.boot_time, tz=UTC), ), SystemBridgeSensorEntityDescription( key="cpu_power_package", diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 81a6badfc34..85f2f82c213 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -1,6 +1,9 @@ """Template platform that aggregates meteorological data.""" from __future__ import annotations +from functools import partial +from typing import Any, Literal + import voluptuous as vol from homeassistant.components.weather import ( @@ -22,9 +25,11 @@ from homeassistant.components.weather import ( ENTITY_ID_FORMAT, Forecast, WeatherEntity, + WeatherEntityFeature, ) from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, CONF_UNIQUE_ID -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity import async_generate_entity_id @@ -39,6 +44,8 @@ from homeassistant.util.unit_conversion import ( from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf +CHECK_FORECAST_KEYS = set().union(Forecast.__annotations__.keys()) + CONDITION_CLASSES = { ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -68,6 +75,9 @@ CONF_WIND_BEARING_TEMPLATE = "wind_bearing_template" CONF_OZONE_TEMPLATE = "ozone_template" CONF_VISIBILITY_TEMPLATE = "visibility_template" CONF_FORECAST_TEMPLATE = "forecast_template" +CONF_FORECAST_DAILY_TEMPLATE = "forecast_daily_template" +CONF_FORECAST_HOURLY_TEMPLATE = "forecast_hourly_template" +CONF_FORECAST_TWICE_DAILY_TEMPLATE = "forecast_twice_daily_template" CONF_PRESSURE_UNIT = "pressure_unit" CONF_WIND_SPEED_UNIT = "wind_speed_unit" CONF_VISIBILITY_UNIT = "visibility_unit" @@ -77,30 +87,40 @@ CONF_CLOUD_COVERAGE_TEMPLATE = "cloud_coverage_template" CONF_DEW_POINT_TEMPLATE = "dew_point_template" CONF_APPARENT_TEMPERATURE_TEMPLATE = "apparent_temperature_template" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_CONDITION_TEMPLATE): cv.template, - vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, - vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, - vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, - vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, - vol.Optional(CONF_OZONE_TEMPLATE): cv.template, - vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, - vol.Optional(CONF_FORECAST_TEMPLATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), - vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), - vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), - vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), - vol.Optional(CONF_PRECIPITATION_UNIT): vol.In(DistanceConverter.VALID_UNITS), - vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, - vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, - vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, - } +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_FORECAST_TEMPLATE), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_CONDITION_TEMPLATE): cv.template, + vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, + vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, + vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, + vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, + vol.Optional(CONF_OZONE_TEMPLATE): cv.template, + vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_HOURLY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_TWICE_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_TEMPERATURE_UNIT): vol.In( + TemperatureConverter.VALID_UNITS + ), + vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), + vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), + vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_PRECIPITATION_UNIT): vol.In( + DistanceConverter.VALID_UNITS + ), + vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, + vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, + vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, + } + ), ) @@ -151,6 +171,11 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): self._ozone_template = config.get(CONF_OZONE_TEMPLATE) self._visibility_template = config.get(CONF_VISIBILITY_TEMPLATE) self._forecast_template = config.get(CONF_FORECAST_TEMPLATE) + self._forecast_daily_template = config.get(CONF_FORECAST_DAILY_TEMPLATE) + self._forecast_hourly_template = config.get(CONF_FORECAST_HOURLY_TEMPLATE) + self._forecast_twice_daily_template = config.get( + CONF_FORECAST_TWICE_DAILY_TEMPLATE + ) self._wind_gust_speed_template = config.get(CONF_WIND_GUST_SPEED_TEMPLATE) self._cloud_coverage_template = config.get(CONF_CLOUD_COVERAGE_TEMPLATE) self._dew_point_template = config.get(CONF_DEW_POINT_TEMPLATE) @@ -180,6 +205,17 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): self._dew_point = None self._apparent_temperature = None self._forecast: list[Forecast] = [] + self._forecast_daily: list[Forecast] = [] + self._forecast_hourly: list[Forecast] = [] + self._forecast_twice_daily: list[Forecast] = [] + + self._attr_supported_features = 0 + if self._forecast_daily_template: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_DAILY + if self._forecast_hourly_template: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_HOURLY + if self._forecast_twice_daily_template: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_TWICE_DAILY @property def condition(self) -> str | None: @@ -246,6 +282,18 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): """Return the forecast.""" return self._forecast + async def async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return self._forecast_daily + + async def async_forecast_hourly(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return self._forecast_hourly + + async def async_forecast_twice_daily(self) -> list[Forecast]: + """Return the daily forecast in native units.""" + return self._forecast_twice_daily + @property def attribution(self) -> str | None: """Return the attribution.""" @@ -327,4 +375,73 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): "_forecast", self._forecast_template, ) + + if self._forecast_daily_template: + self.add_template_attribute( + "_forecast_daily", + self._forecast_daily_template, + on_update=partial(self._update_forecast, "daily"), + validator=partial(self._validate_forecast, "daily"), + ) + if self._forecast_hourly_template: + self.add_template_attribute( + "_forecast_hourly", + self._forecast_hourly_template, + on_update=partial(self._update_forecast, "hourly"), + validator=partial(self._validate_forecast, "hourly"), + ) + if self._forecast_twice_daily_template: + self.add_template_attribute( + "_forecast_twice_daily", + self._forecast_twice_daily_template, + on_update=partial(self._update_forecast, "twice_daily"), + validator=partial(self._validate_forecast, "twice_daily"), + ) + await super().async_added_to_hass() + + @callback + def _update_forecast( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + result: list[Forecast] | TemplateError, + ) -> None: + """Save template result and trigger forecast listener.""" + attr_result = None if isinstance(result, TemplateError) else result + setattr(self, f"_forecast_{forecast_type}", attr_result) + self.hass.create_task(self.async_update_listeners([forecast_type])) + + @callback + def _validate_forecast( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + result: Any, + ) -> list[Forecast] | None: + """Validate the forecasts.""" + if result is None: + return None + + if not isinstance(result, list): + raise vol.Invalid( + "Forecasts is not a list, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + for forecast in result: + if not isinstance(forecast, dict): + raise vol.Invalid( + "Forecast in list is not a dict, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + diff_result = set().union(forecast.keys()).difference(CHECK_FORECAST_KEYS) + if diff_result: + raise vol.Invalid( + "Only valid keys in Forecast are allowed, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + if forecast_type == "twice_daily" and "is_daytime" not in forecast: + raise vol.Invalid( + "`is_daytime` is missing in twice_daily forecast, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + if "datetime" not in forecast: + raise vol.Invalid( + "`datetime` is required in forecasts, see Weather documentation https://www.home-assistant.io/integrations/weather/" + ) + continue + return result diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index 22e2c1822c1..f814fbffbd0 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -202,8 +202,17 @@ class DatasetStore: raise HomeAssistantError("Invalid dataset") # Bail out if the dataset already exists - if any(entry for entry in self.datasets.values() if entry.dataset == dataset): - return + entry: DatasetEntry | None + for entry in self.datasets.values(): + if entry.dataset == dataset: + if ( + preferred_border_agent_id + and entry.preferred_border_agent_id is None + ): + self.async_set_preferred_border_agent_id( + entry.id, preferred_border_agent_id + ) + return # Update if dataset with same extended pan id exists and the timestamp # is newer @@ -248,6 +257,10 @@ class DatasetStore: self.datasets[entry.id], tlv=tlv ) self.async_schedule_save() + if preferred_border_agent_id and entry.preferred_border_agent_id is None: + self.async_set_preferred_border_agent_id( + entry.id, preferred_border_agent_id + ) return entry = DatasetEntry( diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 7ccb4f673cd..119a3dfe582 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -118,6 +118,7 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key=TMRW_ATTR_DEW_POINT, name="Dew Point", + icon="mdi:thermometer-water", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), @@ -142,6 +143,7 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key=TMRW_ATTR_CLOUD_BASE, name="Cloud Base", + icon="mdi:cloud-arrow-down", unit_imperial=UnitOfLength.MILES, unit_metric=UnitOfLength.KILOMETERS, imperial_conversion=lambda val: DistanceConverter.convert( @@ -154,6 +156,7 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key=TMRW_ATTR_CLOUD_CEILING, name="Cloud Ceiling", + icon="mdi:cloud-arrow-up", unit_imperial=UnitOfLength.MILES, unit_metric=UnitOfLength.KILOMETERS, imperial_conversion=lambda val: DistanceConverter.convert( @@ -165,12 +168,14 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key=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=TMRW_ATTR_WIND_GUST, name="Wind Gust", + icon="mdi:weather-windy", unit_imperial=UnitOfSpeed.MILES_PER_HOUR, unit_metric=UnitOfSpeed.METERS_PER_SECOND, imperial_conversion=lambda val: SpeedConverter.convert( @@ -270,9 +275,9 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key=TMRW_ATTR_POLLEN_TREE, name="Tree Pollen Index", + icon="mdi:tree", value_map=PollenIndex, translation_key="pollen_index", - icon="mdi:flower-pollen", ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_POLLEN_WEED, @@ -284,9 +289,9 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key=TMRW_ATTR_POLLEN_GRASS, name="Grass Pollen Index", + icon="mdi:grass", value_map=PollenIndex, translation_key="pollen_index", - icon="mdi:flower-pollen", ), TomorrowioSensorEntityDescription( TMRW_ATTR_FIRE_INDEX, @@ -304,7 +309,7 @@ SENSOR_TYPES = ( name="UV Radiation Health Concern", value_map=UVDescription, translation_key="uv_index", - icon="mdi:sun-wireless", + icon="mdi:weather-sunny-alert", ), ) diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index ec77a2c8040..f88887e64dd 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -17,8 +17,8 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, DOMAIN as WEATHER_DOMAIN, + CoordinatorWeatherEntity, Forecast, - WeatherEntity, WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -31,7 +31,7 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import is_up @@ -93,7 +93,7 @@ def _calculate_unique_id(config_entry_unique_id: str | None, forecast_type: str) return f"{config_entry_unique_id}_{forecast_type}" -class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): +class TomorrowioWeatherEntity(TomorrowioEntity, CoordinatorWeatherEntity): """Entity that talks to Tomorrow.io v4 API to retrieve weather data.""" _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS @@ -123,15 +123,6 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): config_entry.unique_id, forecast_type ) - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - super()._handle_coordinator_update() - assert self.platform.config_entry - self.platform.config_entry.async_create_task( - self.hass, self.async_update_listeners(("daily", "hourly")) - ) - def _forecast_dict( self, forecast_dt: datetime, diff --git a/homeassistant/components/unifi/entity.py b/homeassistant/components/unifi/entity.py index 05ad2f56a8c..28a7b557b16 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -53,12 +53,12 @@ def async_wlan_available_fn(controller: UniFiController, obj_id: str) -> bool: @callback -def async_device_device_info_fn(api: aiounifi.Controller, obj_id: str) -> DeviceInfo: +def async_device_device_info_fn(controller: UniFiController, obj_id: str) -> DeviceInfo: """Create device registry entry for device.""" if "_" in obj_id: # Sub device (outlet or port) obj_id = obj_id.partition("_")[0] - device = api.devices[obj_id] + device = controller.api.devices[obj_id] return DeviceInfo( connections={(CONNECTION_NETWORK_MAC, device.mac)}, manufacturer=ATTR_MANUFACTURER, @@ -70,9 +70,9 @@ def async_device_device_info_fn(api: aiounifi.Controller, obj_id: str) -> Device @callback -def async_wlan_device_info_fn(api: aiounifi.Controller, obj_id: str) -> DeviceInfo: +def async_wlan_device_info_fn(controller: UniFiController, obj_id: str) -> DeviceInfo: """Create device registry entry for WLAN.""" - wlan = api.wlans[obj_id] + wlan = controller.api.wlans[obj_id] return DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, wlan.id)}, @@ -83,9 +83,9 @@ def async_wlan_device_info_fn(api: aiounifi.Controller, obj_id: str) -> DeviceIn @callback -def async_client_device_info_fn(api: aiounifi.Controller, obj_id: str) -> DeviceInfo: +def async_client_device_info_fn(controller: UniFiController, obj_id: str) -> DeviceInfo: """Create device registry entry for client.""" - client = api.clients[obj_id] + client = controller.api.clients[obj_id] return DeviceInfo( connections={(CONNECTION_NETWORK_MAC, obj_id)}, default_manufacturer=client.oui, @@ -100,7 +100,7 @@ class UnifiDescription(Generic[HandlerT, ApiItemT]): allowed_fn: Callable[[UniFiController, str], bool] api_handler_fn: Callable[[aiounifi.Controller], HandlerT] available_fn: Callable[[UniFiController, str], bool] - device_info_fn: Callable[[aiounifi.Controller, str], DeviceInfo | None] + device_info_fn: Callable[[UniFiController, str], DeviceInfo | None] event_is_on: tuple[EventKey, ...] | None event_to_subscribe: tuple[EventKey, ...] | None name_fn: Callable[[ApiItemT], str | None] @@ -137,7 +137,7 @@ class UnifiEntity(Entity, Generic[HandlerT, ApiItemT]): self._removed = False self._attr_available = description.available_fn(controller, obj_id) - self._attr_device_info = description.device_info_fn(controller.api, obj_id) + self._attr_device_info = description.device_info_fn(controller, obj_id) self._attr_should_poll = description.should_poll self._attr_unique_id = description.unique_id_fn(controller, obj_id) diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index e2b4dda3912..046aa3a1abd 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -17,6 +17,7 @@ from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.dpi_restriction_groups import DPIRestrictionGroups from aiounifi.interfaces.outlets import Outlets +from aiounifi.interfaces.port_forwarding import PortForwarding from aiounifi.interfaces.ports import Ports from aiounifi.interfaces.wlans import Wlans from aiounifi.models.api import ApiItemT @@ -30,6 +31,7 @@ from aiounifi.models.dpi_restriction_group import DPIRestrictionGroup from aiounifi.models.event import Event, EventKey from aiounifi.models.outlet import Outlet from aiounifi.models.port import Port +from aiounifi.models.port_forward import PortForward, PortForwardEnableRequest from aiounifi.models.wlan import Wlan, WlanEnableRequest from homeassistant.components.switch import ( @@ -75,7 +77,9 @@ def async_dpi_group_is_on_fn( @callback -def async_dpi_group_device_info_fn(api: aiounifi.Controller, obj_id: str) -> DeviceInfo: +def async_dpi_group_device_info_fn( + controller: UniFiController, obj_id: str +) -> DeviceInfo: """Create device registry entry for DPI group.""" return DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -86,6 +90,22 @@ def async_dpi_group_device_info_fn(api: aiounifi.Controller, obj_id: str) -> Dev ) +@callback +def async_port_forward_device_info_fn( + controller: UniFiController, obj_id: str +) -> DeviceInfo: + """Create device registry entry for port forward.""" + unique_id = controller.config_entry.unique_id + assert unique_id is not None + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, unique_id)}, + manufacturer=ATTR_MANUFACTURER, + model="UniFi Network", + name="UniFi Network", + ) + + async def async_block_client_control_fn( api: aiounifi.Controller, obj_id: str, target: bool ) -> None: @@ -136,6 +156,14 @@ async def async_poe_port_control_fn( await api.request(DeviceSetPoePortModeRequest.create(device, int(index), state)) +async def async_port_forward_control_fn( + api: aiounifi.Controller, obj_id: str, target: bool +) -> None: + """Control port forward state.""" + port_forward = api.port_forwarding[obj_id] + await api.request(PortForwardEnableRequest.create(port_forward, target)) + + async def async_wlan_control_fn( api: aiounifi.Controller, obj_id: str, target: bool ) -> None: @@ -222,6 +250,26 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( supported_fn=async_outlet_supports_switching_fn, unique_id_fn=lambda controller, obj_id: f"{obj_id.split('_', 1)[0]}-outlet-{obj_id.split('_', 1)[1]}", ), + UnifiSwitchEntityDescription[PortForwarding, PortForward]( + key="Port forward control", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + has_entity_name=True, + icon="mdi:upload-network", + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.port_forwarding, + available_fn=lambda controller, obj_id: controller.available, + control_fn=async_port_forward_control_fn, + device_info_fn=async_port_forward_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + is_on_fn=lambda controller, port_forward: port_forward.enabled, + name_fn=lambda port_forward: f"{port_forward.name}", + object_fn=lambda api, obj_id: api.port_forwarding[obj_id], + should_poll=False, + supported_fn=lambda controller, obj_id: True, + unique_id_fn=lambda controller, obj_id: f"port_forward-{obj_id}", + ), UnifiSwitchEntityDescription[Ports, Port]( key="PoE port control", device_class=SwitchDeviceClass.OUTLET, diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index 895dababd54..0a751b7eea2 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -7,7 +7,7 @@ import logging from typing import final from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent @@ -71,16 +71,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class WakeWordDetectionEntity(RestoreEntity): """Represent a single wake word provider.""" + _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_should_poll = False - __last_processed: str | None = None + __last_detected: str | None = None @property @final def state(self) -> str | None: """Return the state of the entity.""" - if self.__last_processed is None: + if self.__last_detected is None: return None - return self.__last_processed + return self.__last_detected @property @abstractmethod @@ -103,9 +104,13 @@ class WakeWordDetectionEntity(RestoreEntity): Audio must be 16Khz sample rate with 16-bit mono PCM samples. """ - self.__last_processed = dt_util.utcnow().isoformat() - self.async_write_ha_state() - return await self._async_process_audio_stream(stream) + result = await self._async_process_audio_stream(stream) + if result is not None: + # Update last detected only when there is a detection + self.__last_detected = dt_util.utcnow().isoformat() + self.async_write_ha_state() + + return result async def async_internal_added_to_hass(self) -> None: """Call when the entity is added to hass.""" @@ -116,4 +121,4 @@ class WakeWordDetectionEntity(RestoreEntity): and state.state is not None and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) ): - self.__last_processed = state.state + self.__last_detected = state.state diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 74671f0c1df..eb137f06d7b 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from datetime import timedelta import inspect import logging -from typing import Any, Final, Literal, Required, TypedDict, final +from typing import Any, Final, Literal, Required, TypedDict, TypeVar, final import voluptuous as vol @@ -37,6 +37,10 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from homeassistant.util.json import JsonValueType from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -117,6 +121,10 @@ ROUNDING_PRECISION = 2 SERVICE_GET_FORECAST: Final = "get_forecast" +_DataUpdateCoordinatorT = TypeVar( + "_DataUpdateCoordinatorT", bound="DataUpdateCoordinator[Any]" +) + # mypy: disallow-any-generics @@ -1071,6 +1079,22 @@ class WeatherEntity(Entity, PostInit): ) and custom_unit_visibility in VALID_UNITS[ATTR_WEATHER_VISIBILITY_UNIT]: self._weather_option_visibility_unit = custom_unit_visibility + @callback + def _async_subscription_started( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + ) -> None: + """Start subscription to forecast_type.""" + return None + + @callback + def _async_subscription_ended( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + ) -> None: + """End subscription to forecast_type.""" + return None + @final @callback def async_subscribe_forecast( @@ -1082,11 +1106,16 @@ class WeatherEntity(Entity, PostInit): Called by websocket API. """ + subscription_started = not self._forecast_listeners[forecast_type] self._forecast_listeners[forecast_type].append(forecast_listener) + if subscription_started: + self._async_subscription_started(forecast_type) @callback def unsubscribe() -> None: self._forecast_listeners[forecast_type].remove(forecast_listener) + if not self._forecast_listeners[forecast_type]: + self._async_subscription_ended(forecast_type) return unsubscribe @@ -1155,3 +1184,18 @@ async def async_get_forecast_service( return { "forecast": converted_forecast_list, } + + +class CoordinatorWeatherEntity( + CoordinatorEntity[_DataUpdateCoordinatorT], WeatherEntity +): + """A class for weather entities using a single DataUpdateCoordinator.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + super()._handle_coordinator_update() + assert self.coordinator.config_entry + self.coordinator.config_entry.async_create_task( + self.hass, self.async_update_listeners(None) + ) diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index 5163e0b3a6e..72c366bb0bc 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import cast from whois import Domain @@ -55,7 +55,7 @@ def _ensure_timezone(timestamp: datetime | None) -> datetime | None: # If timezone info isn't provided by the Whois, assume UTC. if timestamp.tzinfo is None: - return timestamp.replace(tzinfo=timezone.utc) + return timestamp.replace(tzinfo=UTC) return timestamp diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index d8d31451567..84ed67a36dd 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError -from .const import CONF_COUNTRY, CONF_PROVINCE, LOGGER, PLATFORMS +from .const import CONF_COUNTRY, CONF_PROVINCE, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -16,12 +16,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: country: str = entry.options[CONF_COUNTRY] province: str | None = entry.options.get(CONF_PROVINCE) if country and country not in list_supported_countries(): - LOGGER.error("There is no country %s", country) - raise ConfigEntryError("Selected country is not valid") + raise ConfigEntryError(f"Selected country {country} is not valid") if province and province not in list_supported_countries()[country]: - LOGGER.error("There is no subdivision %s in country %s", province, country) - raise ConfigEntryError("Selected province is not valid") + raise ConfigEntryError( + f"Selected province {province} for country {country} is not valid" + ) entry.async_on_unload(entry.add_update_listener(async_update_listener)) diff --git a/homeassistant/components/wyoming/config_flow.py b/homeassistant/components/wyoming/config_flow.py index 3fccbaea9c4..f6b8ed73890 100644 --- a/homeassistant/components/wyoming/config_flow.py +++ b/homeassistant/components/wyoming/config_flow.py @@ -93,9 +93,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: uri = urlparse(self._hassio_discovery.config["uri"]) if service := await WyomingService.create(uri.hostname, uri.port): - if not any( - asr for asr in service.info.asr if asr.installed - ) and not any(tts for tts in service.info.tts if tts.installed): + if ( + not any(asr for asr in service.info.asr if asr.installed) + and not any(tts for tts in service.info.tts if tts.installed) + and not any(wake for wake in service.info.wake if wake.installed) + ): return self.async_abort(reason="no_services") return self.async_create_entry( diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 0d63e87db17..6b04b6c7c4a 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.80.0"] + "requirements": ["zeroconf==0.82.1"] } diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 29fed3a3c9f..4f23945b105 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -111,6 +111,10 @@ "type": "_zigstar_gw._tcp.local.", "name": "*zigstar*" }, + { + "type": "_uzg-01._tcp.local.", + "name": "uzg-01*" + }, { "type": "_slzb-06._tcp.local.", "name": "slzb-06*" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 0e520d98b52..c514e02ec57 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations import enum import functools import numbers -import sys from typing import TYPE_CHECKING, Any, Self from zigpy import types @@ -485,7 +484,7 @@ class SmartEnergyMetering(Sensor): if self._cluster_handler.device_type is not None: attrs["device_type"] = self._cluster_handler.device_type if (status := self._cluster_handler.status) is not None: - if isinstance(status, enum.IntFlag) and sys.version_info >= (3, 11): + if isinstance(status, enum.IntFlag): attrs["status"] = str( status.name if status.name is not None else status.value ) diff --git a/homeassistant/config.py b/homeassistant/config.py index 0d9e1d9034e..7c3bd2e7bfe 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -257,10 +257,10 @@ CORE_CONFIG_SCHEMA = vol.All( vol.Optional(CONF_INTERNAL_URL): cv.url, vol.Optional(CONF_EXTERNAL_URL): cv.url, vol.Optional(CONF_ALLOWLIST_EXTERNAL_DIRS): vol.All( - cv.ensure_list, [vol.IsDir()] # pylint: disable=no-value-for-parameter + cv.ensure_list, [vol.IsDir()] ), vol.Optional(LEGACY_CONF_WHITELIST_EXTERNAL_DIRS): vol.All( - cv.ensure_list, [vol.IsDir()] # pylint: disable=no-value-for-parameter + cv.ensure_list, [vol.IsDir()] ), vol.Optional(CONF_ALLOWLIST_EXTERNAL_URLS): vol.All( cv.ensure_list, [cv.url] @@ -297,7 +297,6 @@ CORE_CONFIG_SCHEMA = vol.All( ], _no_duplicate_auth_mfa_module, ), - # pylint: disable-next=no-value-for-parameter vol.Optional(CONF_MEDIA_DIRS): cv.schema_with_slug_keys(vol.IsDir()), vol.Optional(CONF_LEGACY_TEMPLATES): cv.boolean, vol.Optional(CONF_CURRENCY): _validate_currency, diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 1e3af81395a..d3ff741e3e6 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1814,6 +1814,14 @@ class ConfigFlow(data_entry_flow.FlowHandler): class OptionsFlowManager(data_entry_flow.FlowManager): """Flow to set options for a configuration entry.""" + def _async_get_config_entry(self, config_entry_id: str) -> ConfigEntry: + """Return config entry or raise if not found.""" + entry = self.hass.config_entries.async_get_entry(config_entry_id) + if entry is None: + raise UnknownEntry(config_entry_id) + + return entry + async def async_create_flow( self, handler_key: str, @@ -1825,10 +1833,7 @@ class OptionsFlowManager(data_entry_flow.FlowManager): Entry_id and flow.handler is the same thing to map entry with flow. """ - entry = self.hass.config_entries.async_get_entry(handler_key) - if entry is None: - raise UnknownEntry(handler_key) - + entry = self._async_get_config_entry(handler_key) handler = await _async_get_flow_handler(self.hass, entry.domain, {}) return handler.async_get_options_flow(entry) @@ -1853,6 +1858,14 @@ class OptionsFlowManager(data_entry_flow.FlowManager): result["result"] = True return result + async def _async_setup_preview(self, flow: data_entry_flow.FlowHandler) -> None: + """Set up preview for an option flow handler.""" + entry = self._async_get_config_entry(flow.handler) + await _load_integration(self.hass, entry.domain, {}) + if entry.domain not in self._preview: + self._preview.add(entry.domain) + flow.async_setup_preview(self.hass) + class OptionsFlow(data_entry_flow.FlowHandler): """Base class for config options flows.""" @@ -2016,15 +2029,9 @@ async def support_remove_from_device(hass: HomeAssistant, domain: str) -> bool: return hasattr(component, "async_remove_config_entry_device") -async def _async_get_flow_handler( +async def _load_integration( hass: HomeAssistant, domain: str, hass_config: ConfigType -) -> type[ConfigFlow]: - """Get a flow handler for specified domain.""" - - # First check if there is a handler registered for the domain - if domain in hass.config.components and (handler := HANDLERS.get(domain)): - return handler - +) -> None: try: integration = await loader.async_get_integration(hass, domain) except loader.IntegrationNotFound as err: @@ -2044,6 +2051,18 @@ async def _async_get_flow_handler( ) raise data_entry_flow.UnknownHandler + +async def _async_get_flow_handler( + hass: HomeAssistant, domain: str, hass_config: ConfigType +) -> type[ConfigFlow]: + """Get a flow handler for specified domain.""" + + # First check if there is a handler registered for the domain + if domain in hass.config.components and (handler := HANDLERS.get(domain)): + return handler + + await _load_integration(hass, domain, hass_config) + if handler := HANDLERS.get(domain): return handler diff --git a/homeassistant/const.py b/homeassistant/const.py index adca3dc965c..66d05f0bd4f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -39,6 +39,7 @@ class Platform(StrEnum): HUMIDIFIER = "humidifier" IMAGE = "image" IMAGE_PROCESSING = "image_processing" + LAWN_MOWER = "lawn_mower" LIGHT = "light" LOCK = "lock" MAILBOX = "mailbox" diff --git a/homeassistant/core.py b/homeassistant/core.py index 49c288188f3..18c5c355ae9 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -108,7 +108,7 @@ _P = ParamSpec("_P") # Internal; not helpers.typing.UNDEFINED due to circular dependency _UNDEF: dict[Any, Any] = {} _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) -CALLBACK_TYPE = Callable[[], None] # pylint: disable=invalid-name +CALLBACK_TYPE = Callable[[], None] CORE_STORAGE_KEY = "core.config" CORE_STORAGE_VERSION = 1 @@ -847,8 +847,7 @@ class HomeAssistant: if ( not handle.cancelled() and (args := handle._args) # pylint: disable=protected-access - # pylint: disable-next=unidiomatic-typecheck - and type(job := args[0]) is HassJob + and type(job := args[0]) is HassJob # noqa: E721 and job.cancel_on_shutdown ): handle.cancel() diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index e0408a24b2e..04876590d2b 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -95,6 +95,7 @@ class FlowResult(TypedDict, total=False): last_step: bool | None menu_options: list[str] | dict[str, str] options: Mapping[str, Any] + preview: str | None progress_action: str reason: str required: bool @@ -135,6 +136,7 @@ class FlowManager(abc.ABC): ) -> None: """Initialize the flow manager.""" self.hass = hass + self._preview: set[str] = set() self._progress: dict[str, FlowHandler] = {} self._handler_progress_index: dict[str, set[str]] = {} self._init_data_process_index: dict[type, set[str]] = {} @@ -395,6 +397,10 @@ class FlowManager(abc.ABC): flow.flow_id, flow.handler, err.reason, err.description_placeholders ) + # Setup the flow handler's preview if needed + if result.get("preview") is not None: + await self._async_setup_preview(flow) + if not isinstance(result["type"], FlowResultType): result["type"] = FlowResultType(result["type"]) # type: ignore[unreachable] report( @@ -429,6 +435,12 @@ class FlowManager(abc.ABC): return result + async def _async_setup_preview(self, flow: FlowHandler) -> None: + """Set up preview for a flow handler.""" + if flow.handler not in self._preview: + self._preview.add(flow.handler) + flow.async_setup_preview(self.hass) + class FlowHandler: """Handle a data entry flow.""" @@ -504,6 +516,7 @@ class FlowHandler: errors: dict[str, str] | None = None, description_placeholders: Mapping[str, str | None] | None = None, last_step: bool | None = None, + preview: str | None = None, ) -> FlowResult: """Return the definition of a form to gather user input.""" return FlowResult( @@ -515,6 +528,7 @@ class FlowHandler: errors=errors, description_placeholders=description_placeholders, last_step=last_step, # Display next or submit button in frontend + preview=preview, # Display preview component in frontend ) @callback @@ -635,6 +649,11 @@ class FlowHandler: def async_remove(self) -> None: """Notification that the flow has been removed.""" + @callback + @staticmethod + def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview.""" + @callback def _create_abort_data( diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 6b5676c4a25..3874a06ab4b 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -683,6 +683,12 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_uzg-01._tcp.local.": [ + { + "domain": "zha", + "name": "uzg-01*", + }, + ], "_viziocast._tcp.local.": [ { "domain": "vizio", diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 5e0d66e0a9a..a4018101d0e 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -102,8 +102,6 @@ import homeassistant.util.dt as dt_util from . import script_variables as script_variables_helper, template as template_helper -# pylint: disable=invalid-name - TIME_PERIOD_ERROR = "offset {} should be format 'HH:MM', 'HH:MM:SS' or 'HH:MM:SS.F'" @@ -743,7 +741,6 @@ def socket_timeout(value: Any | None) -> object: raise vol.Invalid(f"Invalid socket timeout: {err}") from err -# pylint: disable=no-value-for-parameter def url( value: Any, _schema_list: frozenset[UrlProtocolSchema] = EXTERNAL_URL_PROTOCOL_SCHEMA_LIST, @@ -1360,7 +1357,7 @@ STATE_CONDITION_ATTRIBUTE_SCHEMA = vol.Schema( ) -def STATE_CONDITION_SCHEMA(value: Any) -> dict: # pylint: disable=invalid-name +def STATE_CONDITION_SCHEMA(value: Any) -> dict: """Validate a state condition.""" if not isinstance(value, dict): raise vol.Invalid("Expected a dictionary") diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index 4e5d152135a..54b90077cdc 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -64,9 +64,7 @@ class Debouncer(Generic[_R_co]): async def async_call(self) -> None: """Call the function.""" if self._shutdown_requested: - self.logger.warning( - "Debouncer call ignored as shutdown has been requested." - ) + self.logger.debug("Debouncer call ignored as shutdown has been requested.") return assert self._job is not None diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9338346fc8b..29a944874ab 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -756,31 +756,10 @@ class Entity(ABC): return f"{device_name} {name}" if device_name else name @callback - def _async_write_ha_state(self) -> None: - """Write the state to the state machine.""" - if self._platform_state == EntityPlatformState.REMOVED: - # Polling returned after the entity has already been removed - return - - hass = self.hass - entity_id = self.entity_id + def _async_generate_attributes(self) -> tuple[str, dict[str, Any]]: + """Calculate state string and attribute mapping.""" entry = self.registry_entry - if entry and entry.disabled_by: - if not self._disabled_reported: - self._disabled_reported = True - _LOGGER.warning( - ( - "Entity %s is incorrectly being triggered for updates while it" - " is disabled. This is a bug in the %s integration" - ), - entity_id, - self.platform.platform_name, - ) - return - - start = timer() - attr = self.capability_attributes attr = dict(attr) if attr else {} @@ -818,6 +797,33 @@ class Entity(ABC): if (supported_features := self.supported_features) is not None: attr[ATTR_SUPPORTED_FEATURES] = supported_features + return (state, attr) + + @callback + def _async_write_ha_state(self) -> None: + """Write the state to the state machine.""" + if self._platform_state == EntityPlatformState.REMOVED: + # Polling returned after the entity has already been removed + return + + hass = self.hass + entity_id = self.entity_id + + if (entry := self.registry_entry) and entry.disabled_by: + if not self._disabled_reported: + self._disabled_reported = True + _LOGGER.warning( + ( + "Entity %s is incorrectly being triggered for updates while it" + " is disabled. This is a bug in the %s integration" + ), + entity_id, + self.platform.platform_name, + ) + return + + start = timer() + state, attr = self._async_generate_attributes() end = timer() if end - start > 0.4 and not self._slow_reported: diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index 33054bcb1b0..e94093cfd2f 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, Final import orjson from homeassistant.util.file import write_utf8_file, write_utf8_file_atomic -from homeassistant.util.json import ( # pylint: disable=unused-import # noqa: F401 +from homeassistant.util.json import ( # noqa: F401 JSON_DECODE_EXCEPTIONS, JSON_ENCODE_EXCEPTIONS, SerializationError, diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 653594f2808..e9d86f79eec 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -78,6 +78,9 @@ class SchemaFlowFormStep(SchemaFlowStep): have priority over the suggested values. """ + preview: str | None = None + """Optional preview component.""" + @dataclass(slots=True) class SchemaFlowMenuStep(SchemaFlowStep): @@ -237,6 +240,7 @@ class SchemaCommonFlowHandler: data_schema=data_schema, errors=errors, last_step=last_step, + preview=form_step.preview, ) async def _async_menu_step( @@ -271,7 +275,10 @@ class SchemaConfigFlowHandler(config_entries.ConfigFlow, ABC): raise UnknownHandler return SchemaOptionsFlowHandler( - config_entry, cls.options_flow, cls.async_options_flow_finished + config_entry, + cls.options_flow, + cls.async_options_flow_finished, + cls.async_setup_preview, ) # Create an async_get_options_flow method @@ -285,6 +292,11 @@ class SchemaConfigFlowHandler(config_entries.ConfigFlow, ABC): """Initialize config flow.""" self._common_handler = SchemaCommonFlowHandler(self, self.config_flow, None) + @callback + @staticmethod + def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview.""" + @classmethod @callback def async_supports_options_flow( @@ -336,7 +348,7 @@ class SchemaConfigFlowHandler(config_entries.ConfigFlow, ABC): """ @callback - def async_create_entry( # pylint: disable=arguments-differ + def async_create_entry( self, data: Mapping[str, Any], **kwargs: Any, @@ -357,6 +369,7 @@ class SchemaOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): options_flow: Mapping[str, SchemaFlowStep], async_options_flow_finished: Callable[[HomeAssistant, Mapping[str, Any]], None] | None = None, + async_setup_preview: Callable[[HomeAssistant], None] | None = None, ) -> None: """Initialize options flow. @@ -378,6 +391,9 @@ class SchemaOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): types.MethodType(self._async_step(step), self), ) + if async_setup_preview: + setattr(self, "async_setup_preview", async_setup_preview) + @staticmethod def _async_step(step_id: str) -> Callable: """Generate a step handler.""" @@ -393,7 +409,7 @@ class SchemaOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): return _async_step @callback - def async_create_entry( # pylint: disable=arguments-differ + def async_create_entry( self, data: Mapping[str, Any], **kwargs: Any, diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index ba473758121..efb1ee0b1f1 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -92,6 +92,7 @@ def _entity_features() -> dict[str, type[IntFlag]]: from homeassistant.components.cover import CoverEntityFeature from homeassistant.components.fan import FanEntityFeature from homeassistant.components.humidifier import HumidifierEntityFeature + from homeassistant.components.lawn_mower import LawnMowerEntityFeature from homeassistant.components.light import LightEntityFeature from homeassistant.components.lock import LockEntityFeature from homeassistant.components.media_player import MediaPlayerEntityFeature @@ -110,6 +111,7 @@ def _entity_features() -> dict[str, type[IntFlag]]: "CoverEntityFeature": CoverEntityFeature, "FanEntityFeature": FanEntityFeature, "HumidifierEntityFeature": HumidifierEntityFeature, + "LawnMowerEntityFeature": LawnMowerEntityFeature, "LightEntityFeature": LightEntityFeature, "LockEntityFeature": LockEntityFeature, "MediaPlayerEntityFeature": MediaPlayerEntityFeature, diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index c83481365ab..0e92cc6ff01 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -237,7 +237,6 @@ class Store(Generic[_T]): self.minor_version, ) if len(inspect.signature(self._async_migrate_func).parameters) == 2: - # pylint: disable-next=no-value-for-parameter stored = await self._async_migrate_func(data["version"], data["data"]) else: try: diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 697e47187ce..40161bd3be9 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -897,7 +897,7 @@ async def async_get_integrations( for domain in domains: int_or_fut = cache.get(domain, _UNDEF) # Integration is never subclassed, so we can check for type - if type(int_or_fut) is Integration: # pylint: disable=unidiomatic-typecheck + if type(int_or_fut) is Integration: # noqa: E721 results[domain] = int_or_fut elif int_or_fut is not _UNDEF: in_progress[domain] = cast(asyncio.Future[None], int_or_fut) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 78b14aaa590..b09296888fe 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.8.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==1.92.0 +dbus-fast==1.93.0 fnv-hash-fast==0.4.0 ha-av==10.1.1 hass-nabucasa==0.69.0 @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.80.0 +zeroconf==0.82.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index 4caf074b879..ce1105cff75 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -21,9 +21,7 @@ _P = ParamSpec("_P") def cancelling(task: Future[Any]) -> bool: - """Return True if task is done or cancelling.""" - # https://docs.python.org/3/library/asyncio-task.html#asyncio.Task.cancelling - # is new in Python 3.11 + """Return True if task is cancelling.""" return bool((cancelling_ := getattr(task, "cancelling", None)) and cancelling_()) diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 6ccb7f14ea2..d9f2a4b96ff 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -180,8 +180,8 @@ COLORS = { class XYPoint: """Represents a CIE 1931 XY coordinate pair.""" - x: float = attr.ib() # pylint: disable=invalid-name - y: float = attr.ib() # pylint: disable=invalid-name + x: float = attr.ib() + y: float = attr.ib() @attr.s() @@ -205,9 +205,6 @@ def color_name_to_rgb(color_name: str) -> RGBColor: return hex_value -# pylint: disable=invalid-name - - def color_RGB_to_xy( iR: int, iG: int, iB: int, Gamut: GamutType | None = None ) -> tuple[float, float]: diff --git a/homeassistant/util/distance.py b/homeassistant/util/distance.py index 509760fff19..45b105aea9f 100644 --- a/homeassistant/util/distance.py +++ b/homeassistant/util/distance.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable -# pylint: disable-next=unused-import,hass-deprecated-import +# pylint: disable-next=hass-deprecated-import from homeassistant.const import ( # noqa: F401 LENGTH, LENGTH_CENTIMETERS, diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 4f49ec44ca7..34a81728d14 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -14,8 +14,8 @@ import zoneinfo import ciso8601 DATE_STR_FORMAT = "%Y-%m-%d" -UTC = dt.timezone.utc -DEFAULT_TIME_ZONE: dt.tzinfo = dt.timezone.utc +UTC = dt.UTC +DEFAULT_TIME_ZONE: dt.tzinfo = dt.UTC CLOCK_MONOTONIC_COARSE = 6 # EPOCHORDINAL is not exposed as a constant diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 60aa920ed6a..7f81c281340 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -11,7 +11,7 @@ import orjson from homeassistant.exceptions import HomeAssistantError -from .file import WriteError # pylint: disable=unused-import # noqa: F401 +from .file import WriteError # noqa: F401 _SENTINEL = object() _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index a251aec268e..44fcaa07067 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -89,7 +89,6 @@ def vincenty( if point1[0] == point2[0] and point1[1] == point2[1]: return 0.0 - # pylint: disable=invalid-name U1 = math.atan((1 - FLATTENING) * math.tan(math.radians(point1[0]))) U2 = math.atan((1 - FLATTENING) * math.tan(math.radians(point2[0]))) L = math.radians(point2[1] - point1[1]) diff --git a/homeassistant/util/pressure.py b/homeassistant/util/pressure.py index 78a69e15a34..9c5082e95ed 100644 --- a/homeassistant/util/pressure.py +++ b/homeassistant/util/pressure.py @@ -1,7 +1,7 @@ """Pressure util functions.""" from __future__ import annotations -# pylint: disable-next=unused-import,hass-deprecated-import +# pylint: disable-next=hass-deprecated-import from homeassistant.const import ( # noqa: F401 PRESSURE, PRESSURE_BAR, diff --git a/homeassistant/util/speed.py b/homeassistant/util/speed.py index a1b6e0a7227..80a3609ab4d 100644 --- a/homeassistant/util/speed.py +++ b/homeassistant/util/speed.py @@ -1,7 +1,7 @@ """Distance util functions.""" from __future__ import annotations -# pylint: disable-next=unused-import,hass-deprecated-import +# pylint: disable-next=hass-deprecated-import from homeassistant.const import ( # noqa: F401 SPEED, SPEED_FEET_PER_SECOND, @@ -16,7 +16,7 @@ from homeassistant.const import ( # noqa: F401 ) from homeassistant.helpers.frame import report -from .unit_conversion import ( # pylint: disable=unused-import # noqa: F401 +from .unit_conversion import ( # noqa: F401 _FOOT_TO_M as FOOT_TO_M, _HRS_TO_SECS as HRS_TO_SECS, _IN_TO_M as IN_TO_M, diff --git a/homeassistant/util/temperature.py b/homeassistant/util/temperature.py index 409fecd1090..74d56e84d94 100644 --- a/homeassistant/util/temperature.py +++ b/homeassistant/util/temperature.py @@ -1,5 +1,5 @@ """Temperature util functions.""" -# pylint: disable-next=unused-import,hass-deprecated-import +# pylint: disable-next=hass-deprecated-import from homeassistant.const import ( # noqa: F401 TEMP_CELSIUS, TEMP_FAHRENHEIT, diff --git a/homeassistant/util/timeout.py b/homeassistant/util/timeout.py index 6c1de55748f..e2e969d46d2 100644 --- a/homeassistant/util/timeout.py +++ b/homeassistant/util/timeout.py @@ -49,7 +49,7 @@ class _GlobalFreezeContext: self._loop.call_soon_threadsafe(self._enter) return self - def __exit__( # pylint: disable=useless-return + def __exit__( self, exc_type: type[BaseException], exc_val: BaseException, @@ -117,7 +117,7 @@ class _ZoneFreezeContext: self._loop.call_soon_threadsafe(self._enter) return self - def __exit__( # pylint: disable=useless-return + def __exit__( self, exc_type: type[BaseException], exc_val: BaseException, diff --git a/homeassistant/util/volume.py b/homeassistant/util/volume.py index 7d70d23c00c..8aae8ff104e 100644 --- a/homeassistant/util/volume.py +++ b/homeassistant/util/volume.py @@ -1,7 +1,7 @@ """Volume conversion util functions.""" from __future__ import annotations -# pylint: disable-next=unused-import,hass-deprecated-import +# pylint: disable-next=hass-deprecated-import from homeassistant.const import ( # noqa: F401 UNIT_NOT_RECOGNIZED_TEMPLATE, VOLUME, diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index b5840a79e8d..2e31b212f1f 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -28,7 +28,7 @@ from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass # mypy: allow-untyped-calls, no-warn-return-any -JSON_TYPE = list | dict | str # pylint: disable=invalid-name +JSON_TYPE = list | dict | str _DictT = TypeVar("_DictT", bound=dict) _LOGGER = logging.getLogger(__name__) diff --git a/mypy.ini b/mypy.ini index 1c47ad019a2..883a5ec2f26 100644 --- a/mypy.ini +++ b/mypy.ini @@ -802,6 +802,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.doorbird.*] +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.dormakaba_dkey.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1702,6 +1712,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.lawn_mower.*] +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.lcn.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index fcc47ed2c31..2ae9c96734c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,18 +120,6 @@ fail-on = [ [tool.pylint.BASIC] class-const-naming-style = "any" -good-names = [ - "_", - "ev", - "ex", - "fp", - "i", - "id", - "j", - "k", - "Run", - "ip", -] [tool.pylint."MESSAGES CONTROL"] # Reasons disabled: @@ -481,9 +469,6 @@ filterwarnings = [ "ignore:ssl.PROTOCOL_TLSv1_2 is deprecated:DeprecationWarning:pylutron_caseta.smartbridge", # https://github.com/Danielhiversen/pyMillLocal/pull/8 - >=0.3.0 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:mill_local", - # https://github.com/home-assistant/core/pull/98619 - update botocore to >=1.31.17 - "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:botocore.utils", - "ignore:'urllib3.contrib.pyopenssl' module is deprecated and will be removed in a future release of urllib3 2.x:DeprecationWarning:botocore.httpsession", # -- not helpful # pyatmo.__init__ imports deprecated moduls from itself - v7.5.0 @@ -521,8 +506,6 @@ filterwarnings = [ ] [tool.ruff] -target-version = "py310" - select = [ "B002", # Python does not support the unary prefix increment "B007", # Loop control variable {name} not used within loop body diff --git a/requirements_all.txt b/requirements_all.txt index 2aa174fe090..1c2c36f937a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2,13 +2,13 @@ -r requirements.txt # homeassistant.components.aemet -AEMET-OpenData==0.2.2 +AEMET-OpenData==0.3.0 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.57 # homeassistant.components.honeywell -AIOSomecomfort==0.0.15 +AIOSomecomfort==0.0.16 # homeassistant.components.adax Adax-local==0.1.5 @@ -188,7 +188,7 @@ aioairq==0.2.4 aioairzone-cloud==0.2.1 # homeassistant.components.airzone -aioairzone==0.6.6 +aioairzone==0.6.7 # homeassistant.components.ambient_station aioambient==2023.04.0 @@ -206,7 +206,7 @@ aioazuredevops==1.3.5 aiobafi6==0.8.2 # homeassistant.components.aws -aiobotocore==2.1.0 +aiobotocore==2.6.0 # homeassistant.components.comelit aiocomelit==0.0.5 @@ -324,7 +324,7 @@ aiopvpc==4.2.2 aiopyarr==23.4.0 # homeassistant.components.qnap_qsw -aioqsw==0.3.2 +aioqsw==0.3.3 # homeassistant.components.recollect_waste aiorecollect==1.0.8 @@ -339,7 +339,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==5.4.0 +aioshelly==6.0.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -402,7 +402,7 @@ amcrest==1.9.7 androidtv[async]==0.0.70 # homeassistant.components.androidtv_remote -androidtvremote2==0.0.13 +androidtvremote2==0.0.14 # homeassistant.components.anel_pwrctrl anel-pwrctrl-homeassistant==0.0.1.dev2 @@ -553,7 +553,7 @@ boschshcpy==0.2.57 # homeassistant.components.amazon_polly # homeassistant.components.route53 -boto3==1.20.24 +boto3==1.28.17 # homeassistant.components.broadlink broadlink==0.18.3 @@ -571,7 +571,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.0.0 +bthome-ble==3.1.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 @@ -635,7 +635,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.92.0 +dbus-fast==1.93.0 # homeassistant.components.debugpy debugpy==1.6.7 @@ -829,7 +829,7 @@ fritzconnection[qr]==1.12.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena_bluetooth==1.0.2 +gardena_bluetooth==1.3.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 @@ -1317,7 +1317,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==5.3.0 +odp-amsterdam==5.3.1 # homeassistant.components.oem oemthermostat==1.1.1 @@ -1668,7 +1668,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.6.0 +pyenphase==1.8.1 # homeassistant.components.envisalink pyenvisalink==4.6 @@ -1752,7 +1752,7 @@ pyintesishome==1.8.0 pyipma==3.0.6 # homeassistant.components.ipp -pyipp==0.14.3 +pyipp==0.14.4 # homeassistant.components.iqvia pyiqvia==2022.04.0 @@ -1815,7 +1815,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.4 +pylitterbot==2023.4.5 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.1 @@ -1890,7 +1890,7 @@ pynzbgetapi==0.2.0 pyobihai==1.4.2 # homeassistant.components.octoprint -pyoctoprintapi==0.1.11 +pyoctoprintapi==0.1.12 # homeassistant.components.ombi pyombi==0.1.10 @@ -2041,7 +2041,7 @@ pysnooz==0.8.3 pysoma==0.0.12 # homeassistant.components.spc -pyspcwebgw==0.4.0 +pyspcwebgw==0.7.0 # homeassistant.components.squeezebox pysqueezebox==0.6.3 @@ -2731,7 +2731,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.2.3 # homeassistant.components.august -yalexs==1.5.2 +yalexs==1.8.0 # homeassistant.components.yeelight yeelight==0.7.13 @@ -2758,7 +2758,7 @@ zamg==0.2.4 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.80.0 +zeroconf==0.82.1 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 61032146fbb..1d9c0e539bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,13 +4,13 @@ -r requirements_test.txt # homeassistant.components.aemet -AEMET-OpenData==0.2.2 +AEMET-OpenData==0.3.0 # homeassistant.components.aladdin_connect AIOAladdinConnect==0.1.57 # homeassistant.components.honeywell -AIOSomecomfort==0.0.15 +AIOSomecomfort==0.0.16 # homeassistant.components.adax Adax-local==0.1.5 @@ -169,7 +169,7 @@ aioairq==0.2.4 aioairzone-cloud==0.2.1 # homeassistant.components.airzone -aioairzone==0.6.6 +aioairzone==0.6.7 # homeassistant.components.ambient_station aioambient==2023.04.0 @@ -187,7 +187,7 @@ aioazuredevops==1.3.5 aiobafi6==0.8.2 # homeassistant.components.aws -aiobotocore==2.1.0 +aiobotocore==2.6.0 # homeassistant.components.comelit aiocomelit==0.0.5 @@ -299,7 +299,7 @@ aiopvpc==4.2.2 aiopyarr==23.4.0 # homeassistant.components.qnap_qsw -aioqsw==0.3.2 +aioqsw==0.3.3 # homeassistant.components.recollect_waste aiorecollect==1.0.8 @@ -314,7 +314,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==5.4.0 +aioshelly==6.0.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -368,7 +368,7 @@ amberelectric==1.0.4 androidtv[async]==0.0.70 # homeassistant.components.androidtv_remote -androidtvremote2==0.0.13 +androidtvremote2==0.0.14 # homeassistant.components.anova anova-wifi==0.10.0 @@ -475,7 +475,7 @@ brottsplatskartan==0.0.1 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.0.0 +bthome-ble==3.1.0 # homeassistant.components.buienradar buienradar==1.0.5 @@ -515,7 +515,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.92.0 +dbus-fast==1.93.0 # homeassistant.components.debugpy debugpy==1.6.7 @@ -648,7 +648,7 @@ fritzconnection[qr]==1.12.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena_bluetooth==1.0.2 +gardena_bluetooth==1.3.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 @@ -1007,7 +1007,7 @@ oauth2client==4.1.3 objgraph==3.5.0 # homeassistant.components.garages_amsterdam -odp-amsterdam==5.3.0 +odp-amsterdam==5.3.1 # homeassistant.components.omnilogic omnilogic==0.4.5 @@ -1232,7 +1232,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.6.0 +pyenphase==1.8.1 # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1295,7 +1295,7 @@ pyinsteon==1.4.3 pyipma==3.0.6 # homeassistant.components.ipp -pyipp==0.14.3 +pyipp==0.14.4 # homeassistant.components.iqvia pyiqvia==2022.04.0 @@ -1343,7 +1343,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.4 +pylitterbot==2023.4.5 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.1 @@ -1403,7 +1403,7 @@ pynzbgetapi==0.2.0 pyobihai==1.4.2 # homeassistant.components.octoprint -pyoctoprintapi==0.1.11 +pyoctoprintapi==0.1.12 # homeassistant.components.openuv pyopenuv==2023.02.0 @@ -1524,7 +1524,7 @@ pysnooz==0.8.3 pysoma==0.0.12 # homeassistant.components.spc -pyspcwebgw==0.4.0 +pyspcwebgw==0.7.0 # homeassistant.components.squeezebox pysqueezebox==0.6.3 @@ -2013,7 +2013,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.2.3 # homeassistant.components.august -yalexs==1.5.2 +yalexs==1.8.0 # homeassistant.components.yeelight yeelight==0.7.13 @@ -2031,7 +2031,7 @@ youtubeaio==1.1.5 zamg==0.2.4 # homeassistant.components.zeroconf -zeroconf==0.80.0 +zeroconf==0.82.1 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 5a683660efe..101a57e419d 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -9,9 +9,8 @@ from pathlib import Path import pkgutil import re import sys -from typing import Any - import tomllib +from typing import Any from homeassistant.util.yaml.loader import load_yaml from script.hassfest.model import Integration diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 4a15acb2d1d..65e37aa515d 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -254,12 +254,8 @@ INTEGRATION_MANIFEST_SCHEMA = vol.Schema( } ) ], - vol.Required("documentation"): vol.All( - vol.Url(), documentation_url # pylint: disable=no-value-for-parameter - ), - vol.Optional( - "issue_tracker" - ): vol.Url(), # pylint: disable=no-value-for-parameter + vol.Required("documentation"): vol.All(vol.Url(), documentation_url), + vol.Optional("issue_tracker"): vol.Url(), vol.Optional("quality_scale"): vol.In(SUPPORTED_QUALITY_SCALES), vol.Optional("requirements"): [str], vol.Optional("dependencies"): [str], diff --git a/script/hassfest/services.py b/script/hassfest/services.py index b3f59ab66a3..4a826f7cad9 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -25,10 +25,8 @@ def exists(value: Any) -> Any: return value -FIELD_SCHEMA = vol.Schema( +CORE_INTEGRATION_FIELD_SCHEMA = vol.Schema( { - vol.Optional("description"): str, - vol.Optional("name"): str, vol.Optional("example"): exists, vol.Optional("default"): exists, vol.Optional("values"): exists, @@ -46,7 +44,26 @@ FIELD_SCHEMA = vol.Schema( } ) -SERVICE_SCHEMA = vol.Any( +CUSTOM_INTEGRATION_FIELD_SCHEMA = CORE_INTEGRATION_FIELD_SCHEMA.extend( + { + vol.Optional("description"): str, + vol.Optional("name"): str, + } +) + +CORE_INTEGRATION_SERVICE_SCHEMA = vol.Any( + vol.Schema( + { + vol.Optional("target"): vol.Any( + selector.TargetSelector.CONFIG_SCHEMA, None + ), + vol.Optional("fields"): vol.Schema({str: CORE_INTEGRATION_FIELD_SCHEMA}), + } + ), + None, +) + +CUSTOM_INTEGRATION_SERVICE_SCHEMA = vol.Any( vol.Schema( { vol.Optional("description"): str, @@ -54,13 +71,23 @@ SERVICE_SCHEMA = vol.Any( vol.Optional("target"): vol.Any( selector.TargetSelector.CONFIG_SCHEMA, None ), - vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), + vol.Optional("fields"): vol.Schema({str: CUSTOM_INTEGRATION_FIELD_SCHEMA}), } ), None, ) -SERVICES_SCHEMA = vol.Schema({cv.slug: SERVICE_SCHEMA}) +CORE_INTEGRATION_SERVICES_SCHEMA = vol.Schema( + {cv.slug: CORE_INTEGRATION_SERVICE_SCHEMA} +) +CUSTOM_INTEGRATION_SERVICES_SCHEMA = vol.Schema( + {cv.slug: CUSTOM_INTEGRATION_SERVICE_SCHEMA} +) + +VALIDATE_AS_CUSTOM_INTEGRATION = { + # Adding translations would be a breaking change + "foursquare", +} def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool: @@ -99,7 +126,13 @@ def validate_services(config: Config, integration: Integration) -> None: return try: - services = SERVICES_SCHEMA(data) + if ( + integration.core + and integration.domain not in VALIDATE_AS_CUSTOM_INTEGRATION + ): + services = CORE_INTEGRATION_SERVICES_SCHEMA(data) + else: + services = CUSTOM_INTEGRATION_SERVICES_SCHEMA(data) except vol.Invalid as err: integration.add_error( "services", f"Invalid services.yaml: {humanize_error(data, err)}" @@ -118,6 +151,10 @@ def validate_services(config: Config, integration: Integration) -> None: with contextlib.suppress(ValueError): strings = json.loads(strings_file.read_text()) + error_msg_suffix = "in the translations file" + if not integration.core: + error_msg_suffix = f"and is not {error_msg_suffix}" + # For each service in the integration, check if the description if set, # if not, check if it's in the strings file. If not, add an error. for service_name, service_schema in services.items(): @@ -129,7 +166,7 @@ def validate_services(config: Config, integration: Integration) -> None: except KeyError: integration.add_error( "services", - f"Service {service_name} has no name and is not in the translations file", + f"Service {service_name} has no name {error_msg_suffix}", ) if "description" not in service_schema: @@ -138,12 +175,21 @@ def validate_services(config: Config, integration: Integration) -> None: except KeyError: integration.add_error( "services", - f"Service {service_name} has no description and is not in the translations file", + f"Service {service_name} has no description {error_msg_suffix}", ) # The same check is done for the description in each of the fields of the # service schema. for field_name, field_schema in service_schema.get("fields", {}).items(): + if "name" not in field_schema: + try: + strings["services"][service_name]["fields"][field_name]["name"] + except KeyError: + integration.add_error( + "services", + f"Service {service_name} has a field {field_name} with no name {error_msg_suffix}", + ) + if "description" not in field_schema: try: strings["services"][service_name]["fields"][field_name][ @@ -152,7 +198,7 @@ def validate_services(config: Config, integration: Integration) -> None: except KeyError: integration.add_error( "services", - f"Service {service_name} has a field {field_name} with no description and is not in the translations file", + f"Service {service_name} has a field {field_name} with no description {error_msg_suffix}", ) if "selector" in field_schema: diff --git a/script/lint_and_test.py b/script/lint_and_test.py index 5a3d448c1f4..27963758415 100755 --- a/script/lint_and_test.py +++ b/script/lint_and_test.py @@ -40,7 +40,7 @@ def printc(the_color, *args): def validate_requirements_ok(): """Validate requirements, returns True of ok.""" - # pylint: disable-next=import-error,import-outside-toplevel + # pylint: disable-next=import-outside-toplevel from gen_requirements_all import main as req_main return req_main(True) == 0 diff --git a/tests/common.py b/tests/common.py index 6f2209276ce..0b63a9a2ef6 100644 --- a/tests/common.py +++ b/tests/common.py @@ -5,7 +5,7 @@ import asyncio from collections import OrderedDict from collections.abc import Generator, Mapping, Sequence from contextlib import contextmanager -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import functools as ft from functools import lru_cache from io import StringIO @@ -384,7 +384,7 @@ def async_fire_time_changed_exact( approach, as this is only for testing. """ if datetime_ is None: - utc_datetime = datetime.now(timezone.utc) + utc_datetime = datetime.now(UTC) else: utc_datetime = dt_util.as_utc(datetime_) @@ -406,7 +406,7 @@ def async_fire_time_changed( for an exact microsecond, use async_fire_time_changed_exact. """ if datetime_ is None: - utc_datetime = datetime.now(timezone.utc) + utc_datetime = datetime.now(UTC) else: utc_datetime = dt_util.as_utc(datetime_) @@ -681,7 +681,6 @@ def ensure_auth_manager_loaded(auth_mgr): class MockModule: """Representation of a fake module.""" - # pylint: disable=invalid-name def __init__( self, domain=None, @@ -756,7 +755,6 @@ class MockPlatform: __name__ = "homeassistant.components.light.bla" __file__ = "homeassistant/components/blah/light" - # pylint: disable=invalid-name def __init__( self, setup_platform=None, diff --git a/tests/components/accuweather/snapshots/test_diagnostics.ambr b/tests/components/accuweather/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..b3c0c1de752 --- /dev/null +++ b/tests/components/accuweather/snapshots/test_diagnostics.ambr @@ -0,0 +1,304 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry_data': dict({ + 'api_key': '**REDACTED**', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'Home', + }), + 'coordinator_data': dict({ + 'ApparentTemperature': dict({ + 'Imperial': dict({ + 'Unit': 'F', + 'UnitType': 18, + 'Value': 73.0, + }), + 'Metric': dict({ + 'Unit': 'C', + 'UnitType': 17, + 'Value': 22.8, + }), + }), + 'Ceiling': dict({ + 'Imperial': dict({ + 'Unit': 'ft', + 'UnitType': 0, + 'Value': 10500.0, + }), + 'Metric': dict({ + 'Unit': 'm', + 'UnitType': 5, + 'Value': 3200.0, + }), + }), + 'CloudCover': 10, + 'DewPoint': dict({ + 'Imperial': dict({ + 'Unit': 'F', + 'UnitType': 18, + 'Value': 61.0, + }), + 'Metric': dict({ + 'Unit': 'C', + 'UnitType': 17, + 'Value': 16.2, + }), + }), + 'HasPrecipitation': False, + 'IndoorRelativeHumidity': 67, + 'ObstructionsToVisibility': '', + 'Past24HourTemperatureDeparture': dict({ + 'Imperial': dict({ + 'Unit': 'F', + 'UnitType': 18, + 'Value': 0.0, + }), + 'Metric': dict({ + 'Unit': 'C', + 'UnitType': 17, + 'Value': 0.3, + }), + }), + 'Precip1hr': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.0, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 0.0, + }), + }), + 'PrecipitationSummary': dict({ + 'Past12Hours': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.15, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 3.8, + }), + }), + 'Past18Hours': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.2, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 5.1, + }), + }), + 'Past24Hours': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.3, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 7.6, + }), + }), + 'Past3Hours': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.05, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 1.3, + }), + }), + 'Past6Hours': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.05, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 1.3, + }), + }), + 'Past9Hours': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.1, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 2.5, + }), + }), + 'PastHour': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.0, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 0.0, + }), + }), + 'Precipitation': dict({ + 'Imperial': dict({ + 'Unit': 'in', + 'UnitType': 1, + 'Value': 0.0, + }), + 'Metric': dict({ + 'Unit': 'mm', + 'UnitType': 3, + 'Value': 0.0, + }), + }), + }), + 'PrecipitationType': None, + 'Pressure': dict({ + 'Imperial': dict({ + 'Unit': 'inHg', + 'UnitType': 12, + 'Value': 29.88, + }), + 'Metric': dict({ + 'Unit': 'mb', + 'UnitType': 14, + 'Value': 1012.0, + }), + }), + 'PressureTendency': dict({ + 'Code': 'F', + 'LocalizedText': 'Falling', + }), + 'RealFeelTemperature': dict({ + 'Imperial': dict({ + 'Unit': 'F', + 'UnitType': 18, + 'Value': 77.0, + }), + 'Metric': dict({ + 'Unit': 'C', + 'UnitType': 17, + 'Value': 25.1, + }), + }), + 'RealFeelTemperatureShade': dict({ + 'Imperial': dict({ + 'Unit': 'F', + 'UnitType': 18, + 'Value': 70.0, + }), + 'Metric': dict({ + 'Unit': 'C', + 'UnitType': 17, + 'Value': 21.1, + }), + }), + 'RelativeHumidity': 67, + 'Temperature': dict({ + 'Imperial': dict({ + 'Unit': 'F', + 'UnitType': 18, + 'Value': 73.0, + }), + 'Metric': dict({ + 'Unit': 'C', + 'UnitType': 17, + 'Value': 22.6, + }), + }), + 'UVIndex': 6, + 'UVIndexText': 'High', + 'Visibility': dict({ + 'Imperial': dict({ + 'Unit': 'mi', + 'UnitType': 2, + 'Value': 10.0, + }), + 'Metric': dict({ + 'Unit': 'km', + 'UnitType': 6, + 'Value': 16.1, + }), + }), + 'WeatherIcon': 1, + 'WeatherText': 'Sunny', + 'WetBulbTemperature': dict({ + 'Imperial': dict({ + 'Unit': 'F', + 'UnitType': 18, + 'Value': 65.0, + }), + 'Metric': dict({ + 'Unit': 'C', + 'UnitType': 17, + 'Value': 18.6, + }), + }), + 'Wind': dict({ + 'Direction': dict({ + 'Degrees': 180, + 'English': 'S', + 'Localized': 'S', + }), + 'Speed': dict({ + 'Imperial': dict({ + 'Unit': 'mi/h', + 'UnitType': 9, + 'Value': 9.0, + }), + 'Metric': dict({ + 'Unit': 'km/h', + 'UnitType': 7, + 'Value': 14.5, + }), + }), + }), + 'WindChillTemperature': dict({ + 'Imperial': dict({ + 'Unit': 'F', + 'UnitType': 18, + 'Value': 73.0, + }), + 'Metric': dict({ + 'Unit': 'C', + 'UnitType': 17, + 'Value': 22.8, + }), + }), + 'WindGust': dict({ + 'Speed': dict({ + 'Imperial': dict({ + 'Unit': 'mi/h', + 'UnitType': 9, + 'Value': 12.6, + }), + 'Metric': dict({ + 'Unit': 'km/h', + 'UnitType': 7, + 'Value': 20.3, + }), + }), + }), + 'forecast': list([ + ]), + }), + }) +# --- diff --git a/tests/components/accuweather/test_diagnostics.py b/tests/components/accuweather/test_diagnostics.py index 98be70d9ec6..7c13f318cc3 100644 --- a/tests/components/accuweather/test_diagnostics.py +++ b/tests/components/accuweather/test_diagnostics.py @@ -1,4 +1,5 @@ """Test AccuWeather diagnostics.""" +from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -10,7 +11,9 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" entry = await init_integration(hass) @@ -23,10 +26,4 @@ async def test_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert result["config_entry_data"] == { - "api_key": "**REDACTED**", - "latitude": "**REDACTED**", - "longitude": "**REDACTED**", - "name": "Home", - } - assert result["coordinator_data"] == coordinator_data + assert result == snapshot diff --git a/tests/components/aemet/fixtures/station-3195.json b/tests/components/aemet/fixtures/station-3195.json deleted file mode 100644 index cfd8c59a7ee..00000000000 --- a/tests/components/aemet/fixtures/station-3195.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "descripcion": "exito", - "estado": 200, - "datos": "https://opendata.aemet.es/opendata/sh/208c3ca3", - "metadatos": "https://opendata.aemet.es/opendata/sh/55c2971b" -} diff --git a/tests/components/aemet/fixtures/station-list.json b/tests/components/aemet/fixtures/station-list.json deleted file mode 100644 index 86f79727e7f..00000000000 --- a/tests/components/aemet/fixtures/station-list.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "descripcion": "exito", - "estado": 200, - "datos": "https://opendata.aemet.es/opendata/sh/2c55192f", - "metadatos": "https://opendata.aemet.es/opendata/sh/55c2971b" -} diff --git a/tests/components/aemet/fixtures/town-28065-forecast-daily.json b/tests/components/aemet/fixtures/town-28065-forecast-daily.json deleted file mode 100644 index 41103c1033f..00000000000 --- a/tests/components/aemet/fixtures/town-28065-forecast-daily.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "descripcion": "exito", - "estado": 200, - "datos": "https://opendata.aemet.es/opendata/sh/64e29abb", - "metadatos": "https://opendata.aemet.es/opendata/sh/dfd88b22" -} diff --git a/tests/components/aemet/fixtures/town-28065-forecast-hourly.json b/tests/components/aemet/fixtures/town-28065-forecast-hourly.json deleted file mode 100644 index cdcacfcb6a5..00000000000 --- a/tests/components/aemet/fixtures/town-28065-forecast-hourly.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "descripcion": "exito", - "estado": 200, - "datos": "https://opendata.aemet.es/opendata/sh/18ca1886", - "metadatos": "https://opendata.aemet.es/opendata/sh/93a7c63d" -} diff --git a/tests/components/aemet/test_config_flow.py b/tests/components/aemet/test_config_flow.py index 59a6993903f..b311cfd4a54 100644 --- a/tests/components/aemet/test_config_flow.py +++ b/tests/components/aemet/test_config_flow.py @@ -1,8 +1,8 @@ """Define tests for the AEMET OpenData config flow.""" from unittest.mock import AsyncMock, MagicMock, patch +from aemet_opendata.exceptions import AuthError import pytest -import requests_mock from homeassistant import data_entry_flow from homeassistant.components.aemet.const import CONF_STATION_UPDATES, DOMAIN @@ -11,7 +11,7 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CON from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from .util import aemet_requests_mock +from .util import mock_api_call from tests.common import MockConfigEntry @@ -28,9 +28,10 @@ CONFIG = { async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test that the form is served with valid input.""" - with requests_mock.mock() as _m: - aemet_requests_mock(_m) - + with patch( + "homeassistant.components.aemet.AEMET.api_call", + side_effect=mock_api_call, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -64,9 +65,10 @@ async def test_form_options(hass: HomeAssistant) -> None: now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") with patch("homeassistant.util.dt.now", return_value=now), patch( "homeassistant.util.dt.utcnow", return_value=now - ), requests_mock.mock() as _m: - aemet_requests_mock(_m) - + ), patch( + "homeassistant.components.aemet.AEMET.api_call", + side_effect=mock_api_call, + ): entry = MockConfigEntry( domain=DOMAIN, unique_id="40.30403754--3.72935236", data=CONFIG ) @@ -120,9 +122,10 @@ async def test_form_duplicated_id(hass: HomeAssistant) -> None: now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") with patch("homeassistant.util.dt.now", return_value=now), patch( "homeassistant.util.dt.utcnow", return_value=now - ), requests_mock.mock() as _m: - aemet_requests_mock(_m) - + ), patch( + "homeassistant.components.aemet.AEMET.api_call", + side_effect=mock_api_call, + ): entry = MockConfigEntry( domain=DOMAIN, unique_id="40.30403754--3.72935236", data=CONFIG ) @@ -136,11 +139,10 @@ async def test_form_duplicated_id(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_form_api_offline(hass: HomeAssistant) -> None: - """Test setting up with api call error.""" +async def test_form_auth_error(hass: HomeAssistant) -> None: + """Test setting up with api auth error.""" mocked_aemet = MagicMock() - - mocked_aemet.get_conventional_observation_stations.return_value = None + mocked_aemet.get_conventional_observation_stations.side_effect = AuthError with patch( "homeassistant.components.aemet.config_flow.AEMET", diff --git a/tests/components/aemet/test_init.py b/tests/components/aemet/test_init.py index 9db0ffb2bcf..24c16ba3ef3 100644 --- a/tests/components/aemet/test_init.py +++ b/tests/components/aemet/test_init.py @@ -1,15 +1,13 @@ """Define tests for the AEMET OpenData init.""" from unittest.mock import patch -import requests_mock - from homeassistant.components.aemet.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from .util import aemet_requests_mock +from .util import mock_api_call from tests.common import MockConfigEntry @@ -27,9 +25,10 @@ async def test_unload_entry(hass: HomeAssistant) -> None: now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") with patch("homeassistant.util.dt.now", return_value=now), patch( "homeassistant.util.dt.utcnow", return_value=now - ), requests_mock.mock() as _m: - aemet_requests_mock(_m) - + ), patch( + "homeassistant.components.aemet.AEMET.api_call", + side_effect=mock_api_call, + ): config_entry = MockConfigEntry( domain=DOMAIN, unique_id="aemet_unique_id", data=CONFIG ) diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index c64e824e18d..703ef4348f8 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -4,7 +4,6 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest -import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.components.aemet.const import ATTRIBUTION, DOMAIN @@ -36,7 +35,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util -from .util import aemet_requests_mock, async_init_integration +from .util import async_init_integration, mock_api_call from tests.typing import WebSocketGenerator @@ -191,8 +190,10 @@ async def test_forecast_subscription( assert forecast1 == snapshot - with requests_mock.mock() as _m: - aemet_requests_mock(_m) + with patch( + "homeassistant.components.aemet.AEMET.api_call", + side_effect=mock_api_call, + ): freezer.tick(WEATHER_UPDATE_INTERVAL + datetime.timedelta(seconds=1)) await hass.async_block_till_done() msg = await client.receive_json() diff --git a/tests/components/aemet/util.py b/tests/components/aemet/util.py index 991e7459bf6..05417563e2f 100644 --- a/tests/components/aemet/util.py +++ b/tests/components/aemet/util.py @@ -1,93 +1,74 @@ """Tests for the AEMET OpenData integration.""" +from typing import Any +from unittest.mock import patch -import requests_mock +from aemet_opendata.const import ATTR_DATA from homeassistant.components.aemet import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_value_fixture + +FORECAST_DAILY_DATA_MOCK = { + ATTR_DATA: load_json_value_fixture("aemet/town-28065-forecast-daily-data.json"), +} + +FORECAST_HOURLY_DATA_MOCK = { + ATTR_DATA: load_json_value_fixture("aemet/town-28065-forecast-hourly-data.json"), +} + +STATION_DATA_MOCK = { + ATTR_DATA: load_json_value_fixture("aemet/station-3195-data.json"), +} + +STATIONS_DATA_MOCK = { + ATTR_DATA: load_json_value_fixture("aemet/station-list-data.json"), +} + +TOWN_DATA_MOCK = { + ATTR_DATA: load_json_value_fixture("aemet/town-id28065.json"), +} + +TOWNS_DATA_MOCK = { + ATTR_DATA: load_json_value_fixture("aemet/town-list.json"), +} -def aemet_requests_mock(mock): - """Mock requests performed to AEMET OpenData API.""" - - station_3195_fixture = "aemet/station-3195.json" - station_3195_data_fixture = "aemet/station-3195-data.json" - station_list_fixture = "aemet/station-list.json" - station_list_data_fixture = "aemet/station-list-data.json" - - town_28065_forecast_daily_fixture = "aemet/town-28065-forecast-daily.json" - town_28065_forecast_daily_data_fixture = "aemet/town-28065-forecast-daily-data.json" - town_28065_forecast_hourly_fixture = "aemet/town-28065-forecast-hourly.json" - town_28065_forecast_hourly_data_fixture = ( - "aemet/town-28065-forecast-hourly-data.json" - ) - town_id28065_fixture = "aemet/town-id28065.json" - town_list_fixture = "aemet/town-list.json" - - mock.get( - "https://opendata.aemet.es/opendata/api/observacion/convencional/datos/estacion/3195", - text=load_fixture(station_3195_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/sh/208c3ca3", - text=load_fixture(station_3195_data_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/api/observacion/convencional/todas", - text=load_fixture(station_list_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/sh/2c55192f", - text=load_fixture(station_list_data_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/api/prediccion/especifica/municipio/diaria/28065", - text=load_fixture(town_28065_forecast_daily_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/sh/64e29abb", - text=load_fixture(town_28065_forecast_daily_data_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/api/prediccion/especifica/municipio/horaria/28065", - text=load_fixture(town_28065_forecast_hourly_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/sh/18ca1886", - text=load_fixture(town_28065_forecast_hourly_data_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/api/maestro/municipio/id28065", - text=load_fixture(town_id28065_fixture), - ) - mock.get( - "https://opendata.aemet.es/opendata/api/maestro/municipios", - text=load_fixture(town_list_fixture), - ) +def mock_api_call(cmd: str, fetch_data: bool = False) -> dict[str, Any]: + """Mock AEMET OpenData API calls.""" + if cmd == "maestro/municipio/id28065": + return TOWN_DATA_MOCK + if cmd == "maestro/municipios": + return TOWNS_DATA_MOCK + if cmd == "observacion/convencional/datos/estacion/3195": + return STATION_DATA_MOCK + if cmd == "observacion/convencional/todas": + return STATIONS_DATA_MOCK + if cmd == "prediccion/especifica/municipio/diaria/28065": + return FORECAST_DAILY_DATA_MOCK + if cmd == "prediccion/especifica/municipio/horaria/28065": + return FORECAST_HOURLY_DATA_MOCK + return {} -async def async_init_integration( - hass: HomeAssistant, - skip_setup: bool = False, -): +async def async_init_integration(hass: HomeAssistant): """Set up the AEMET OpenData integration in Home Assistant.""" - with requests_mock.mock() as _m: - aemet_requests_mock(_m) + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "api-key", + CONF_LATITUDE: "40.30403754", + CONF_LONGITUDE: "-3.72935236", + CONF_NAME: "AEMET", + }, + ) + config_entry.add_to_hass(hass) - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_API_KEY: "mock", - CONF_LATITUDE: "40.30403754", - CONF_LONGITUDE: "-3.72935236", - CONF_NAME: "AEMET", - }, - ) - entry.add_to_hass(hass) - - if not skip_setup: - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + with patch( + "homeassistant.components.aemet.AEMET.api_call", + side_effect=mock_api_call, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/airly/__init__.py b/tests/components/airly/__init__.py index 452df6d9c27..ca26dbaf87f 100644 --- a/tests/components/airly/__init__.py +++ b/tests/components/airly/__init__.py @@ -14,6 +14,7 @@ async def init_integration(hass, aioclient_mock) -> MockConfigEntry: entry = MockConfigEntry( domain=DOMAIN, title="Home", + entry_id="3bd2acb0e4f0476d40865546d0d91921", unique_id="123-456", data={ "api_key": "foo", diff --git a/tests/components/airly/fixtures/diagnostics_data.json b/tests/components/airly/fixtures/diagnostics_data.json deleted file mode 100644 index 0f225fd4a20..00000000000 --- a/tests/components/airly/fixtures/diagnostics_data.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "PM1": 2.83, - "PM25": 4.37, - "PM10": 6.06, - "CO": 162.49, - "NO2": 16.04, - "O3": 41.52, - "SO2": 13.97, - "PRESSURE": 1019.86, - "HUMIDITY": 68.35, - "TEMPERATURE": 14.37, - "PM25_LIMIT": 15.0, - "PM25_PERCENT": 29.13, - "PM10_LIMIT": 45.0, - "PM10_PERCENT": 14.5, - "CO_LIMIT": 4000, - "CO_PERCENT": 4.06, - "NO2_LIMIT": 25, - "NO2_PERCENT": 64.17, - "O3_LIMIT": 100, - "O3_PERCENT": 41.52, - "SO2_LIMIT": 40, - "SO2_PERCENT": 34.93, - "CAQI": 7.29, - "LEVEL": "very low", - "DESCRIPTION": "Great air here today!", - "ADVICE": "Catch your breath!" -} diff --git a/tests/components/airly/snapshots/test_diagnostics.ambr b/tests/components/airly/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..a224ea07d46 --- /dev/null +++ b/tests/components/airly/snapshots/test_diagnostics.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'Home', + }), + 'disabled_by': None, + 'domain': 'airly', + 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Home', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + 'coordinator_data': dict({ + 'ADVICE': 'Catch your breath!', + 'CAQI': 7.29, + 'CO': 162.49, + 'CO_LIMIT': 4000, + 'CO_PERCENT': 4.06, + 'DESCRIPTION': 'Great air here today!', + 'HUMIDITY': 68.35, + 'LEVEL': 'very low', + 'NO2': 16.04, + 'NO2_LIMIT': 25, + 'NO2_PERCENT': 64.17, + 'O3': 41.52, + 'O3_LIMIT': 100, + 'O3_PERCENT': 41.52, + 'PM1': 2.83, + 'PM10': 6.06, + 'PM10_LIMIT': 45, + 'PM10_PERCENT': 14.5, + 'PM25': 4.37, + 'PM25_LIMIT': 15, + 'PM25_PERCENT': 29.13, + 'PRESSURE': 1019.86, + 'SO2': 13.97, + 'SO2_LIMIT': 40, + 'SO2_PERCENT': 34.93, + 'TEMPERATURE': 14.37, + }), + }) +# --- diff --git a/tests/components/airly/test_diagnostics.py b/tests/components/airly/test_diagnostics.py index 611f7910ae7..7364824e594 100644 --- a/tests/components/airly/test_diagnostics.py +++ b/tests/components/airly/test_diagnostics.py @@ -1,12 +1,11 @@ """Test Airly diagnostics.""" -import json -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from . import init_integration -from tests.common import load_fixture from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -16,30 +15,11 @@ async def test_entry_diagnostics( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" entry = await init_integration(hass, aioclient_mock) - coordinator_data = json.loads(load_fixture("diagnostics_data.json", "airly")) - result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert result["config_entry"] == { - "entry_id": entry.entry_id, - "version": 1, - "domain": "airly", - "title": "Home", - "data": { - "latitude": REDACTED, - "longitude": REDACTED, - "name": "Home", - "api_key": REDACTED, - }, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - } - assert result["coordinator_data"] == coordinator_data + assert result == snapshot diff --git a/tests/components/airnow/conftest.py b/tests/components/airnow/conftest.py index 47f20ccd883..15298ef3db0 100644 --- a/tests/components/airnow/conftest.py +++ b/tests/components/airnow/conftest.py @@ -16,6 +16,7 @@ def config_entry_fixture(hass, config): """Define a config entry fixture.""" entry = MockConfigEntry( domain=DOMAIN, + entry_id="3bd2acb0e4f0476d40865546d0d91921", unique_id=f"{config[CONF_LATITUDE]}-{config[CONF_LONGITUDE]}", data=config, ) diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..ca333bbff72 --- /dev/null +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -0,0 +1,39 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'AQI': 44, + 'Category.Name': 'Good', + 'Category.Number': 1, + 'DateObserved': '2020-12-20', + 'HourObserved': 15, + 'Latitude': '**REDACTED**', + 'Longitude': '**REDACTED**', + 'O3': 0.048, + 'PM10': 12, + 'PM2.5': 8.9, + 'Pollutant': 'O3', + 'ReportingArea': '**REDACTED**', + 'StateCode': '**REDACTED**', + }), + 'entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'radius': 75, + }), + 'disabled_by': None, + 'domain': 'airnow', + 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/airnow/test_diagnostics.py b/tests/components/airnow/test_diagnostics.py index 38049cfec4b..ecf6acc1c80 100644 --- a/tests/components/airnow/test_diagnostics.py +++ b/tests/components/airnow/test_diagnostics.py @@ -1,5 +1,6 @@ """Test AirNow diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -7,41 +8,14 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( - hass: HomeAssistant, config_entry, hass_client: ClientSessionGenerator, setup_airnow + hass: HomeAssistant, + config_entry, + hass_client: ClientSessionGenerator, + setup_airnow, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "entry_id": config_entry.entry_id, - "version": 1, - "domain": "airnow", - "title": REDACTED, - "data": { - "api_key": REDACTED, - "latitude": REDACTED, - "longitude": REDACTED, - "radius": 75, - }, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - }, - "data": { - "O3": 0.048, - "PM2.5": 8.9, - "HourObserved": 15, - "DateObserved": "2020-12-20", - "StateCode": REDACTED, - "ReportingArea": REDACTED, - "Latitude": REDACTED, - "Longitude": REDACTED, - "PM10": 12, - "AQI": 44, - "Category.Number": 1, - "Category.Name": "Good", - "Pollutant": "O3", - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/airvisual/conftest.py b/tests/components/airvisual/conftest.py index bdd325d4739..58b8864ea9c 100644 --- a/tests/components/airvisual/conftest.py +++ b/tests/components/airvisual/conftest.py @@ -69,6 +69,7 @@ def config_entry_fixture(hass, config, config_entry_version, integration_type): """Define a config entry fixture.""" entry = MockConfigEntry( domain=DOMAIN, + entry_id="3bd2acb0e4f0476d40865546d0d91921", unique_id=async_get_geography_id(config), data={**config, CONF_INTEGRATION_TYPE: integration_type}, options={CONF_SHOW_ON_MAP: True}, diff --git a/tests/components/airvisual/snapshots/test_diagnostics.ambr b/tests/components/airvisual/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c805c5f9cb7 --- /dev/null +++ b/tests/components/airvisual/snapshots/test_diagnostics.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'city': '**REDACTED**', + 'country': '**REDACTED**', + 'current': dict({ + 'pollution': dict({ + 'aqicn': 18, + 'aqius': 52, + 'maincn': 'p2', + 'mainus': 'p2', + 'ts': '2021-09-04T00:00:00.000Z', + }), + 'weather': dict({ + 'hu': 45, + 'ic': '10d', + 'pr': 999, + 'tp': 23, + 'ts': '2021-09-03T21:00:00.000Z', + 'wd': 252, + 'ws': 0.45, + }), + }), + 'location': dict({ + 'coordinates': '**REDACTED**', + 'type': 'Point', + }), + 'state': '**REDACTED**', + }), + 'entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'integration_type': 'Geographical Location by Latitude/Longitude', + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'airvisual', + 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'options': dict({ + 'show_on_map': True, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 3, + }), + }) +# --- diff --git a/tests/components/airvisual/test_diagnostics.py b/tests/components/airvisual/test_diagnostics.py index 94d22e7f61c..32a083ec985 100644 --- a/tests/components/airvisual/test_diagnostics.py +++ b/tests/components/airvisual/test_diagnostics.py @@ -1,5 +1,6 @@ """Test AirVisual diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -11,49 +12,10 @@ async def test_entry_diagnostics( config_entry, hass_client: ClientSessionGenerator, setup_config_entry, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "entry_id": config_entry.entry_id, - "version": 3, - "domain": "airvisual", - "title": REDACTED, - "data": { - "integration_type": "Geographical Location by Latitude/Longitude", - "api_key": REDACTED, - "latitude": REDACTED, - "longitude": REDACTED, - }, - "options": {"show_on_map": True}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - }, - "data": { - "city": REDACTED, - "state": REDACTED, - "country": REDACTED, - "location": {"type": "Point", "coordinates": REDACTED}, - "current": { - "weather": { - "ts": "2021-09-03T21:00:00.000Z", - "tp": 23, - "pr": 999, - "hu": 45, - "ws": 0.45, - "wd": 252, - "ic": "10d", - }, - "pollution": { - "ts": "2021-09-04T00:00:00.000Z", - "aqius": 52, - "mainus": "p2", - "aqicn": 18, - "maincn": "p2", - }, - }, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/airvisual_pro/conftest.py b/tests/components/airvisual_pro/conftest.py index caff9571812..4376db23366 100644 --- a/tests/components/airvisual_pro/conftest.py +++ b/tests/components/airvisual_pro/conftest.py @@ -24,7 +24,12 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture(name="config_entry") def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, unique_id="XXXXXXX", data=config) + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="6a2b3770e53c28dc1eeb2515e906b0ce", + unique_id="XXXXXXX", + data=config, + ) entry.add_to_hass(hass) return entry diff --git a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..96cda8e012f --- /dev/null +++ b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr @@ -0,0 +1,106 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + 'date_and_time': dict({ + 'date': '2022/10/06', + 'time': '16:00:44', + 'timestamp': '1665072044', + }), + 'history': dict({ + }), + 'last_measurement_timestamp': 1665072044, + 'measurements': dict({ + 'aqi_cn': '0', + 'aqi_us': '0', + 'co2': '472', + 'humidity': '57', + 'pm0_1': '0', + 'pm1_0': '0', + 'pm2_5': '0', + 'temperature_C': '23.0', + 'temperature_F': '73.4', + 'voc': '-1', + }), + 'serial_number': '**REDACTED**', + 'settings': dict({ + 'follow_mode': 'station', + 'followed_station': '0', + 'is_aqi_usa': True, + 'is_concentration_showed': True, + 'is_indoor': True, + 'is_lcd_on': True, + 'is_network_time': True, + 'is_temperature_celsius': False, + 'language': 'en-US', + 'lcd_brightness': 80, + 'node_name': 'Office', + 'power_saving': dict({ + '2slots': list([ + dict({ + 'hour_off': 9, + 'hour_on': 7, + }), + dict({ + 'hour_off': 22, + 'hour_on': 18, + }), + ]), + 'mode': 'yes', + 'running_time': 99, + 'yes': list([ + dict({ + 'hour': 8, + 'minute': 0, + }), + dict({ + 'hour': 21, + 'minute': 0, + }), + ]), + }), + 'sensor_mode': dict({ + 'custom_mode_interval': 3, + 'mode': 1, + }), + 'speed_unit': 'mph', + 'timezone': 'America/New York', + 'tvoc_unit': 'ppb', + }), + 'status': dict({ + 'app_version': '1.1826', + 'battery': 100, + 'datetime': 1665072044, + 'device_name': 'AIRVISUAL-XXXXXXX', + 'ip_address': '192.168.1.101', + 'mac_address': '**REDACTED**', + 'model': 20, + 'sensor_life': dict({ + 'pm2_5': 1567924345130, + }), + 'sensor_pm25_serial': '00000005050224011145', + 'sync_time': 250000, + 'system_version': 'KBG63F84', + 'used_memory': 3, + 'wifi_strength': 4, + }), + }), + 'entry': dict({ + 'data': dict({ + 'ip_address': '192.168.1.101', + 'password': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'airvisual_pro', + 'entry_id': '6a2b3770e53c28dc1eeb2515e906b0ce', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': 'XXXXXXX', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/airvisual_pro/test_diagnostics.py b/tests/components/airvisual_pro/test_diagnostics.py index 5141782e574..7c69a7e636f 100644 --- a/tests/components/airvisual_pro/test_diagnostics.py +++ b/tests/components/airvisual_pro/test_diagnostics.py @@ -1,5 +1,6 @@ """Test AirVisual Pro diagnostics.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -11,83 +12,10 @@ async def test_entry_diagnostics( config_entry, hass_client: ClientSessionGenerator, setup_airvisual_pro, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "entry_id": config_entry.entry_id, - "version": 1, - "domain": "airvisual_pro", - "title": "Mock Title", - "data": {"ip_address": "192.168.1.101", "password": REDACTED}, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": "XXXXXXX", - "disabled_by": None, - }, - "data": { - "date_and_time": { - "date": "2022/10/06", - "time": "16:00:44", - "timestamp": "1665072044", - }, - "history": {}, - "measurements": { - "co2": "472", - "humidity": "57", - "pm0_1": "0", - "pm1_0": "0", - "aqi_cn": "0", - "aqi_us": "0", - "pm2_5": "0", - "temperature_C": "23.0", - "temperature_F": "73.4", - "voc": "-1", - }, - "serial_number": REDACTED, - "settings": { - "follow_mode": "station", - "followed_station": "0", - "is_aqi_usa": True, - "is_concentration_showed": True, - "is_indoor": True, - "is_lcd_on": True, - "is_network_time": True, - "is_temperature_celsius": False, - "language": "en-US", - "lcd_brightness": 80, - "node_name": "Office", - "power_saving": { - "2slots": [ - {"hour_off": 9, "hour_on": 7}, - {"hour_off": 22, "hour_on": 18}, - ], - "mode": "yes", - "running_time": 99, - "yes": [{"hour": 8, "minute": 0}, {"hour": 21, "minute": 0}], - }, - "sensor_mode": {"custom_mode_interval": 3, "mode": 1}, - "speed_unit": "mph", - "timezone": "America/New York", - "tvoc_unit": "ppb", - }, - "status": { - "app_version": "1.1826", - "battery": 100, - "datetime": 1665072044, - "device_name": "AIRVISUAL-XXXXXXX", - "ip_address": "192.168.1.101", - "mac_address": REDACTED, - "model": 20, - "sensor_life": {"pm2_5": 1567924345130}, - "sensor_pm25_serial": "00000005050224011145", - "sync_time": 250000, - "system_version": "KBG63F84", - "used_memory": 3, - "wifi_strength": 4, - }, - "last_measurement_timestamp": 1665072044, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index cce8a452a15..4de1cae7555 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -28,6 +28,10 @@ async def test_airzone_create_sensors( await async_init_integration(hass) + # Hot Water + state = hass.states.get("sensor.airzone_dhw_temperature") + assert state.state == "43" + # WebServer state = hass.states.get("sensor.webserver_rssi") assert state.state == "-42" diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..94e602ec03b --- /dev/null +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -0,0 +1,236 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics + dict({ + 'api_data': dict({ + 'devices-config': dict({ + 'device1': dict({ + }), + }), + 'devices-status': dict({ + 'device1': dict({ + }), + }), + 'installations': dict({ + 'installation1': dict({ + 'groups': list([ + dict({ + 'devices': list([ + dict({ + 'device_id': 'device1', + 'ws_id': 'webserver1', + }), + ]), + 'group_id': 'group1', + }), + ]), + 'plugins': dict({ + 'schedules': dict({ + 'calendar_ws_ids': list([ + 'webserver1', + ]), + }), + }), + }), + }), + 'installations-list': dict({ + }), + 'test_cov': dict({ + '1': None, + '2': list([ + 'foo', + 'bar', + ]), + '3': list([ + list([ + 'foo', + 'bar', + ]), + ]), + }), + 'webservers': dict({ + 'webserver1': dict({ + }), + }), + }), + 'config_entry': dict({ + 'data': dict({ + 'id': 'installation1', + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'airzone_cloud', + 'entry_id': 'd186e31edb46d64d14b9b2f11f1ebd9f', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': 'installation1', + 'version': 1, + }), + 'coord_data': dict({ + 'aidoos': dict({ + 'aidoo1': dict({ + 'action': 6, + 'active': False, + 'available': True, + 'id': 'aidoo1', + 'installation': 'installation1', + 'is-connected': True, + 'mode': None, + 'name': 'Bron', + 'power': None, + 'problems': False, + 'temperature': 21.0, + 'temperature-step': 0.5, + 'web-server': '11:22:33:44:55:67', + 'ws-connected': True, + }), + }), + 'groups': dict({ + 'group1': dict({ + 'action': 6, + 'active': True, + 'available': True, + 'humidity': 27, + 'installation': 'installation1', + 'mode': 0, + 'name': 'Group', + 'num-devices': 2, + 'power': None, + 'systems': list([ + 'system1', + ]), + 'temperature': 22.5, + 'temperature-step': 0.5, + 'zones': list([ + 'zone1', + 'zone2', + ]), + }), + 'grp2': dict({ + 'action': 6, + 'active': False, + 'aidoos': list([ + 'aidoo1', + ]), + 'available': True, + 'installation': 'installation1', + 'mode': 0, + 'name': 'Aidoo Group', + 'num-devices': 1, + 'power': None, + 'temperature': 21.0, + 'temperature-step': 0.5, + }), + }), + 'installations': dict({ + 'installation1': dict({ + 'id': 'installation1', + 'name': 'House', + 'web-servers': list([ + 'webserver1', + '11:22:33:44:55:67', + ]), + }), + }), + 'systems': dict({ + 'system1': dict({ + 'available': True, + 'errors': list([ + dict({ + '_id': 'error-id', + }), + ]), + 'id': 'system1', + 'installation': 'installation1', + 'is-connected': True, + 'mode': None, + 'name': 'System 1', + 'problems': True, + 'system': 1, + 'web-server': 'webserver1', + 'ws-connected': True, + }), + }), + 'web-servers': dict({ + '11:22:33:44:55:67': dict({ + 'available': True, + 'connection-date': '2023-05-24 17:00:52 +0200', + 'disconnection-date': '2023-05-24 17:00:25 +0200', + 'firmware': '3.13', + 'id': '11:22:33:44:55:67', + 'installation': 'installation1', + 'name': 'WebServer 11:22:33:44:55:67', + 'type': 'ws_aidoo', + 'wifi-channel': 1, + 'wifi-mac': '**REDACTED**', + 'wifi-quality': 4, + 'wifi-rssi': -77, + 'wifi-ssid': 'Wifi', + }), + 'webserver1': dict({ + 'available': True, + 'connection-date': '2023-05-07T12:55:51.000Z', + 'disconnection-date': '2023-01-01T22:26:55.376Z', + 'firmware': '3.44', + 'id': 'webserver1', + 'installation': 'installation1', + 'name': 'WebServer 11:22:33:44:55:66', + 'type': 'ws_az', + 'wifi-channel': 36, + 'wifi-mac': '**REDACTED**', + 'wifi-quality': 4, + 'wifi-rssi': -56, + 'wifi-ssid': 'Wifi', + }), + }), + 'zones': dict({ + 'zone1': dict({ + 'action': 6, + 'active': True, + 'available': True, + 'humidity': 30, + 'id': 'zone1', + 'installation': 'installation1', + 'is-connected': True, + 'master': None, + 'mode': None, + 'name': 'Salon', + 'power': None, + 'problems': False, + 'system': 1, + 'system-id': 'system1', + 'temperature': 20.0, + 'temperature-step': 0.5, + 'web-server': 'webserver1', + 'ws-connected': True, + 'zone': 1, + }), + 'zone2': dict({ + 'action': 6, + 'active': False, + 'available': True, + 'humidity': 24, + 'id': 'zone2', + 'installation': 'installation1', + 'is-connected': True, + 'master': None, + 'mode': None, + 'name': 'Dormitorio', + 'power': None, + 'problems': False, + 'system': 1, + 'system-id': 'system1', + 'temperature': 25.0, + 'temperature-step': 0.5, + 'web-server': 'webserver1', + 'ws-connected': True, + 'zone': 2, + }), + }), + }), + }) +# --- diff --git a/tests/components/airzone_cloud/test_binary_sensor.py b/tests/components/airzone_cloud/test_binary_sensor.py index 14f7a078156..a1b5d5319c0 100644 --- a/tests/components/airzone_cloud/test_binary_sensor.py +++ b/tests/components/airzone_cloud/test_binary_sensor.py @@ -1,5 +1,7 @@ """The binary sensor tests for the Airzone Cloud platform.""" +from aioairzone_cloud.const import API_OLD_ID + from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -20,6 +22,16 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.bron_running") assert state.state == STATE_OFF + # Systems + state = hass.states.get("binary_sensor.system_1_problem") + assert state.state == STATE_ON + assert state.attributes.get("errors") == [ + { + API_OLD_ID: "error-id", + }, + ] + assert state.attributes.get("warnings") is None + # Zones state = hass.states.get("binary_sensor.dormitorio_problem") assert state.state == STATE_OFF diff --git a/tests/components/airzone_cloud/test_diagnostics.py b/tests/components/airzone_cloud/test_diagnostics.py index 6c8ae366518..8bef70501e7 100644 --- a/tests/components/airzone_cloud/test_diagnostics.py +++ b/tests/components/airzone_cloud/test_diagnostics.py @@ -8,22 +8,16 @@ from aioairzone_cloud.const import ( API_GROUP_ID, API_GROUPS, API_WS_ID, - AZD_AIDOOS, - AZD_GROUPS, - AZD_INSTALLATIONS, - AZD_SYSTEMS, - AZD_WEBSERVERS, - AZD_ZONES, RAW_DEVICES_CONFIG, RAW_DEVICES_STATUS, RAW_INSTALLATIONS, RAW_INSTALLATIONS_LIST, RAW_WEBSERVERS, ) +from syrupy import SnapshotAssertion from homeassistant.components.airzone_cloud.const import DOMAIN -from homeassistant.components.diagnostics import REDACTED -from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from .util import CONFIG, WS_ID, async_init_integration @@ -78,7 +72,9 @@ RAW_DATA_MOCK = { async def test_config_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" await async_init_integration(hass) @@ -89,40 +85,5 @@ async def test_config_entry_diagnostics( "homeassistant.components.airzone_cloud.AirzoneCloudApi.raw_data", return_value=RAW_DATA_MOCK, ): - diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - - assert list(diag["api_data"]) >= list(RAW_DATA_MOCK) - assert "dev1" not in diag["api_data"][RAW_DEVICES_CONFIG] - assert "device1" in diag["api_data"][RAW_DEVICES_CONFIG] - assert ( - diag["api_data"][RAW_INSTALLATIONS]["installation1"][API_GROUPS][0][ - API_GROUP_ID - ] - == "group1" - ) - assert "inst1" not in diag["api_data"][RAW_INSTALLATIONS] - assert "installation1" in diag["api_data"][RAW_INSTALLATIONS] - assert WS_ID not in diag["api_data"][RAW_WEBSERVERS] - assert "webserver1" in diag["api_data"][RAW_WEBSERVERS] - - assert ( - diag["config_entry"].items() - >= { - "data": { - CONF_ID: "installation1", - CONF_PASSWORD: REDACTED, - CONF_USERNAME: REDACTED, - }, - "domain": DOMAIN, - "unique_id": "installation1", - }.items() - ) - - assert list(diag["coord_data"]) >= [ - AZD_AIDOOS, - AZD_GROUPS, - AZD_INSTALLATIONS, - AZD_SYSTEMS, - AZD_WEBSERVERS, - AZD_ZONES, - ] + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert result == snapshot diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 0c26755f948..8fd7da06853 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -25,6 +25,7 @@ from aioairzone_cloud.const import ( API_LOCAL_TEMP, API_META, API_NAME, + API_OLD_ID, API_STAT_AP_MAC, API_STAT_CHANNEL, API_STAT_QUALITY, @@ -175,7 +176,11 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: } if device.get_id() == "system1": return { - API_ERRORS: [], + API_ERRORS: [ + { + API_OLD_ID: "error-id", + }, + ], API_IS_CONNECTED: True, API_WS_CONNECTED: True, API_WARNINGS: [], @@ -223,6 +228,7 @@ async def async_init_integration( config_entry = MockConfigEntry( data=CONFIG, + entry_id="d186e31edb46d64d14b9b2f11f1ebd9f", domain=DOMAIN, unique_id=CONFIG[CONF_ID], ) diff --git a/tests/components/ambient_station/conftest.py b/tests/components/ambient_station/conftest.py index aa849922b34..ab5eb6239c8 100644 --- a/tests/components/ambient_station/conftest.py +++ b/tests/components/ambient_station/conftest.py @@ -28,7 +28,11 @@ def config_fixture(hass): @pytest.fixture(name="config_entry") def config_entry_fixture(hass, config): """Define a config entry fixture.""" - entry = MockConfigEntry(domain=DOMAIN, data=config) + entry = MockConfigEntry( + domain=DOMAIN, + data=config, + entry_id="382cf7643f016fd48b3fe52163fe8877", + ) entry.add_to_hass(hass) return entry diff --git a/tests/components/ambient_station/snapshots/test_diagnostics.ambr b/tests/components/ambient_station/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..4b231660c4b --- /dev/null +++ b/tests/components/ambient_station/snapshots/test_diagnostics.ambr @@ -0,0 +1,65 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'app_key': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'ambient_station', + 'entry_id': '382cf7643f016fd48b3fe52163fe8877', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 2, + }), + 'stations': dict({ + 'devices': list([ + dict({ + 'apiKey': '**REDACTED**', + 'info': dict({ + 'location': '**REDACTED**', + 'name': 'Side Yard', + }), + 'lastData': dict({ + 'baromabsin': 25.016, + 'baromrelin': 29.953, + 'batt_co2': 1, + 'dailyrainin': 0, + 'date': '2022-01-19T22:38:00.000Z', + 'dateutc': 1642631880000, + 'deviceId': '**REDACTED**', + 'dewPoint': 17.75, + 'dewPointin': 37, + 'eventrainin': 0, + 'feelsLike': 21, + 'feelsLikein': 69.1, + 'hourlyrainin': 0, + 'humidity': 87, + 'humidityin': 29, + 'lastRain': '2022-01-07T19:45:00.000Z', + 'maxdailygust': 9.2, + 'monthlyrainin': 0.409, + 'solarradiation': 11.62, + 'tempf': 21, + 'tempinf': 70.9, + 'totalrainin': 35.398, + 'tz': '**REDACTED**', + 'uv': 0, + 'weeklyrainin': 0, + 'winddir': 25, + 'windgustmph': 1.1, + 'windspeedmph': 0.2, + }), + 'macAddress': '**REDACTED**', + }), + ]), + 'method': 'subscribe', + }), + }) +# --- diff --git a/tests/components/ambient_station/test_diagnostics.py b/tests/components/ambient_station/test_diagnostics.py index 61e974f4d0b..4c7a0f66f6a 100644 --- a/tests/components/ambient_station/test_diagnostics.py +++ b/tests/components/ambient_station/test_diagnostics.py @@ -1,6 +1,7 @@ """Test Ambient PWS diagnostics.""" +from syrupy import SnapshotAssertion + from homeassistant.components.ambient_station import DOMAIN -from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -13,62 +14,12 @@ async def test_entry_diagnostics( hass_client: ClientSessionGenerator, data_station, setup_config_entry, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" ambient = hass.data[DOMAIN][config_entry.entry_id] ambient.stations = data_station - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "entry_id": config_entry.entry_id, - "version": 2, - "domain": "ambient_station", - "title": REDACTED, - "data": {"api_key": REDACTED, "app_key": REDACTED}, - "options": {}, - "pref_disable_new_entities": False, - "pref_disable_polling": False, - "source": "user", - "unique_id": REDACTED, - "disabled_by": None, - }, - "stations": { - "devices": [ - { - "macAddress": REDACTED, - "lastData": { - "dateutc": 1642631880000, - "tempinf": 70.9, - "humidityin": 29, - "baromrelin": 29.953, - "baromabsin": 25.016, - "tempf": 21, - "humidity": 87, - "winddir": 25, - "windspeedmph": 0.2, - "windgustmph": 1.1, - "maxdailygust": 9.2, - "hourlyrainin": 0, - "eventrainin": 0, - "dailyrainin": 0, - "weeklyrainin": 0, - "monthlyrainin": 0.409, - "totalrainin": 35.398, - "solarradiation": 11.62, - "uv": 0, - "batt_co2": 1, - "feelsLike": 21, - "dewPoint": 17.75, - "feelsLikein": 69.1, - "dewPointin": 37, - "lastRain": "2022-01-07T19:45:00.000Z", - "deviceId": REDACTED, - "tz": REDACTED, - "date": "2022-01-19T22:38:00.000Z", - }, - "info": {"name": "Side Yard", "location": REDACTED}, - "apiKey": REDACTED, - } - ], - "method": "subscribe", - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index 7474e3ba890..831f7811972 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -869,7 +869,6 @@ async def test_v1_sensors( { "sensor_entity": "sensor.test_device_18b2_timestamp", "friendly_name": "Test Device 18B2 Timestamp", - "unit_of_measurement": "s", "state_class": "measurement", "expected_state": "2023-05-14T19:41:17+00:00", }, @@ -943,6 +942,21 @@ async def test_v1_sensors( }, ], ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x44\x53\x0C\x48\x65\x6C\x6C\x6F\x20\x57\x6F\x72\x6C\x64\x21", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_text", + "friendly_name": "Test Device 18B2 Text", + "expected_state": "Hello World!", + }, + ], + ), ( "A4:C1:38:8D:18:B2", make_bthome_v2_adv( @@ -1080,7 +1094,9 @@ async def test_v2_sensors( if ATTR_UNIT_OF_MEASUREMENT in sensor_attr: # Some sensors don't have a unit of measurement assert sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == meas["unit_of_measurement"] - assert sensor_attr[ATTR_STATE_CLASS] == meas["state_class"] + if ATTR_STATE_CLASS in sensor_attr: + # Some sensors have state class None + assert sensor_attr[ATTR_STATE_CLASS] == meas["state_class"] assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py index d010cac77ad..f83de408bcc 100644 --- a/tests/components/cloud/test_repairs.py +++ b/tests/components/cloud/test_repairs.py @@ -123,6 +123,7 @@ async def test_legacy_subscription_repair_flow( "errors": None, "description_placeholders": None, "last_step": None, + "preview": None, } resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") @@ -205,6 +206,7 @@ async def test_legacy_subscription_repair_flow_timeout( "errors": None, "description_placeholders": None, "last_step": None, + "preview": None, } with patch("homeassistant.components.cloud.repairs.MAX_RETRIES", new=0): diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 4684b4148b1..4239e031893 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -396,6 +396,7 @@ async def test_initialize_flow(hass: HomeAssistant, client) -> None: }, "errors": {"username": "Should be unique."}, "last_step": None, + "preview": None, } @@ -571,6 +572,7 @@ async def test_two_step_flow( "description_placeholders": None, "errors": None, "last_step": None, + "preview": None, } with patch.dict(HANDLERS, {"test": TestFlow}): @@ -647,6 +649,7 @@ async def test_continue_flow_unauth( "description_placeholders": None, "errors": None, "last_step": None, + "preview": None, } hass_admin_user.groups = [] @@ -822,6 +825,7 @@ async def test_options_flow(hass: HomeAssistant, client) -> None: "description_placeholders": {"enabled": "Set to true to be true"}, "errors": None, "last_step": None, + "preview": None, } @@ -917,6 +921,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client) -> None: "description_placeholders": None, "errors": None, "last_step": None, + "preview": None, } with patch.dict(HANDLERS, {"test": TestFlow}): @@ -998,6 +1003,7 @@ async def test_options_flow_with_invalid_data(hass: HomeAssistant, client) -> No "description_placeholders": None, "errors": None, "last_step": None, + "preview": None, } with patch.dict(HANDLERS, {"test": TestFlow}): diff --git a/tests/components/datetime/test_init.py b/tests/components/datetime/test_init.py index 66390c8d90f..6f2e2db29a1 100644 --- a/tests/components/datetime/test_init.py +++ b/tests/components/datetime/test_init.py @@ -1,5 +1,5 @@ """The tests for the datetime component.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from zoneinfo import ZoneInfo import pytest @@ -14,7 +14,7 @@ from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, CONF_PLATFOR from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -DEFAULT_VALUE = datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc) +DEFAULT_VALUE = datetime(2020, 1, 1, 12, 0, 0, tzinfo=UTC) class MockDateTimeEntity(DateTimeEntity): diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py index fe11a55eb85..f4cc372660c 100644 --- a/tests/components/devolo_home_network/const.py +++ b/tests/components/devolo_home_network/const.py @@ -1,11 +1,13 @@ """Constants used for mocking data.""" from devolo_plc_api.device_api import ( + UPDATE_AVAILABLE, WIFI_BAND_2G, WIFI_BAND_5G, WIFI_VAP_MAIN_AP, ConnectedStationInfo, NeighborAPInfo, + UpdateFirmwareCheck, WifiGuestAccessGet, ) from devolo_plc_api.plcnet_api import LogicalNetwork @@ -79,6 +81,10 @@ DISCOVERY_INFO_WRONG_DEVICE = ZeroconfServiceInfo( type="mock_type", ) +FIRMWARE_UPDATE_AVAILABLE = UpdateFirmwareCheck( + result=UPDATE_AVAILABLE, new_firmware_version="5.6.2_2023-01-15" +) + GUEST_WIFI = WifiGuestAccessGet( ssid="devolo-guest-930", key="HMANPGBA", diff --git a/tests/components/devolo_home_network/mock.py b/tests/components/devolo_home_network/mock.py index 1cced53a520..80d1348cf0f 100644 --- a/tests/components/devolo_home_network/mock.py +++ b/tests/components/devolo_home_network/mock.py @@ -13,6 +13,7 @@ from zeroconf.asyncio import AsyncZeroconf from .const import ( CONNECTED_STATIONS, DISCOVERY_INFO, + FIRMWARE_UPDATE_AVAILABLE, GUEST_WIFI, IP, NEIGHBOR_ACCESS_POINTS, @@ -50,6 +51,9 @@ class MockDevice(Device): """Reset mock to starting point.""" self.async_disconnect = AsyncMock() self.device = DeviceApi(IP, None, DISCOVERY_INFO) + self.device.async_check_firmware_available = AsyncMock( + return_value=FIRMWARE_UPDATE_AVAILABLE + ) self.device.async_get_led_setting = AsyncMock(return_value=False) self.device.async_restart = AsyncMock(return_value=True) self.device.async_start_wps = AsyncMock(return_value=True) @@ -60,6 +64,7 @@ class MockDevice(Device): self.device.async_get_wifi_neighbor_access_points = AsyncMock( return_value=NEIGHBOR_ACCESS_POINTS ) + self.device.async_start_firmware_update = AsyncMock(return_value=True) self.plcnet = PlcNetApi(IP, None, DISCOVERY_INFO) self.plcnet.async_get_network_overview = AsyncMock(return_value=PLCNET) self.plcnet.async_identify_device_start = AsyncMock(return_value=True) diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 99b6053e1ba..ba34eb18490 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -10,6 +10,7 @@ from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER from homeassistant.components.devolo_home_network.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.components.switch import DOMAIN as SWITCH +from homeassistant.components.update import DOMAIN as UPDATE from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant @@ -84,9 +85,12 @@ async def test_hass_stop(hass: HomeAssistant, mock_device: MockDevice) -> None: @pytest.mark.parametrize( ("device", "expected_platforms"), [ - ["mock_device", (BINARY_SENSOR, BUTTON, DEVICE_TRACKER, SENSOR, SWITCH)], - ["mock_repeater_device", (BUTTON, DEVICE_TRACKER, SENSOR, SWITCH)], - ["mock_nonwifi_device", (BINARY_SENSOR, BUTTON, SENSOR, SWITCH)], + [ + "mock_device", + (BINARY_SENSOR, BUTTON, DEVICE_TRACKER, SENSOR, SWITCH, UPDATE), + ], + ["mock_repeater_device", (BUTTON, DEVICE_TRACKER, SENSOR, SWITCH, UPDATE)], + ["mock_nonwifi_device", (BINARY_SENSOR, BUTTON, SENSOR, SWITCH, UPDATE)], ], ) async def test_platforms( diff --git a/tests/components/devolo_home_network/test_update.py b/tests/components/devolo_home_network/test_update.py new file mode 100644 index 00000000000..f5ef0bc9381 --- /dev/null +++ b/tests/components/devolo_home_network/test_update.py @@ -0,0 +1,166 @@ +"""Tests for the devolo Home Network update.""" +from devolo_plc_api.device_api import UPDATE_NOT_AVAILABLE, UpdateFirmwareCheck +from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable +import pytest + +from homeassistant.components.devolo_home_network.const import ( + DOMAIN, + LONG_UPDATE_INTERVAL, +) +from homeassistant.components.update import ( + DOMAIN as PLATFORM, + SERVICE_INSTALL, + UpdateDeviceClass, +) +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory +from homeassistant.util import dt as dt_util + +from . import configure_integration +from .const import FIRMWARE_UPDATE_AVAILABLE +from .mock import MockDevice + +from tests.common import async_fire_time_changed + + +@pytest.mark.usefixtures("mock_device") +async def test_update_setup(hass: HomeAssistant) -> None: + """Test default setup of the update component.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(f"{PLATFORM}.{device_name}_firmware") is not None + + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_update_firmware( + hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry +) -> None: + """Test updating a device.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_firmware" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_ON + assert state.attributes["device_class"] == UpdateDeviceClass.FIRMWARE + assert state.attributes["installed_version"] == mock_device.firmware_version + assert ( + state.attributes["latest_version"] + == FIRMWARE_UPDATE_AVAILABLE.new_firmware_version.split("_")[0] + ) + + assert entity_registry.async_get(state_key).entity_category == EntityCategory.CONFIG + + await hass.services.async_call( + PLATFORM, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + assert mock_device.device.async_start_firmware_update.call_count == 1 + + # Emulate state change + mock_device.device.async_check_firmware_available.return_value = ( + UpdateFirmwareCheck(result=UPDATE_NOT_AVAILABLE) + ) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_OFF + + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_device_failure_check( + hass: HomeAssistant, mock_device: MockDevice +) -> None: + """Test device failure during check.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_firmware" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + + mock_device.device.async_check_firmware_available.side_effect = DeviceUnavailable + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_device_failure_update( + hass: HomeAssistant, + mock_device: MockDevice, +) -> None: + """Test device failure when starting update.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_firmware" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_device.device.async_start_firmware_update.side_effect = DeviceUnavailable + + # Emulate update start + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + PLATFORM, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None: + """Test updating unautherized triggers the reauth flow.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_firmware" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_device.device.async_start_firmware_update.side_effect = DevicePasswordProtected + + with pytest.raises(HomeAssistantError): + assert await hass.services.async_call( + PLATFORM, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + assert "context" in flow + assert flow["context"]["source"] == SOURCE_REAUTH + assert flow["context"]["entry_id"] == entry.entry_id + + await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/dexcom/test_sensor.py b/tests/components/dexcom/test_sensor.py index 8e1974a3533..a211f0606f3 100644 --- a/tests/components/dexcom/test_sensor.py +++ b/tests/components/dexcom/test_sensor.py @@ -19,13 +19,9 @@ async def test_sensors(hass: HomeAssistant) -> None: """Test we get sensor data.""" await init_integration(hass) - test_username_glucose_value = hass.states.get( - "sensor.dexcom_test_username_glucose_value" - ) + test_username_glucose_value = hass.states.get("sensor.test_username_glucose_value") assert test_username_glucose_value.state == str(GLUCOSE_READING.value) - test_username_glucose_trend = hass.states.get( - "sensor.dexcom_test_username_glucose_trend" - ) + test_username_glucose_trend = hass.states.get("sensor.test_username_glucose_trend") assert test_username_glucose_trend.state == GLUCOSE_READING.trend_description @@ -37,16 +33,12 @@ async def test_sensors_unknown(hass: HomeAssistant) -> None: "homeassistant.components.dexcom.Dexcom.get_current_glucose_reading", return_value=None, ): - await async_update_entity(hass, "sensor.dexcom_test_username_glucose_value") - await async_update_entity(hass, "sensor.dexcom_test_username_glucose_trend") + await async_update_entity(hass, "sensor.test_username_glucose_value") + await async_update_entity(hass, "sensor.test_username_glucose_trend") - test_username_glucose_value = hass.states.get( - "sensor.dexcom_test_username_glucose_value" - ) + test_username_glucose_value = hass.states.get("sensor.test_username_glucose_value") assert test_username_glucose_value.state == STATE_UNKNOWN - test_username_glucose_trend = hass.states.get( - "sensor.dexcom_test_username_glucose_trend" - ) + test_username_glucose_trend = hass.states.get("sensor.test_username_glucose_trend") assert test_username_glucose_trend.state == STATE_UNKNOWN @@ -58,16 +50,12 @@ async def test_sensors_update_failed(hass: HomeAssistant) -> None: "homeassistant.components.dexcom.Dexcom.get_current_glucose_reading", side_effect=SessionError, ): - await async_update_entity(hass, "sensor.dexcom_test_username_glucose_value") - await async_update_entity(hass, "sensor.dexcom_test_username_glucose_trend") + await async_update_entity(hass, "sensor.test_username_glucose_value") + await async_update_entity(hass, "sensor.test_username_glucose_trend") - test_username_glucose_value = hass.states.get( - "sensor.dexcom_test_username_glucose_value" - ) + test_username_glucose_value = hass.states.get("sensor.test_username_glucose_value") assert test_username_glucose_value.state == STATE_UNAVAILABLE - test_username_glucose_trend = hass.states.get( - "sensor.dexcom_test_username_glucose_trend" - ) + test_username_glucose_trend = hass.states.get("sensor.test_username_glucose_trend") assert test_username_glucose_trend.state == STATE_UNAVAILABLE @@ -75,13 +63,9 @@ async def test_sensors_options_changed(hass: HomeAssistant) -> None: """Test we handle sensor unavailable.""" entry = await init_integration(hass) - test_username_glucose_value = hass.states.get( - "sensor.dexcom_test_username_glucose_value" - ) + test_username_glucose_value = hass.states.get("sensor.test_username_glucose_value") assert test_username_glucose_value.state == str(GLUCOSE_READING.value) - test_username_glucose_trend = hass.states.get( - "sensor.dexcom_test_username_glucose_trend" - ) + test_username_glucose_trend = hass.states.get("sensor.test_username_glucose_trend") assert test_username_glucose_trend.state == GLUCOSE_READING.trend_description with patch( @@ -99,11 +83,7 @@ async def test_sensors_options_changed(hass: HomeAssistant) -> None: assert entry.options == {CONF_UNIT_OF_MEASUREMENT: MMOL_L} - test_username_glucose_value = hass.states.get( - "sensor.dexcom_test_username_glucose_value" - ) + test_username_glucose_value = hass.states.get("sensor.test_username_glucose_value") assert test_username_glucose_value.state == str(GLUCOSE_READING.mmol_l) - test_username_glucose_trend = hass.states.get( - "sensor.dexcom_test_username_glucose_trend" - ) + test_username_glucose_trend = hass.states.get("sensor.test_username_glucose_trend") assert test_username_glucose_trend.state == GLUCOSE_READING.trend_description diff --git a/tests/components/environment_canada/test_diagnostics.py b/tests/components/environment_canada/test_diagnostics.py index f85de2cb97c..6044c9e778b 100644 --- a/tests/components/environment_canada/test_diagnostics.py +++ b/tests/components/environment_canada/test_diagnostics.py @@ -1,5 +1,5 @@ """Test Environment Canada diagnostics.""" -from datetime import datetime, timezone +from datetime import UTC, datetime import json from unittest.mock import AsyncMock, MagicMock, patch @@ -43,7 +43,7 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: ) weather_mock = mock_ec() - ec_data["metadata"]["timestamp"] = datetime(2022, 10, 4, tzinfo=timezone.utc) + ec_data["metadata"]["timestamp"] = datetime(2022, 10, 4, tzinfo=UTC) weather_mock.conditions = ec_data["conditions"] weather_mock.alerts = ec_data["alerts"] weather_mock.daily_forecasts = ec_data["daily_forecasts"] @@ -51,7 +51,7 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: radar_mock = mock_ec() radar_mock.image = b"GIF..." - radar_mock.timestamp = datetime(2022, 10, 4, tzinfo=timezone.utc) + radar_mock.timestamp = datetime(2022, 10, 4, tzinfo=UTC) with patch( "homeassistant.components.environment_canada.ECWeather", diff --git a/tests/components/esphome/test_init.py b/tests/components/esphome/test_init.py index d3d47a40d66..8e7e228e422 100644 --- a/tests/components/esphome/test_init.py +++ b/tests/components/esphome/test_init.py @@ -1,7 +1,5 @@ """ESPHome set up tests.""" -from unittest.mock import AsyncMock -from aioesphomeapi import DeviceInfo from homeassistant.components.esphome import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT @@ -10,29 +8,6 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def test_unique_id_updated_to_mac( - hass: HomeAssistant, mock_client, mock_zeroconf: None -) -> None: - """Test we update config entry unique ID to MAC address.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, - unique_id="mock-config-name", - ) - entry.add_to_hass(hass) - - mock_client.device_info = AsyncMock( - return_value=DeviceInfo( - mac_address="1122334455aa", - ) - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.unique_id == "11:22:33:44:55:aa" - - async def test_delete_entry( hass: HomeAssistant, mock_client, mock_zeroconf: None ) -> None: diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 3bb298024f9..d297dddee4a 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -1,11 +1,20 @@ """Test ESPHome manager.""" from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock -from aioesphomeapi import APIClient, EntityInfo, EntityState, UserService +from aioesphomeapi import APIClient, DeviceInfo, EntityInfo, EntityState, UserService +import pytest -from homeassistant.components.esphome.const import DOMAIN, STABLE_BLE_VERSION_STR +from homeassistant import config_entries +from homeassistant.components import dhcp +from homeassistant.components.esphome.const import ( + CONF_DEVICE_NAME, + DOMAIN, + STABLE_BLE_VERSION_STR, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import issue_registry as ir from .conftest import MockESPHomeDevice @@ -113,3 +122,213 @@ async def test_esphome_device_with_current_bluetooth( ) is None ) + + +async def test_unique_id_updated_to_mac( + hass: HomeAssistant, mock_client, mock_zeroconf: None +) -> None: + """Test we update config entry unique ID to MAC address.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="mock-config-name", + ) + entry.add_to_hass(hass) + + mock_client.device_info = AsyncMock( + return_value=DeviceInfo( + mac_address="1122334455aa", + ) + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.unique_id == "11:22:33:44:55:aa" + + +async def test_unique_id_not_updated_if_name_same_and_already_mac( + hass: HomeAssistant, mock_client: APIClient, mock_zeroconf: None +) -> None: + """Test we never update the entry unique ID event if the name is the same.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(mac_address="1122334455ab", name="test") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Mac should never update + assert entry.unique_id == "11:22:33:44:55:aa" + + +async def test_unique_id_updated_if_name_unset_and_already_mac( + hass: HomeAssistant, mock_client: APIClient, mock_zeroconf: None +) -> None: + """Test we never update config entry unique ID even if the name is unset.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(mac_address="1122334455ab", name="test") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Mac should never update + assert entry.unique_id == "11:22:33:44:55:aa" + + +async def test_unique_id_not_updated_if_name_different_and_already_mac( + hass: HomeAssistant, mock_client: APIClient, mock_zeroconf: None +) -> None: + """Test we do not update config entry unique ID if the name is different.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(mac_address="1122334455ab", name="different") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Mac should not be updated because name is different + assert entry.unique_id == "11:22:33:44:55:aa" + # Name should not be updated either + assert entry.data[CONF_DEVICE_NAME] == "test" + + +async def test_name_updated_only_if_mac_matches( + hass: HomeAssistant, mock_client: APIClient, mock_zeroconf: None +) -> None: + """Test we update config entry name only if the mac matches.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "old", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(mac_address="1122334455aa", name="new") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.unique_id == "11:22:33:44:55:aa" + assert entry.data[CONF_DEVICE_NAME] == "new" + + +async def test_name_updated_only_if_mac_was_unset( + hass: HomeAssistant, mock_client: APIClient, mock_zeroconf: None +) -> None: + """Test we update config entry name if the old unique id was not a mac.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "old", + }, + unique_id="notamac", + ) + entry.add_to_hass(hass) + + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(mac_address="1122334455aa", name="new") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.unique_id == "11:22:33:44:55:aa" + assert entry.data[CONF_DEVICE_NAME] == "new" + + +async def test_connection_aborted_wrong_device( + hass: HomeAssistant, + mock_client: APIClient, + mock_zeroconf: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test we abort the connection if the unique id is a mac and neither name or mac match.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.43.183", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(mac_address="1122334455ab", name="different") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert ( + "Unexpected device found at 192.168.43.183; expected `test` " + "with mac address `11:22:33:44:55:aa`, found `different` " + "with mac address `11:22:33:44:55:ab`" in caplog.text + ) + + caplog.clear() + # Make sure discovery triggers a reconnect to the correct device + service_info = dhcp.DhcpServiceInfo( + ip="192.168.43.184", + hostname="test", + macaddress="1122334455aa", + ) + new_info = AsyncMock( + return_value=DeviceInfo(mac_address="1122334455aa", name="test") + ) + mock_client.device_info = new_info + result = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == "192.168.43.184" + await hass.async_block_till_done() + assert len(new_info.mock_calls) == 1 + assert "Unexpected device found at" not in caplog.text diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index d6562651f0b..b7ce5670441 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -7,7 +7,13 @@ from unittest.mock import Mock, patch from aioesphomeapi import VoiceAssistantEventType import pytest -from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType +from homeassistant.components.assist_pipeline import ( + PipelineEvent, + PipelineEventType, + PipelineNotFound, + PipelineStage, +) +from homeassistant.components.assist_pipeline.error import WakeWordDetectionError from homeassistant.components.esphome import DomainData from homeassistant.components.esphome.voice_assistant import VoiceAssistantUDPServer from homeassistant.core import HomeAssistant @@ -71,6 +77,13 @@ async def test_pipeline_events( event_callback = kwargs["event_callback"] + event_callback( + PipelineEvent( + type=PipelineEventType.WAKE_WORD_END, + data={"wake_word_output": {}}, + ) + ) + # Fake events event_callback( PipelineEvent( @@ -112,6 +125,8 @@ async def test_pipeline_events( elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: assert data is not None assert data["url"] == _TEST_OUTPUT_URL + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: + assert data is None voice_assistant_udp_server_v1.handle_event = handle_event @@ -343,134 +358,90 @@ async def test_send_tts( voice_assistant_udp_server_v2.transport.sendto.assert_called() -async def test_speech_detection( +async def test_wake_word( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, ) -> None: - """Test the UDP server queues incoming data.""" + """Test that the pipeline is set to start with Wake word.""" - def is_speech(self, chunk, sample_rate): - """Anything non-zero is speech.""" - return sum(chunk) > 0 - - async def async_pipeline_from_audio_stream(*args, **kwargs): - stt_stream = kwargs["stt_stream"] - event_callback = kwargs["event_callback"] - async for _chunk in stt_stream: - pass - - # Test empty data - event_callback( - PipelineEvent( - type=PipelineEventType.STT_END, - data={"stt_output": {"text": _TEST_INPUT_TEXT}}, - ) - ) + async def async_pipeline_from_audio_stream(*args, start_stage, **kwargs): + assert start_stage == PipelineStage.WAKE_WORD with patch( - "webrtcvad.Vad.is_speech", - new=is_speech, - ), patch( "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ): - voice_assistant_udp_server_v2.started = True - - voice_assistant_udp_server_v2.queue.put_nowait(bytes(_ONE_SECOND)) - voice_assistant_udp_server_v2.queue.put_nowait(bytes([255] * _ONE_SECOND * 2)) - voice_assistant_udp_server_v2.queue.put_nowait(bytes([255] * _ONE_SECOND * 2)) - voice_assistant_udp_server_v2.queue.put_nowait(bytes(_ONE_SECOND)) + voice_assistant_udp_server_v2.transport = Mock() await voice_assistant_udp_server_v2.run_pipeline( - device_id="", conversation_id=None, use_vad=True, pipeline_timeout=1.0 + device_id="mock-device-id", + conversation_id=None, + flags=2, + pipeline_timeout=1, ) -async def test_no_speech( +async def test_wake_word_exception( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, ) -> None: - """Test there is no speech.""" - - def is_speech(self, chunk, sample_rate): - """Anything non-zero is speech.""" - return sum(chunk) > 0 - - def handle_event( - event_type: VoiceAssistantEventType, data: dict[str, str] | None - ) -> None: - assert event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR - assert data is not None - assert data["code"] == "speech-timeout" - - voice_assistant_udp_server_v2.handle_event = handle_event - - with patch( - "webrtcvad.Vad.is_speech", - new=is_speech, - ): - voice_assistant_udp_server_v2.started = True - - voice_assistant_udp_server_v2.queue.put_nowait(bytes(_ONE_SECOND)) - - await voice_assistant_udp_server_v2.run_pipeline( - device_id="", conversation_id=None, use_vad=True, pipeline_timeout=1.0 - ) - - -async def test_speech_timeout( - hass: HomeAssistant, - voice_assistant_udp_server_v2: VoiceAssistantUDPServer, -) -> None: - """Test when speech was detected, but the pipeline times out.""" - - def is_speech(self, chunk, sample_rate): - """Anything non-zero is speech.""" - return sum(chunk) > 255 + """Test that the pipeline is set to start with Wake word.""" async def async_pipeline_from_audio_stream(*args, **kwargs): - stt_stream = kwargs["stt_stream"] - async for _chunk in stt_stream: - # Stream will end when VAD detects end of "speech" - pass - - async def segment_audio(*args, **kwargs): - raise asyncio.TimeoutError() - async for chunk in []: - yield chunk + raise WakeWordDetectionError("pipeline-not-found", "Pipeline not found") with patch( - "webrtcvad.Vad.is_speech", - new=is_speech, - ), patch( "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, - ), patch( - "homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPServer._segment_audio", - new=segment_audio, ): - voice_assistant_udp_server_v2.started = True + voice_assistant_udp_server_v2.transport = Mock() - voice_assistant_udp_server_v2.queue.put_nowait(bytes([255] * (_ONE_SECOND * 2))) + def handle_event( + event_type: VoiceAssistantEventType, data: dict[str, str] | None + ) -> None: + if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: + assert data is not None + assert data["code"] == "pipeline-not-found" + assert data["message"] == "Pipeline not found" + + voice_assistant_udp_server_v2.handle_event = handle_event await voice_assistant_udp_server_v2.run_pipeline( - device_id="", conversation_id=None, use_vad=True, pipeline_timeout=1.0 + device_id="mock-device-id", + conversation_id=None, + flags=2, + pipeline_timeout=1, ) -async def test_cancelled( +async def test_pipeline_timeout( hass: HomeAssistant, voice_assistant_udp_server_v2: VoiceAssistantUDPServer, ) -> None: - """Test when the server is stopped while waiting for speech.""" + """Test that the pipeline is set to start with Wake word.""" - voice_assistant_udp_server_v2.started = True + async def async_pipeline_from_audio_stream(*args, **kwargs): + raise PipelineNotFound("not-found", "Pipeline not found") - voice_assistant_udp_server_v2.queue.put_nowait(b"") + with patch( + "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ): + voice_assistant_udp_server_v2.transport = Mock() - await voice_assistant_udp_server_v2.run_pipeline( - device_id="", conversation_id=None, use_vad=True, pipeline_timeout=1.0 - ) + def handle_event( + event_type: VoiceAssistantEventType, data: dict[str, str] | None + ) -> None: + if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: + assert data is not None + assert data["code"] == "pipeline not found" + assert data["message"] == "Selected pipeline not found" - # No events should be sent if cancelled while waiting for speech - voice_assistant_udp_server_v2.handle_event.assert_not_called() + voice_assistant_udp_server_v2.handle_event = handle_event + + await voice_assistant_udp_server_v2.run_pipeline( + device_id="mock-device-id", + conversation_id=None, + flags=2, + pipeline_timeout=1, + ) diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 61851559969..345c37dc8f1 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -1,7 +1,11 @@ """The tests for the feedreader component.""" -from datetime import timedelta +from collections.abc import Generator +from datetime import datetime, timedelta +import pickle +from time import gmtime +from typing import Any from unittest import mock -from unittest.mock import mock_open, patch +from unittest.mock import MagicMock, mock_open, patch import pytest @@ -13,7 +17,7 @@ from homeassistant.components.feedreader import ( EVENT_FEEDREADER, ) from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -27,7 +31,7 @@ VALID_CONFIG_4 = {feedreader.DOMAIN: {CONF_URLS: [URL], CONF_MAX_ENTRIES: 5}} VALID_CONFIG_5 = {feedreader.DOMAIN: {CONF_URLS: [URL], CONF_MAX_ENTRIES: 1}} -def load_fixture_bytes(src): +def load_fixture_bytes(src: str) -> bytes: """Return byte stream of fixture.""" feed_data = load_fixture(src) raw = bytes(feed_data, "utf-8") @@ -35,72 +39,198 @@ def load_fixture_bytes(src): @pytest.fixture(name="feed_one_event") -def fixture_feed_one_event(hass): +def fixture_feed_one_event(hass: HomeAssistant) -> bytes: """Load test feed data for one event.""" return load_fixture_bytes("feedreader.xml") @pytest.fixture(name="feed_two_event") -def fixture_feed_two_events(hass): +def fixture_feed_two_events(hass: HomeAssistant) -> bytes: """Load test feed data for two event.""" return load_fixture_bytes("feedreader1.xml") @pytest.fixture(name="feed_21_events") -def fixture_feed_21_events(hass): +def fixture_feed_21_events(hass: HomeAssistant) -> bytes: """Load test feed data for twenty one events.""" return load_fixture_bytes("feedreader2.xml") @pytest.fixture(name="feed_three_events") -def fixture_feed_three_events(hass): +def fixture_feed_three_events(hass: HomeAssistant) -> bytes: """Load test feed data for three events.""" return load_fixture_bytes("feedreader3.xml") @pytest.fixture(name="feed_atom_event") -def fixture_feed_atom_event(hass): +def fixture_feed_atom_event(hass: HomeAssistant) -> bytes: """Load test feed data for atom event.""" return load_fixture_bytes("feedreader5.xml") @pytest.fixture(name="events") -async def fixture_events(hass): +async def fixture_events(hass: HomeAssistant) -> list[Event]: """Fixture that catches alexa events.""" return async_capture_events(hass, EVENT_FEEDREADER) -@pytest.fixture(name="feed_storage", autouse=True) -def fixture_feed_storage(): +@pytest.fixture(name="storage") +def fixture_storage(request: pytest.FixtureRequest) -> Generator[None, None, None]: + """Set up the test storage environment.""" + if request.param == "legacy_storage": + with patch("os.path.exists", return_value=False): + yield + elif request.param == "json_storage": + with patch("os.path.exists", return_value=True): + yield + else: + raise RuntimeError("Invalid storage fixture") + + +@pytest.fixture(name="legacy_storage_open") +def fixture_legacy_storage_open() -> Generator[MagicMock, None, None]: """Mock builtins.open for feedreader storage.""" - with patch("homeassistant.components.feedreader.open", mock_open(), create=True): - yield - - -async def test_setup_one_feed(hass: HomeAssistant) -> None: - """Test the general setup of this component.""" with patch( - "homeassistant.components.feedreader.track_time_interval" + "homeassistant.components.feedreader.open", + mock_open(), + create=True, + ) as open_mock: + yield open_mock + + +@pytest.fixture(name="legacy_storage_load", autouse=True) +def fixture_legacy_storage_load( + legacy_storage_open, +) -> Generator[MagicMock, None, None]: + """Mock builtins.open for feedreader storage.""" + with patch( + "homeassistant.components.feedreader.pickle.load", return_value={} + ) as pickle_load: + yield pickle_load + + +async def test_setup_no_feeds(hass: HomeAssistant) -> None: + """Test config with no urls.""" + assert not await async_setup_component( + hass, feedreader.DOMAIN, {feedreader.DOMAIN: {CONF_URLS: []}} + ) + + +@pytest.mark.parametrize( + ("open_error", "load_error"), + [ + (FileNotFoundError("No file"), None), + (OSError("Boom"), None), + (None, pickle.PickleError("Bad data")), + ], +) +async def test_legacy_storage_error( + hass: HomeAssistant, + legacy_storage_open: MagicMock, + legacy_storage_load: MagicMock, + open_error: Exception | None, + load_error: Exception | None, +) -> None: + """Test legacy storage error.""" + legacy_storage_open.side_effect = open_error + legacy_storage_load.side_effect = load_error + + with patch( + "homeassistant.components.feedreader.async_track_time_interval" ) as track_method: assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_1) await hass.async_block_till_done() - track_method.assert_called_once_with( - hass, mock.ANY, DEFAULT_SCAN_INTERVAL, cancel_on_shutdown=True - ) + track_method.assert_called_once_with( + hass, mock.ANY, DEFAULT_SCAN_INTERVAL, cancel_on_shutdown=True + ) + + +@pytest.mark.parametrize("storage", ["legacy_storage", "json_storage"], indirect=True) +async def test_storage_data_loading( + hass: HomeAssistant, + events: list[Event], + feed_one_event: bytes, + legacy_storage_load: MagicMock, + hass_storage: dict[str, Any], + storage: None, +) -> None: + """Test loading existing storage data.""" + storage_data: dict[str, str] = {URL: "2018-04-30T05:10:00+00:00"} + hass_storage[feedreader.DOMAIN] = { + "version": 1, + "minor_version": 1, + "key": feedreader.DOMAIN, + "data": storage_data, + } + legacy_storage_data = { + URL: gmtime(datetime.fromisoformat(storage_data[URL]).timestamp()) + } + legacy_storage_load.return_value = legacy_storage_data + + with patch( + "feedparser.http.get", + return_value=feed_one_event, + ): + assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_2) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + # no new events + assert not events + + +async def test_storage_data_writing( + hass: HomeAssistant, + events: list[Event], + feed_one_event: bytes, + hass_storage: dict[str, Any], +) -> None: + """Test writing to storage.""" + storage_data: dict[str, str] = {URL: "2018-04-30T05:10:00+00:00"} + + with patch( + "feedparser.http.get", + return_value=feed_one_event, + ), patch("homeassistant.components.feedreader.DELAY_SAVE", new=0): + assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_2) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + # one new event + assert len(events) == 1 + + # storage data updated + assert hass_storage[feedreader.DOMAIN]["data"] == storage_data + + +@pytest.mark.parametrize("storage", ["legacy_storage", "json_storage"], indirect=True) +async def test_setup_one_feed(hass: HomeAssistant, storage: None) -> None: + """Test the general setup of this component.""" + with patch( + "homeassistant.components.feedreader.async_track_time_interval" + ) as track_method: + assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_1) + await hass.async_block_till_done() + + track_method.assert_called_once_with( + hass, mock.ANY, DEFAULT_SCAN_INTERVAL, cancel_on_shutdown=True + ) async def test_setup_scan_interval(hass: HomeAssistant) -> None: """Test the setup of this component with scan interval.""" with patch( - "homeassistant.components.feedreader.track_time_interval" + "homeassistant.components.feedreader.async_track_time_interval" ) as track_method: assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_2) await hass.async_block_till_done() - track_method.assert_called_once_with( - hass, mock.ANY, timedelta(seconds=60), cancel_on_shutdown=True - ) + track_method.assert_called_once_with( + hass, mock.ANY, timedelta(seconds=60), cancel_on_shutdown=True + ) async def test_setup_max_entries(hass: HomeAssistant) -> None: diff --git a/tests/components/forecast_solar/conftest.py b/tests/components/forecast_solar/conftest.py index 60e9c9dc5d0..06cf39b4875 100644 --- a/tests/components/forecast_solar/conftest.py +++ b/tests/components/forecast_solar/conftest.py @@ -9,7 +9,8 @@ import pytest from homeassistant.components.forecast_solar.const import ( CONF_AZIMUTH, - CONF_DAMPING, + CONF_DAMPING_EVENING, + CONF_DAMPING_MORNING, CONF_DECLINATION, CONF_INVERTER_SIZE, CONF_MODULES_POWER, @@ -37,6 +38,7 @@ def mock_config_entry() -> MockConfigEntry: return MockConfigEntry( title="Green House", unique_id="unique", + version=2, domain=DOMAIN, data={ CONF_LATITUDE: 52.42, @@ -47,7 +49,8 @@ def mock_config_entry() -> MockConfigEntry: CONF_DECLINATION: 30, CONF_AZIMUTH: 190, CONF_MODULES_POWER: 5100, - CONF_DAMPING: 0.5, + CONF_DAMPING_MORNING: 0.5, + CONF_DAMPING_EVENING: 0.5, CONF_INVERTER_SIZE: 2000, }, ) diff --git a/tests/components/forecast_solar/snapshots/test_diagnostics.ambr b/tests/components/forecast_solar/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..686721a9d4a --- /dev/null +++ b/tests/components/forecast_solar/snapshots/test_diagnostics.ambr @@ -0,0 +1,45 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'account': dict({ + 'rate_limit': 60, + 'timezone': 'Europe/Amsterdam', + 'type': 'public', + }), + 'data': dict({ + 'energy_current_hour': 800000, + 'energy_production_today': 100000, + 'energy_production_today_remaining': 50000, + 'energy_production_tomorrow': 200000, + 'power_production_now': 300000, + 'watts': dict({ + '2021-06-27T13:00:00-07:00': 10, + '2022-06-27T13:00:00-07:00': 100, + }), + 'wh_days': dict({ + '2021-06-27T13:00:00-07:00': 20, + '2022-06-27T13:00:00-07:00': 200, + }), + 'wh_period': dict({ + '2021-06-27T13:00:00-07:00': 30, + '2022-06-27T13:00:00-07:00': 300, + }), + }), + 'entry': dict({ + 'data': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'options': dict({ + 'api_key': '**REDACTED**', + 'azimuth': 190, + 'damping_evening': 0.5, + 'damping_morning': 0.5, + 'declination': 30, + 'inverter_size': 2000, + 'modules_power': 5100, + }), + 'title': 'Green House', + }), + }) +# --- diff --git a/tests/components/forecast_solar/snapshots/test_init.ambr b/tests/components/forecast_solar/snapshots/test_init.ambr new file mode 100644 index 00000000000..a009105e2e6 --- /dev/null +++ b/tests/components/forecast_solar/snapshots/test_init.ambr @@ -0,0 +1,27 @@ +# serializer version: 1 +# name: test_migration + ConfigEntrySnapshot({ + 'data': dict({ + 'latitude': 52.42, + 'longitude': 4.42, + }), + 'disabled_by': None, + 'domain': 'forecast_solar', + 'entry_id': , + 'options': dict({ + 'api_key': 'abcdef12345', + 'azimuth': 190, + 'damping_evening': 0.5, + 'damping_morning': 0.5, + 'declination': 30, + 'inverter_size': 2000, + 'modules_power': 5100, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Green House', + 'unique_id': 'unique', + 'version': 2, + }) +# --- diff --git a/tests/components/forecast_solar/test_config_flow.py b/tests/components/forecast_solar/test_config_flow.py index 2129821217e..06aeb94542e 100644 --- a/tests/components/forecast_solar/test_config_flow.py +++ b/tests/components/forecast_solar/test_config_flow.py @@ -3,7 +3,8 @@ from unittest.mock import AsyncMock from homeassistant.components.forecast_solar.const import ( CONF_AZIMUTH, - CONF_DAMPING, + CONF_DAMPING_EVENING, + CONF_DAMPING_MORNING, CONF_DECLINATION, CONF_INVERTER_SIZE, CONF_MODULES_POWER, @@ -75,7 +76,8 @@ async def test_options_flow_invalid_api( CONF_DECLINATION: 21, CONF_AZIMUTH: 22, CONF_MODULES_POWER: 2122, - CONF_DAMPING: 0.25, + CONF_DAMPING_MORNING: 0.25, + CONF_DAMPING_EVENING: 0.25, CONF_INVERTER_SIZE: 2000, }, ) @@ -108,7 +110,8 @@ async def test_options_flow( CONF_DECLINATION: 21, CONF_AZIMUTH: 22, CONF_MODULES_POWER: 2122, - CONF_DAMPING: 0.25, + CONF_DAMPING_MORNING: 0.25, + CONF_DAMPING_EVENING: 0.25, CONF_INVERTER_SIZE: 2000, }, ) @@ -120,7 +123,8 @@ async def test_options_flow( CONF_DECLINATION: 21, CONF_AZIMUTH: 22, CONF_MODULES_POWER: 2122, - CONF_DAMPING: 0.25, + CONF_DAMPING_MORNING: 0.25, + CONF_DAMPING_EVENING: 0.25, CONF_INVERTER_SIZE: 2000, } @@ -147,7 +151,8 @@ async def test_options_flow_without_key( CONF_DECLINATION: 21, CONF_AZIMUTH: 22, CONF_MODULES_POWER: 2122, - CONF_DAMPING: 0.25, + CONF_DAMPING_MORNING: 0.25, + CONF_DAMPING_EVENING: 0.25, CONF_INVERTER_SIZE: 2000, }, ) @@ -159,6 +164,7 @@ async def test_options_flow_without_key( CONF_DECLINATION: 21, CONF_AZIMUTH: 22, CONF_MODULES_POWER: 2122, - CONF_DAMPING: 0.25, + CONF_DAMPING_MORNING: 0.25, + CONF_DAMPING_EVENING: 0.25, CONF_INVERTER_SIZE: 2000, } diff --git a/tests/components/forecast_solar/test_diagnostics.py b/tests/components/forecast_solar/test_diagnostics.py index 4900c3bdb32..e72f2d7d9dc 100644 --- a/tests/components/forecast_solar/test_diagnostics.py +++ b/tests/components/forecast_solar/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the Forecast.Solar integration.""" +from syrupy import SnapshotAssertion -from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -12,48 +12,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": { - "title": "Green House", - "data": { - "latitude": REDACTED, - "longitude": REDACTED, - }, - "options": { - "api_key": REDACTED, - "declination": 30, - "azimuth": 190, - "modules power": 5100, - "damping": 0.5, - "inverter_size": 2000, - }, - }, - "data": { - "energy_production_today": 100000, - "energy_production_today_remaining": 50000, - "energy_production_tomorrow": 200000, - "energy_current_hour": 800000, - "power_production_now": 300000, - "watts": { - "2021-06-27T13:00:00-07:00": 10, - "2022-06-27T13:00:00-07:00": 100, - }, - "wh_days": { - "2021-06-27T13:00:00-07:00": 20, - "2022-06-27T13:00:00-07:00": 200, - }, - "wh_period": { - "2021-06-27T13:00:00-07:00": 30, - "2022-06-27T13:00:00-07:00": 300, - }, - }, - "account": { - "type": "public", - "rate_limit": 60, - "timezone": "Europe/Amsterdam", - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/forecast_solar/test_energy.py b/tests/components/forecast_solar/test_energy.py index 3ca89d33faa..7d3a853b8a7 100644 --- a/tests/components/forecast_solar/test_energy.py +++ b/tests/components/forecast_solar/test_energy.py @@ -1,5 +1,5 @@ """Test forecast solar energy platform.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import MagicMock from homeassistant.components.forecast_solar import energy @@ -16,8 +16,8 @@ async def test_energy_solar_forecast( ) -> None: """Test the Forecast.Solar energy platform solar forecast.""" mock_forecast_solar.estimate.return_value.wh_period = { - datetime(2021, 6, 27, 13, 0, tzinfo=timezone.utc): 12, - datetime(2021, 6, 27, 14, 0, tzinfo=timezone.utc): 8, + datetime(2021, 6, 27, 13, 0, tzinfo=UTC): 12, + datetime(2021, 6, 27, 14, 0, tzinfo=UTC): 8, } mock_config_entry.add_to_hass(hass) diff --git a/tests/components/forecast_solar/test_init.py b/tests/components/forecast_solar/test_init.py index a7696fe8f53..25dcb41c976 100644 --- a/tests/components/forecast_solar/test_init.py +++ b/tests/components/forecast_solar/test_init.py @@ -2,9 +2,17 @@ from unittest.mock import MagicMock, patch from forecast_solar import ForecastSolarConnectionError +from syrupy import SnapshotAssertion -from homeassistant.components.forecast_solar.const import DOMAIN +from homeassistant.components.forecast_solar.const import ( + CONF_AZIMUTH, + CONF_DAMPING, + CONF_DECLINATION, + CONF_INVERTER_SIZE, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -44,3 +52,29 @@ async def test_config_entry_not_ready( assert mock_request.call_count == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_migration(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test config entry version 1 -> 2 migration.""" + mock_config_entry = MockConfigEntry( + title="Green House", + unique_id="unique", + domain=DOMAIN, + data={ + CONF_LATITUDE: 52.42, + CONF_LONGITUDE: 4.42, + }, + options={ + CONF_API_KEY: "abcdef12345", + CONF_DECLINATION: 30, + CONF_AZIMUTH: 190, + "modules power": 5100, + CONF_DAMPING: 0.5, + CONF_INVERTER_SIZE: 2000, + }, + ) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.config_entries.async_get_entry(mock_config_entry.entry_id) == snapshot diff --git a/tests/components/forked_daapd/test_browse_media.py b/tests/components/forked_daapd/test_browse_media.py index 3d540c1f5af..4d15f083591 100644 --- a/tests/components/forked_daapd/test_browse_media.py +++ b/tests/components/forked_daapd/test_browse_media.py @@ -35,6 +35,7 @@ async def test_async_browse_media( "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", autospec=True, ) as mock_api: + mock_api.return_value.get_request.return_value = {"websocket_port": 2} config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -212,6 +213,7 @@ async def test_async_browse_media_not_found( "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", autospec=True, ) as mock_api: + mock_api.return_value.get_request.return_value = {"websocket_port": 2} config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -366,6 +368,7 @@ async def test_async_browse_image( "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", autospec=True, ) as mock_api: + mock_api.return_value.get_request.return_value = {"websocket_port": 2} config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -421,6 +424,7 @@ async def test_async_browse_image_missing( "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", autospec=True, ) as mock_api: + mock_api.return_value.get_request.return_value = {"websocket_port": 2} 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/gardena_bluetooth/snapshots/test_config_flow.ambr b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr index fde70b60a01..31925e2d626 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr @@ -3,12 +3,13 @@ FlowResultSnapshot({ 'data_schema': None, 'description_placeholders': dict({ - 'name': 'Timer', + 'name': 'Gardena Water Computer', }), 'errors': None, 'flow_id': , 'handler': 'gardena_bluetooth', 'last_step': None, + 'preview': None, 'step_id': 'confirm', 'type': , }) @@ -19,7 +20,7 @@ 'confirm_only': True, 'source': 'bluetooth', 'title_placeholders': dict({ - 'name': 'Timer', + 'name': 'Gardena Water Computer', }), 'unique_id': '00000000-0000-0000-0000-000000000001', }), @@ -44,11 +45,11 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'bluetooth', - 'title': 'Timer', + 'title': 'Gardena Water Computer', 'unique_id': '00000000-0000-0000-0000-000000000001', 'version': 1, }), - 'title': 'Timer', + 'title': 'Gardena Water Computer', 'type': , 'version': 1, }) @@ -124,7 +125,7 @@ 'options': list([ tuple( '00000000-0000-0000-0000-000000000001', - 'Timer', + 'Gardena Water Computer', ), ]), 'required': True, @@ -136,6 +137,7 @@ 'flow_id': , 'handler': 'gardena_bluetooth', 'last_step': None, + 'preview': None, 'step_id': 'user', 'type': , }) @@ -144,12 +146,13 @@ FlowResultSnapshot({ 'data_schema': None, 'description_placeholders': dict({ - 'name': 'Timer', + 'name': 'Gardena Water Computer', }), 'errors': None, 'flow_id': , 'handler': 'gardena_bluetooth', 'last_step': None, + 'preview': None, 'step_id': 'confirm', 'type': , }) @@ -182,11 +185,11 @@ 'options': list([ tuple( '00000000-0000-0000-0000-000000000001', - 'Timer', + 'Gardena Water Computer', ), tuple( '00000000-0000-0000-0000-000000000002', - 'Gardena Device', + 'Gardena Water Computer', ), ]), 'required': True, @@ -198,6 +201,7 @@ 'flow_id': , 'handler': 'gardena_bluetooth', 'last_step': None, + 'preview': None, 'step_id': 'user', 'type': , }) @@ -206,12 +210,13 @@ FlowResultSnapshot({ 'data_schema': None, 'description_placeholders': dict({ - 'name': 'Timer', + 'name': 'Gardena Water Computer', }), 'errors': None, 'flow_id': , 'handler': 'gardena_bluetooth', 'last_step': None, + 'preview': None, 'step_id': 'confirm', 'type': , }) @@ -222,7 +227,7 @@ 'confirm_only': True, 'source': 'user', 'title_placeholders': dict({ - 'name': 'Timer', + 'name': 'Gardena Water Computer', }), 'unique_id': '00000000-0000-0000-0000-000000000001', }), @@ -247,11 +252,11 @@ 'pref_disable_new_entities': False, 'pref_disable_polling': False, 'source': 'user', - 'title': 'Timer', + 'title': 'Gardena Water Computer', 'unique_id': '00000000-0000-0000-0000-000000000001', 'version': 1, }), - 'title': 'Timer', + 'title': 'Gardena Water Computer', 'type': , 'version': 1, }) diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index b7dc880ede0..44dc40f5a47 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -1,5 +1,5 @@ """Test Google http services.""" -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from http import HTTPStatus from typing import Any from unittest.mock import ANY, patch @@ -51,7 +51,7 @@ async def test_get_jwt(hass: HomeAssistant) -> None: jwt = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkdW1teUBkdW1teS5pYW0uZ3NlcnZpY2VhY2NvdW50LmNvbSIsInNjb3BlIjoiaHR0cHM6Ly93d3cuZ29vZ2xlYXBpcy5jb20vYXV0aC9ob21lZ3JhcGgiLCJhdWQiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20vby9vYXV0aDIvdG9rZW4iLCJpYXQiOjE1NzEwMTEyMDAsImV4cCI6MTU3MTAxNDgwMH0.akHbMhOflXdIDHVvUVwO0AoJONVOPUdCghN6hAdVz4gxjarrQeGYc_Qn2r84bEvCU7t6EvimKKr0fyupyzBAzfvKULs5mTHO3h2CwSgvOBMv8LnILboJmbO4JcgdnRV7d9G3ktQs7wWSCXJsI5i5jUr1Wfi9zWwxn2ebaAAgrp8" res = _get_homegraph_jwt( - datetime(2019, 10, 14, tzinfo=timezone.utc), + datetime(2019, 10, 14, tzinfo=UTC), DUMMY_CONFIG["service_account"]["client_email"], DUMMY_CONFIG["service_account"]["private_key"], ) @@ -85,7 +85,7 @@ async def test_update_access_token(hass: HomeAssistant) -> None: config = GoogleConfig(hass, DUMMY_CONFIG) await config.async_initialize() - base_time = datetime(2019, 10, 14, tzinfo=timezone.utc) + base_time = datetime(2019, 10, 14, tzinfo=UTC) with patch( "homeassistant.components.google_assistant.http._get_homegraph_token" ) as mock_get_token, patch( diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 8202601fc18..ce4bad2ac8a 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -10,6 +10,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator @pytest.mark.parametrize( @@ -446,3 +447,290 @@ async def test_options_flow_hides_members( assert registry.async_get(f"{group_type}.one").hidden_by == hidden_by assert registry.async_get(f"{group_type}.three").hidden_by == hidden_by + + +async def test_config_flow_binary_sensor_preview( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + input_entities = ["binary_sensor.input_one", "binary_sensor.input_two"] + + 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": "binary_sensor"}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "binary_sensor" + assert result["errors"] is None + assert result["preview"] == "group_binary_sensor" + + await client.send_json_auto_id( + { + "type": "group/binary_sensor/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": { + "name": "My binary sensor group", + "entities": input_entities, + "all": True, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": {"friendly_name": "My binary sensor group"}, + "state": "unavailable", + } + + hass.states.async_set("binary_sensor.input_one", "on") + hass.states.async_set("binary_sensor.input_two", "off") + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": { + "entity_id": ["binary_sensor.input_one", "binary_sensor.input_two"], + "friendly_name": "My binary sensor group", + }, + "state": "off", + } + + +async def test_option_flow_binary_sensor_preview( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the option flow preview.""" + client = await hass_ws_client(hass) + + input_entities = ["binary_sensor.input_one", "binary_sensor.input_two"] + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "all": True, + "entities": input_entities, + "group_type": "binary_sensor", + "hide_members": False, + "name": "My group", + }, + title="My min_max", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "group_binary_sensor" + + hass.states.async_set("binary_sensor.input_one", "on") + hass.states.async_set("binary_sensor.input_two", "off") + + await client.send_json_auto_id( + { + "type": "group/binary_sensor/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": { + "entities": input_entities, + "all": False, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": { + "entity_id": input_entities, + "friendly_name": "My group", + }, + "state": "on", + } + + +async def test_config_flow_sensor_preview( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + input_entities = ["sensor.input_one", "sensor.input_two"] + + 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" + assert result["errors"] is None + assert result["preview"] == "group_sensor" + + await client.send_json_auto_id( + { + "type": "group/sensor/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": { + "name": "My sensor group", + "entities": input_entities, + "type": "max", + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": { + "friendly_name": "My sensor group", + "icon": "mdi:calculator", + }, + "state": "unavailable", + } + + hass.states.async_set("sensor.input_one", "10") + hass.states.async_set("sensor.input_two", "20") + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": { + "entity_id": input_entities, + "friendly_name": "My sensor group", + "icon": "mdi:calculator", + "max_entity_id": "sensor.input_two", + }, + "state": "20.0", + } + + +async def test_option_flow_sensor_preview( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the option flow preview.""" + client = await hass_ws_client(hass) + + input_entities = ["sensor.input_one", "sensor.input_two"] + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entities": input_entities, + "group_type": "sensor", + "hide_members": False, + "name": "My sensor group", + "type": "min", + }, + title="My min_max", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "group_sensor" + + hass.states.async_set("sensor.input_one", "10") + hass.states.async_set("sensor.input_two", "20") + + await client.send_json_auto_id( + { + "type": "group/sensor/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": { + "entities": input_entities, + "type": "min", + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"] == { + "attributes": { + "entity_id": input_entities, + "friendly_name": "My sensor group", + "icon": "mdi:calculator", + "min_entity_id": "sensor.input_one", + }, + "state": "10.0", + } + + +async def test_option_flow_sensor_preview_config_entry_removed( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the option flow preview where the config entry is removed.""" + client = await hass_ws_client(hass) + + input_entities = ["sensor.input_one", "sensor.input_two"] + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entities": input_entities, + "group_type": "sensor", + "hide_members": False, + "name": "My sensor group", + "type": "min", + }, + title="My min_max", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "group_sensor" + + await hass.config_entries.async_remove(config_entry.entry_id) + + await client.send_json_auto_id( + { + "type": "group/sensor/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": { + "entities": input_entities, + "type": "min", + }, + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == {"code": "unknown_error", "message": "Unknown error"} diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 237c20a5272..21bf7e5b47a 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -84,6 +84,7 @@ async def test_supervisor_issue_repair_flow( "errors": None, "description_placeholders": {"reference": "/dev/sda1"}, "last_step": True, + "preview": None, } resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") @@ -292,6 +293,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confir "errors": None, "description_placeholders": None, "last_step": True, + "preview": None, } resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") @@ -371,6 +373,7 @@ async def test_supervisor_issue_repair_flow_skip_confirmation( "errors": None, "description_placeholders": None, "last_step": True, + "preview": None, } resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") @@ -580,6 +583,7 @@ async def test_supervisor_issue_docker_config_repair_flow( "errors": None, "description_placeholders": {"components": "Home Assistant\n- test"}, "last_step": True, + "preview": None, } resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") diff --git a/tests/components/home_plus_control/test_switch.py b/tests/components/home_plus_control/test_switch.py index ead1f83cb94..d41977d57a9 100644 --- a/tests/components/home_plus_control/test_switch.py +++ b/tests/components/home_plus_control/test_switch.py @@ -143,7 +143,7 @@ async def test_plant_topology_reduction_change( return_value=mock_modules, ) as mock_check: async_fire_time_changed( - hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=400) + hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) ) await hass.async_block_till_done() assert len(mock_check.mock_calls) == 1 @@ -205,7 +205,7 @@ async def test_plant_topology_increase_change( return_value=mock_modules, ) as mock_check: async_fire_time_changed( - hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=400) + hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) ) await hass.async_block_till_done() assert len(mock_check.mock_calls) == 1 @@ -267,7 +267,7 @@ async def test_module_status_unavailable( return_value=mock_modules, ) as mock_check: async_fire_time_changed( - hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=400) + hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) ) await hass.async_block_till_done() assert len(mock_check.mock_calls) == 1 @@ -338,7 +338,7 @@ async def test_module_status_available( return_value=mock_modules, ) as mock_check: async_fire_time_changed( - hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=400) + hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) ) await hass.async_block_till_done() assert len(mock_check.mock_calls) == 1 @@ -442,7 +442,7 @@ async def test_update_with_api_error( side_effect=HomePlusControlApiError, ) as mock_check: async_fire_time_changed( - hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=400) + hass, dt.datetime.now(dt.UTC) + dt.timedelta(seconds=400) ) await hass.async_block_till_done() assert len(mock_check.mock_calls) == 1 diff --git a/tests/components/homekit_controller/fixtures/homespan_daikin_bridge.json b/tests/components/homekit_controller/fixtures/homespan_daikin_bridge.json new file mode 100644 index 00000000000..b3dd6f8a84e --- /dev/null +++ b/tests/components/homekit_controller/fixtures/homespan_daikin_bridge.json @@ -0,0 +1,161 @@ +[ + { + "aid": 1, + "services": [ + { + "iid": 1, + "type": "0000003E-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "00000014-0000-1000-8000-0026BB765291", + "iid": 2, + "perms": ["pw"], + "format": "bool", + "description": "Identify" + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "iid": 3, + "perms": ["pr", "ev"], + "format": "string", + "value": "1.0.0", + "description": "Firmware Revision", + "maxLen": 64 + }, + { + "type": "00000053-0000-1000-8000-0026BB765291", + "iid": 4, + "perms": ["pr"], + "format": "string", + "value": "1.0.0", + "description": "Hardware Revision", + "maxLen": 64 + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "iid": 5, + "perms": ["pr"], + "format": "string", + "value": "Garzola Marco", + "description": "Manufacturer", + "maxLen": 64 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "iid": 6, + "perms": ["pr"], + "format": "string", + "value": "Daikin-fwec3a-esp32-homekit-bridge", + "description": "Model", + "maxLen": 64 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 7, + "perms": ["pr"], + "format": "string", + "value": "Air Conditioner", + "description": "Name", + "maxLen": 64 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "iid": 8, + "perms": ["pr"], + "format": "string", + "value": "00000001", + "description": "Serial Number", + "maxLen": 64 + } + ] + }, + { + "iid": 9, + "type": "000000BC-0000-1000-8000-0026BB765291", + "characteristics": [ + { + "type": "000000B0-0000-1000-8000-0026BB765291", + "iid": 10, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 1, + "description": "Active" + }, + { + "type": "00000011-0000-1000-8000-0026BB765291", + "iid": 11, + "perms": ["pr", "ev"], + "format": "float", + "value": 27.9, + "description": "Current Temperature", + "unit": "celsius", + "minValue": 0, + "maxValue": 99, + "minStep": 0.5 + }, + { + "type": "000000B1-0000-1000-8000-0026BB765291", + "iid": 12, + "perms": ["pr", "ev"], + "format": "uint8", + "value": 3, + "description": "Current Heater Cooler State" + }, + { + "type": "000000B2-0000-1000-8000-0026BB765291", + "iid": 13, + "perms": ["pr", "pw", "ev"], + "format": "uint8", + "value": 2, + "description": "Target Heater Cooler State" + }, + { + "type": "0000000D-0000-1000-8000-0026BB765291", + "iid": 14, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 24.5, + "description": "Cooling Threshold Temperature", + "unit": "celsius", + "minValue": 18, + "maxValue": 32, + "minStep": 0.5 + }, + { + "type": "00000012-0000-1000-8000-0026BB765291", + "iid": 15, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 24.5, + "description": "Heating Threshold Temperature", + "unit": "celsius", + "minValue": 13, + "maxValue": 27, + "minStep": 0.5 + }, + { + "type": "00000029-0000-1000-8000-0026BB765291", + "iid": 16, + "perms": ["pr", "pw", "ev"], + "format": "float", + "value": 100, + "description": "Rotation Speed", + "unit": "percentage", + "minValue": 0, + "maxValue": 100, + "minStep": 1 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "iid": 17, + "perms": ["pr"], + "format": "string", + "value": "SlaveID 1", + "description": "Name", + "maxLen": 64 + } + ] + } + ] + } +] diff --git a/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py b/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py new file mode 100644 index 00000000000..5bb7003e58b --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_homespan_daikin_bridge.py @@ -0,0 +1,51 @@ +"""Tests for handling accessories on a Homespan esp32 daikin bridge.""" +from homeassistant.components.climate import ClimateEntityFeature +from homeassistant.core import HomeAssistant + +from ..common import ( + HUB_TEST_ACCESSORY_ID, + DeviceTestInfo, + EntityTestInfo, + assert_devices_and_entities_created, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_homespan_daikin_bridge_setup(hass: HomeAssistant) -> None: + """Test that aHomespan esp32 daikin bridge can be correctly setup in HA via HomeKit.""" + accessories = await setup_accessories_from_file(hass, "homespan_daikin_bridge.json") + await setup_test_accessories(hass, accessories) + + await assert_devices_and_entities_created( + hass, + DeviceTestInfo( + unique_id=HUB_TEST_ACCESSORY_ID, + name="Air Conditioner", + model="Daikin-fwec3a-esp32-homekit-bridge", + manufacturer="Garzola Marco", + sw_version="1.0.0", + hw_version="1.0.0", + serial_number="00000001", + devices=[], + entities=[ + EntityTestInfo( + entity_id="climate.air_conditioner_slaveid_1", + friendly_name="Air Conditioner SlaveID 1", + unique_id="00:00:00:00:00:00_1_9", + supported_features=( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + ), + capabilities={ + "hvac_modes": ["heat_cool", "heat", "cool", "off"], + "min_temp": 18, + "max_temp": 32, + "target_temp_step": 0.5, + "fan_modes": ["off", "low", "medium", "high"], + }, + state="cool", + ), + ], + ), + ) diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index 27c675b78ec..0f6a3633bd4 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -691,6 +691,9 @@ def create_heater_cooler_service(accessory): char = service.add_char(CharacteristicsTypes.SWING_MODE) char.value = 0 + char = service.add_char(CharacteristicsTypes.ROTATION_SPEED) + char.value = 100 + # Test heater-cooler devices def create_heater_cooler_service_min_max(accessory): @@ -867,6 +870,103 @@ async def test_heater_cooler_change_thermostat_temperature( ) +async def test_heater_cooler_change_fan_speed(hass: HomeAssistant, utcnow) -> None: + """Test that we can change the target fan speed.""" + helper = await setup_test_component(hass, create_heater_cooler_service) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.COOL}, + blocking=True, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + {"entity_id": "climate.testdevice", "fan_mode": "low"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 33, + }, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + {"entity_id": "climate.testdevice", "fan_mode": "medium"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 66, + }, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + {"entity_id": "climate.testdevice", "fan_mode": "high"}, + blocking=True, + ) + helper.async_assert_service_values( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 100, + }, + ) + + +async def test_heater_cooler_read_fan_speed(hass: HomeAssistant, utcnow) -> None: + """Test that we can read the state of a HomeKit thermostat accessory.""" + helper = await setup_test_component(hass, create_heater_cooler_service) + + # Simulate that fan speed is off + await helper.async_update( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 0, + }, + ) + + state = await helper.poll_and_get_state() + assert state.attributes["fan_mode"] == "off" + + # Simulate that fan speed is low + await helper.async_update( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 33, + }, + ) + + state = await helper.poll_and_get_state() + assert state.attributes["fan_mode"] == "low" + + # Simulate that fan speed is medium + await helper.async_update( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 66, + }, + ) + + state = await helper.poll_and_get_state() + assert state.attributes["fan_mode"] == "medium" + + # Simulate that fan speed is high + await helper.async_update( + ServicesTypes.HEATER_COOLER, + { + CharacteristicsTypes.ROTATION_SPEED: 100, + }, + ) + + state = await helper.poll_and_get_state() + assert state.attributes["fan_mode"] == "high" + + async def test_heater_cooler_read_thermostat_state(hass: HomeAssistant, utcnow) -> None: """Test that we can read the state of a HomeKit thermostat accessory.""" helper = await setup_test_component(hass, create_heater_cooler_service) diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index b9512da0278..b4ee11ba787 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -470,6 +470,8 @@ async def test_reset_last_message( ) -> None: """Test receiving a message successfully.""" event = asyncio.Event() # needed for pushed coordinator to make a new loop + idle_start_future = asyncio.Future() + idle_start_future.set_result(None) async def _sleep_till_event() -> None: """Simulate imap server waiting for pushes message and keep the push loop going. @@ -479,10 +481,10 @@ async def test_reset_last_message( nonlocal event await event.wait() event.clear() - mock_imap_protocol.idle_start.return_value = AsyncMock()() + mock_imap_protocol.idle_start = AsyncMock(return_value=idle_start_future) # Make sure we make another cycle (needed for pushed coordinator) - mock_imap_protocol.idle_start.return_value = AsyncMock()() + mock_imap_protocol.idle_start = AsyncMock(return_value=idle_start_future) # Mock we wait till we push an update (needed for pushed coordinator) mock_imap_protocol.wait_server_push.side_effect = _sleep_till_event diff --git a/tests/components/ipma/__init__.py b/tests/components/ipma/__init__.py index ba172fc7bb8..827481c60de 100644 --- a/tests/components/ipma/__init__.py +++ b/tests/components/ipma/__init__.py @@ -1,6 +1,6 @@ """Tests for the IPMA component.""" from collections import namedtuple -from datetime import datetime, timezone +from datetime import UTC, datetime from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME @@ -87,7 +87,7 @@ class MockLocation: return [ Forecast( "7.7", - datetime(2020, 1, 15, 1, 0, 0, tzinfo=timezone.utc), + datetime(2020, 1, 15, 1, 0, 0, tzinfo=UTC), 1, "86.9", 12.0, @@ -101,7 +101,7 @@ class MockLocation: ), Forecast( "5.7", - datetime(2020, 1, 15, 2, 0, 0, tzinfo=timezone.utc), + datetime(2020, 1, 15, 2, 0, 0, tzinfo=UTC), 1, "86.9", 12.0, diff --git a/tests/components/ipma/test_config_flow.py b/tests/components/ipma/test_config_flow.py index 18b68a5a44d..aff8af16bc3 100644 --- a/tests/components/ipma/test_config_flow.py +++ b/tests/components/ipma/test_config_flow.py @@ -1,12 +1,16 @@ """Tests for IPMA config flow.""" from unittest.mock import patch +from pyipma import IPMAException import pytest -from homeassistant import config_entries, data_entry_flow from homeassistant.components.ipma.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.components.ipma import MockLocation @pytest.fixture(name="ipma_setup", autouse=True) @@ -19,7 +23,7 @@ def ipma_setup_fixture(request): async def test_config_flow(hass: HomeAssistant) -> None: """Test configuration form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] == "form" @@ -29,16 +33,59 @@ async def test_config_flow(hass: HomeAssistant) -> None: CONF_LONGITUDE: 0, CONF_LATITUDE: 0, } + with patch( + "pyipma.location.Location.get", + return_value=MockLocation(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + test_data, + ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - test_data, + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "HomeTown" + assert result["data"] == { + CONF_LONGITUDE: 0, + CONF_LATITUDE: 0, + } + + +async def test_config_flow_failures(hass: HomeAssistant) -> None: + """Test config flow with failures.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "Home" + assert result["type"] == "form" + assert result["step_id"] == "user" + + test_data = { + CONF_LONGITUDE: 0, + CONF_LATITUDE: 0, + } + with patch( + "pyipma.location.Location.get", + side_effect=IPMAException(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + test_data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + with patch( + "pyipma.location.Location.get", + return_value=MockLocation(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + test_data, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "HomeTown" assert result["data"] == { - CONF_NAME: "Home", CONF_LONGITUDE: 0, CONF_LATITUDE: 0, } @@ -57,7 +104,7 @@ async def test_flow_entry_already_exists(hass: HomeAssistant, config_entry) -> N } result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data + DOMAIN, context={"source": SOURCE_USER}, data=test_data ) await hass.async_block_till_done() diff --git a/tests/components/ipp/test_sensor.py b/tests/components/ipp/test_sensor.py index ebebd18bc72..5992b928f63 100644 --- a/tests/components/ipp/test_sensor.py +++ b/tests/components/ipp/test_sensor.py @@ -4,7 +4,12 @@ from unittest.mock import AsyncMock import pytest from homeassistant.components.sensor import ATTR_OPTIONS -from homeassistant.const import ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE +from homeassistant.const import ( + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + EntityCategory, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -66,8 +71,10 @@ async def test_sensors( assert state.state == "2019-11-11T09:10:02+00:00" entry = entity_registry.async_get("sensor.test_ha_1000_series_uptime") + assert entry assert entry.unique_id == "cfe92100-67c4-11d4-a45f-f8d027761251_uptime" + assert entry.entity_category == EntityCategory.DIAGNOSTIC async def test_disabled_by_default_sensors( diff --git a/tests/components/jellyfin/snapshots/test_diagnostics.ambr b/tests/components/jellyfin/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c992628f034 --- /dev/null +++ b/tests/components/jellyfin/snapshots/test_diagnostics.ambr @@ -0,0 +1,1788 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'entry': dict({ + 'data': dict({ + 'client_device_id': 'entry-id', + 'password': '**REDACTED**', + 'url': 'https://example.com', + 'username': 'test-username', + }), + 'title': 'Jellyfin', + }), + 'server': dict({ + 'id': 'SERVER-UUID', + 'name': 'JELLYFIN-SERVER', + 'version': None, + }), + 'sessions': list([ + dict({ + 'capabilities': dict({ + 'AppStoreUrl': 'string', + 'DeviceProfile': dict({ + 'AlbumArtPn': 'string', + 'CodecProfiles': list([ + dict({ + 'ApplyConditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Codec': 'string', + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Type': 'Video', + }), + ]), + 'ContainerProfiles': list([ + dict({ + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Type': 'Audio', + }), + ]), + 'DirectPlayProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'Container': 'string', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'EnableAlbumArtInDidl': False, + 'EnableMSMediaReceiverRegistrar': False, + 'EnableSingleAlbumArtLimit': False, + 'EnableSingleSubtitleLimit': False, + 'FriendlyName': 'string', + 'Id': 'string', + 'Identification': dict({ + 'FriendlyName': 'string', + 'Headers': list([ + dict({ + 'Match': 'Equals', + 'Name': 'string', + 'Value': 'string', + }), + ]), + 'Manufacturer': 'string', + 'ManufacturerUrl': 'string', + 'ModelDescription': 'string', + 'ModelName': 'string', + 'ModelNumber': 'string', + 'ModelUrl': 'string', + 'SerialNumber': 'string', + }), + 'IgnoreTranscodeByteRangeRequests': False, + 'Manufacturer': 'string', + 'ManufacturerUrl': 'string', + 'MaxAlbumArtHeight': 0, + 'MaxAlbumArtWidth': 0, + 'MaxIconHeight': 0, + 'MaxIconWidth': 0, + 'MaxStaticBitrate': 0, + 'MaxStaticMusicBitrate': 0, + 'MaxStreamingBitrate': 0, + 'ModelDescription': 'string', + 'ModelName': 'string', + 'ModelNumber': 'string', + 'ModelUrl': 'string', + 'MusicStreamingTranscodingBitrate': 0, + 'Name': 'string', + 'ProtocolInfo': 'string', + 'RequiresPlainFolders': False, + 'RequiresPlainVideoItems': False, + 'ResponseProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'MimeType': 'string', + 'OrgPn': 'string', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'SerialNumber': 'string', + 'SonyAggregationFlags': 'string', + 'SubtitleProfiles': list([ + dict({ + 'Container': 'string', + 'DidlMode': 'string', + 'Format': 'string', + 'Language': 'string', + 'Method': 'Encode', + }), + ]), + 'SupportedMediaTypes': 'string', + 'TimelineOffsetSeconds': 0, + 'TranscodingProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'BreakOnNonKeyFrames': False, + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Context': 'Streaming', + 'CopyTimestamps': False, + 'EnableMpegtsM2TsMode': False, + 'EnableSubtitlesInManifest': False, + 'EstimateContentLength': False, + 'MaxAudioChannels': 'string', + 'MinSegments': 0, + 'Protocol': 'string', + 'SegmentLength': 0, + 'TranscodeSeekInfo': 'Auto', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'UserId': 'string', + 'XmlRootAttributes': list([ + dict({ + 'Name': 'string', + 'Value': 'string', + }), + ]), + }), + 'IconUrl': 'string', + 'MessageCallbackUrl': 'string', + 'PlayableMediaTypes': list([ + 'Video', + ]), + 'SupportedCommands': list([ + 'VolumeSet', + 'Mute', + ]), + 'SupportsContentUploading': True, + 'SupportsMediaControl': True, + 'SupportsPersistentIdentifier': True, + 'SupportsSync': True, + }), + 'client_name': 'Jellyfin for Developers', + 'client_version': '1.0.0', + 'device_id': 'DEVICE-UUID', + 'device_name': 'JELLYFIN-DEVICE', + 'id': 'SESSION-UUID', + 'now_playing': dict({ + 'AirDays': list([ + 'Sunday', + ]), + 'AirTime': 'string', + 'AirsAfterSeasonNumber': 0, + 'AirsBeforeEpisodeNumber': 0, + 'AirsBeforeSeasonNumber': 0, + 'Album': 'string', + 'AlbumArtist': 'string', + 'AlbumArtists': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'Name': 'string', + }), + ]), + 'AlbumCount': 0, + 'AlbumId': '21af9851-8e39-43a9-9c47-513d3b9e99fc', + 'AlbumPrimaryImageTag': 'string', + 'Altitude': 0, + 'Aperture': 0, + 'ArtistCount': 0, + 'ArtistItems': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'Name': 'string', + }), + ]), + 'Artists': list([ + 'string', + ]), + 'AspectRatio': 'string', + 'Audio': 'Mono', + 'BackdropImageTags': list([ + 'string', + ]), + 'CameraMake': 'string', + 'CameraModel': 'string', + 'CanDelete': True, + 'CanDownload': True, + 'ChannelId': '04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff', + 'ChannelName': 'string', + 'ChannelNumber': 'string', + 'ChannelPrimaryImageTag': 'string', + 'ChannelType': 'TV', + 'Chapters': list([ + dict({ + 'ImageDateModified': '2019-08-24T14:15:22Z', + 'ImagePath': 'string', + 'ImageTag': 'string', + 'Name': 'string', + 'StartPositionTicks': 0, + }), + ]), + 'ChildCount': 0, + 'CollectionType': 'string', + 'CommunityRating': 0, + 'CompletionPercentage': 0, + 'Container': 'string', + 'CriticRating': 0, + 'CumulativeRunTimeTicks': 0, + 'CurrentProgram': dict({ + }), + 'CustomRating': 'string', + 'DateCreated': '2019-08-24T14:15:22Z', + 'DateLastMediaAdded': '2019-08-24T14:15:22Z', + 'DisplayOrder': 'string', + 'DisplayPreferencesId': 'string', + 'EnableMediaSourceDisplay': True, + 'EndDate': '2019-08-24T14:15:22Z', + 'EpisodeCount': 0, + 'EpisodeTitle': 'string', + 'Etag': 'string', + 'ExposureTime': 0, + 'ExternalUrls': list([ + dict({ + 'Name': 'string', + 'Url': 'string', + }), + ]), + 'ExtraType': 'string', + 'FocalLength': 0, + 'ForcedSortName': 'string', + 'GenreItems': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'Name': 'string', + }), + ]), + 'Genres': list([ + 'string', + ]), + 'HasSubtitles': True, + 'Height': 0, + 'Id': 'EPISODE-UUID', + 'ImageBlurHashes': dict({ + 'Art': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Backdrop': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Banner': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Box': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'BoxRear': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Chapter': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Disc': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Logo': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Menu': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Primary': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Profile': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Screenshot': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Thumb': dict({ + 'property1': 'string', + 'property2': 'string', + }), + }), + 'ImageOrientation': 'TopLeft', + 'ImageTags': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'IndexNumber': 3, + 'IndexNumberEnd': 0, + 'IsFolder': False, + 'IsHD': True, + 'IsKids': True, + 'IsLive': True, + 'IsMovie': True, + 'IsNews': True, + 'IsPlaceHolder': True, + 'IsPremiere': True, + 'IsRepeat': True, + 'IsSeries': True, + 'IsSports': True, + 'IsoSpeedRating': 0, + 'IsoType': 'Dvd', + 'Latitude': 0, + 'LocalTrailerCount': 0, + 'LocationType': 'FileSystem', + 'LockData': True, + 'LockedFields': list([ + 'Cast', + ]), + 'Longitude': 0, + 'MediaSourceCount': 0, + 'MediaSources': list([ + dict({ + 'AnalyzeDurationMs': 0, + 'Bitrate': 0, + 'BufferMs': 0, + 'Container': 'string', + 'DefaultAudioStreamIndex': 0, + 'DefaultSubtitleStreamIndex': 0, + 'ETag': 'string', + 'EncoderPath': 'string', + 'EncoderProtocol': 'File', + 'Formats': list([ + 'string', + ]), + 'GenPtsInput': True, + 'Id': 'string', + 'IgnoreDts': True, + 'IgnoreIndex': True, + 'IsInfiniteStream': True, + 'IsRemote': True, + 'IsoType': 'Dvd', + 'LiveStreamId': 'string', + 'MediaAttachments': list([ + dict({ + 'Codec': 'string', + 'CodecTag': 'string', + 'Comment': 'string', + 'DeliveryUrl': 'string', + 'FileName': 'string', + 'Index': 0, + 'MimeType': 'string', + }), + ]), + 'MediaStreams': list([ + dict({ + 'AspectRatio': 'string', + 'AverageFrameRate': 0, + 'BitDepth': 0, + 'BitRate': 0, + 'BlPresentFlag': 0, + 'ChannelLayout': 'string', + 'Channels': 0, + 'Codec': 'string', + 'CodecTag': 'string', + 'CodecTimeBase': 'string', + 'ColorPrimaries': 'string', + 'ColorRange': 'string', + 'ColorSpace': 'string', + 'ColorTransfer': 'string', + 'Comment': 'string', + 'DeliveryMethod': 'Encode', + 'DeliveryUrl': 'string', + 'DisplayTitle': 'string', + 'DvBlSignalCompatibilityId': 0, + 'DvLevel': 0, + 'DvProfile': 0, + 'DvVersionMajor': 0, + 'DvVersionMinor': 0, + 'ElPresentFlag': 0, + 'Height': 0, + 'Index': 0, + 'IsAVC': True, + 'IsAnamorphic': True, + 'IsDefault': True, + 'IsExternal': True, + 'IsExternalUrl': True, + 'IsForced': True, + 'IsInterlaced': True, + 'IsTextSubtitleStream': True, + 'Language': 'string', + 'Level': 0, + 'LocalizedDefault': 'string', + 'LocalizedExternal': 'string', + 'LocalizedForced': 'string', + 'LocalizedUndefined': 'string', + 'NalLengthSize': 'string', + 'PacketLength': 0, + 'Path': 'string', + 'PixelFormat': 'string', + 'Profile': 'string', + 'RealFrameRate': 0, + 'RefFrames': 0, + 'RpuPresentFlag': 0, + 'SampleRate': 0, + 'Score': 0, + 'SupportsExternalStream': True, + 'TimeBase': 'string', + 'Title': 'string', + 'Type': 'Audio', + 'VideoDoViTitle': 'string', + 'VideoRange': 'string', + 'VideoRangeType': 'string', + 'Width': 0, + }), + ]), + 'Name': 'string', + 'OpenToken': 'string', + 'Path': 'string', + 'Protocol': 'File', + 'ReadAtNativeFramerate': True, + 'RequiredHttpHeaders': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'RequiresClosing': True, + 'RequiresLooping': True, + 'RequiresOpening': True, + 'RunTimeTicks': 0, + 'Size': 0, + 'SupportsDirectPlay': True, + 'SupportsDirectStream': True, + 'SupportsProbing': True, + 'SupportsTranscoding': True, + 'Timestamp': 'None', + 'TranscodingContainer': 'string', + 'TranscodingSubProtocol': 'string', + 'TranscodingUrl': 'string', + 'Type': 'Default', + 'Video3DFormat': 'HalfSideBySide', + 'VideoType': 'VideoFile', + }), + ]), + 'MediaStreams': list([ + dict({ + 'AspectRatio': 'string', + 'AverageFrameRate': 0, + 'BitDepth': 0, + 'BitRate': 0, + 'BlPresentFlag': 0, + 'ChannelLayout': 'string', + 'Channels': 0, + 'Codec': 'string', + 'CodecTag': 'string', + 'CodecTimeBase': 'string', + 'ColorPrimaries': 'string', + 'ColorRange': 'string', + 'ColorSpace': 'string', + 'ColorTransfer': 'string', + 'Comment': 'string', + 'DeliveryMethod': 'Encode', + 'DeliveryUrl': 'string', + 'DisplayTitle': 'string', + 'DvBlSignalCompatibilityId': 0, + 'DvLevel': 0, + 'DvProfile': 0, + 'DvVersionMajor': 0, + 'DvVersionMinor': 0, + 'ElPresentFlag': 0, + 'Height': 0, + 'Index': 0, + 'IsAVC': True, + 'IsAnamorphic': True, + 'IsDefault': True, + 'IsExternal': True, + 'IsExternalUrl': True, + 'IsForced': True, + 'IsInterlaced': True, + 'IsTextSubtitleStream': True, + 'Language': 'string', + 'Level': 0, + 'LocalizedDefault': 'string', + 'LocalizedExternal': 'string', + 'LocalizedForced': 'string', + 'LocalizedUndefined': 'string', + 'NalLengthSize': 'string', + 'PacketLength': 0, + 'Path': 'string', + 'PixelFormat': 'string', + 'Profile': 'string', + 'RealFrameRate': 0, + 'RefFrames': 0, + 'RpuPresentFlag': 0, + 'SampleRate': 0, + 'Score': 0, + 'SupportsExternalStream': True, + 'TimeBase': 'string', + 'Title': 'string', + 'Type': 'Audio', + 'VideoDoViTitle': 'string', + 'VideoRange': 'string', + 'VideoRangeType': 'string', + 'Width': 0, + }), + ]), + 'MediaType': 'string', + 'MovieCount': 0, + 'MusicVideoCount': 0, + 'Name': 'EPISODE', + 'Number': 'string', + 'OfficialRating': 'string', + 'OriginalTitle': 'string', + 'Overview': 'string', + 'ParentArtImageTag': 'string', + 'ParentArtItemId': '10c1875b-b82c-48e8-bae9-939a5e68dc2f', + 'ParentBackdropImageTags': list([ + 'string', + ]), + 'ParentBackdropItemId': 'c22fd826-17fc-44f4-9b04-1eb3e8fb9173', + 'ParentId': 'PARENT-UUID', + 'ParentIndexNumber': 1, + 'ParentLogoImageTag': 'string', + 'ParentLogoItemId': 'c78d400f-de5c-421e-8714-4fb05d387233', + 'ParentPrimaryImageItemId': 'string', + 'ParentPrimaryImageTag': 'string', + 'ParentThumbImageTag': 'string', + 'ParentThumbItemId': 'ae6ff707-333d-4994-be6d-b83ca1b35f46', + 'PartCount': 0, + 'Path': 'string', + 'People': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'ImageBlurHashes': dict({ + 'Art': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Backdrop': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Banner': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Box': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'BoxRear': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Chapter': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Disc': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Logo': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Menu': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Primary': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Profile': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Screenshot': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Thumb': dict({ + 'property1': 'string', + 'property2': 'string', + }), + }), + 'Name': 'string', + 'PrimaryImageTag': 'string', + 'Role': 'string', + 'Type': 'string', + }), + ]), + 'PlayAccess': 'Full', + 'PlaylistItemId': 'string', + 'PreferredMetadataCountryCode': 'string', + 'PreferredMetadataLanguage': 'string', + 'PremiereDate': '2019-08-24T14:15:22Z', + 'PrimaryImageAspectRatio': 0, + 'ProductionLocations': list([ + 'string', + ]), + 'ProductionYear': 0, + 'ProgramCount': 0, + 'ProgramId': 'string', + 'ProviderIds': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'RecursiveItemCount': 0, + 'RemoteTrailers': list([ + dict({ + 'Name': 'string', + 'Url': 'string', + }), + ]), + 'RunTimeTicks': 600000000, + 'ScreenshotImageTags': list([ + 'string', + ]), + 'SeasonId': 'SEASON-UUID', + 'SeasonName': 'SEASON', + 'SeriesCount': 0, + 'SeriesId': 'SERIES-UUID', + 'SeriesName': 'SERIES', + 'SeriesPrimaryImageTag': 'string', + 'SeriesStudio': 'HASS', + 'SeriesThumbImageTag': 'string', + 'SeriesTimerId': 'string', + 'ServerId': 'SERVER-UUID', + 'ShutterSpeed': 0, + 'Software': 'string', + 'SongCount': 0, + 'SortName': 'string', + 'SourceType': 'string', + 'SpecialFeatureCount': 0, + 'StartDate': '2019-08-24T14:15:22Z', + 'Status': 'string', + 'Studios': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'Name': 'string', + }), + ]), + 'SupportsSync': True, + 'Taglines': list([ + 'string', + ]), + 'Tags': list([ + 'string', + ]), + 'TimerId': 'string', + 'TrailerCount': 0, + 'Type': 'Episode', + 'UserData': dict({ + 'IsFavorite': True, + 'ItemId': 'string', + 'Key': 'string', + 'LastPlayedDate': '2019-08-24T14:15:22Z', + 'Likes': True, + 'PlayCount': 0, + 'PlaybackPositionTicks': 0, + 'Played': True, + 'PlayedPercentage': 0, + 'Rating': 0, + 'UnplayedItemCount': 0, + }), + 'Video3DFormat': 'HalfSideBySide', + 'VideoType': 'VideoFile', + 'Width': 0, + }), + 'play_state': dict({ + 'AudioStreamIndex': 0, + 'CanSeek': True, + 'IsMuted': True, + 'IsPaused': True, + 'LiveStreamId': 'string', + 'MediaSourceId': 'string', + 'PlayMethod': 'Transcode', + 'PositionTicks': 100000000, + 'RepeatMode': 'RepeatNone', + 'SubtitleStreamIndex': 0, + 'VolumeLevel': 0, + }), + 'user_id': '08ba1929-681e-4b24-929b-9245852f65c0', + }), + dict({ + 'capabilities': dict({ + 'AppStoreUrl': 'string', + 'DeviceProfile': dict({ + 'AlbumArtPn': 'string', + 'CodecProfiles': list([ + dict({ + 'ApplyConditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Codec': 'string', + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Type': 'Video', + }), + ]), + 'ContainerProfiles': list([ + dict({ + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Type': 'Audio', + }), + ]), + 'DirectPlayProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'Container': 'string', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'EnableAlbumArtInDidl': False, + 'EnableMSMediaReceiverRegistrar': False, + 'EnableSingleAlbumArtLimit': False, + 'EnableSingleSubtitleLimit': False, + 'FriendlyName': 'string', + 'Id': 'string', + 'Identification': dict({ + 'FriendlyName': 'string', + 'Headers': list([ + dict({ + 'Match': 'Equals', + 'Name': 'string', + 'Value': 'string', + }), + ]), + 'Manufacturer': 'string', + 'ManufacturerUrl': 'string', + 'ModelDescription': 'string', + 'ModelName': 'string', + 'ModelNumber': 'string', + 'ModelUrl': 'string', + 'SerialNumber': 'string', + }), + 'IgnoreTranscodeByteRangeRequests': False, + 'Manufacturer': 'string', + 'ManufacturerUrl': 'string', + 'MaxAlbumArtHeight': 0, + 'MaxAlbumArtWidth': 0, + 'MaxIconHeight': 0, + 'MaxIconWidth': 0, + 'MaxStaticBitrate': 0, + 'MaxStaticMusicBitrate': 0, + 'MaxStreamingBitrate': 0, + 'ModelDescription': 'string', + 'ModelName': 'string', + 'ModelNumber': 'string', + 'ModelUrl': 'string', + 'MusicStreamingTranscodingBitrate': 0, + 'Name': 'string', + 'ProtocolInfo': 'string', + 'RequiresPlainFolders': False, + 'RequiresPlainVideoItems': False, + 'ResponseProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'MimeType': 'string', + 'OrgPn': 'string', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'SerialNumber': 'string', + 'SonyAggregationFlags': 'string', + 'SubtitleProfiles': list([ + dict({ + 'Container': 'string', + 'DidlMode': 'string', + 'Format': 'string', + 'Language': 'string', + 'Method': 'Encode', + }), + ]), + 'SupportedMediaTypes': 'string', + 'TimelineOffsetSeconds': 0, + 'TranscodingProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'BreakOnNonKeyFrames': False, + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Context': 'Streaming', + 'CopyTimestamps': False, + 'EnableMpegtsM2TsMode': False, + 'EnableSubtitlesInManifest': False, + 'EstimateContentLength': False, + 'MaxAudioChannels': 'string', + 'MinSegments': 0, + 'Protocol': 'string', + 'SegmentLength': 0, + 'TranscodeSeekInfo': 'Auto', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'UserId': 'string', + 'XmlRootAttributes': list([ + dict({ + 'Name': 'string', + 'Value': 'string', + }), + ]), + }), + 'IconUrl': 'string', + 'MessageCallbackUrl': 'string', + 'PlayableMediaTypes': list([ + 'Video', + ]), + 'SupportedCommands': list([ + 'VolumeSet', + 'Mute', + ]), + 'SupportsContentUploading': True, + 'SupportsMediaControl': True, + 'SupportsPersistentIdentifier': True, + 'SupportsSync': True, + }), + 'client_name': 'Jellyfin for Developers', + 'client_version': '1.0.0', + 'device_id': 'DEVICE-UUID-TWO', + 'device_name': 'JELLYFIN-DEVICE-TWO', + 'id': 'SESSION-UUID-TWO', + 'now_playing': dict({ + 'AirDays': list([ + 'Sunday', + ]), + 'AirTime': 'string', + 'AirsAfterSeasonNumber': 0, + 'AirsBeforeEpisodeNumber': 0, + 'AirsBeforeSeasonNumber': 0, + 'Album': 'string', + 'AlbumArtist': 'string', + 'AlbumArtists': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'Name': 'string', + }), + ]), + 'AlbumCount': 0, + 'AlbumId': '21af9851-8e39-43a9-9c47-513d3b9e99fc', + 'AlbumPrimaryImageTag': 'string', + 'Altitude': 0, + 'Aperture': 0, + 'ArtistCount': 0, + 'ArtistItems': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'Name': 'string', + }), + ]), + 'Artists': list([ + 'string', + ]), + 'AspectRatio': 'string', + 'Audio': 'Mono', + 'BackdropImageTags': list([ + 'string', + ]), + 'CameraMake': 'string', + 'CameraModel': 'string', + 'CanDelete': True, + 'CanDownload': True, + 'ChannelId': '04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff', + 'ChannelName': 'string', + 'ChannelNumber': 'string', + 'ChannelPrimaryImageTag': 'string', + 'ChannelType': 'TV', + 'Chapters': list([ + dict({ + 'ImageDateModified': '2019-08-24T14:15:22Z', + 'ImagePath': 'string', + 'ImageTag': 'string', + 'Name': 'string', + 'StartPositionTicks': 0, + }), + ]), + 'ChildCount': 0, + 'CollectionType': 'string', + 'CommunityRating': 0, + 'CompletionPercentage': 0, + 'Container': 'string', + 'CriticRating': 0, + 'CumulativeRunTimeTicks': 0, + 'CurrentProgram': dict({ + }), + 'CustomRating': 'string', + 'DateCreated': '2019-08-24T14:15:22Z', + 'DateLastMediaAdded': '2019-08-24T14:15:22Z', + 'DisplayOrder': 'string', + 'DisplayPreferencesId': 'string', + 'EnableMediaSourceDisplay': True, + 'EndDate': '2019-08-24T14:15:22Z', + 'EpisodeCount': 0, + 'EpisodeTitle': 'string', + 'Etag': 'string', + 'ExposureTime': 0, + 'ExternalUrls': list([ + dict({ + 'Name': 'string', + 'Url': 'string', + }), + ]), + 'ExtraType': 'string', + 'FocalLength': 0, + 'ForcedSortName': 'string', + 'GenreItems': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'Name': 'string', + }), + ]), + 'Genres': list([ + 'string', + ]), + 'HasSubtitles': True, + 'Height': 0, + 'Id': 'EPISODE-UUID', + 'ImageBlurHashes': dict({ + 'Art': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Backdrop': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Banner': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Box': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'BoxRear': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Chapter': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Disc': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Logo': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Menu': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Primary': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Profile': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Screenshot': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Thumb': dict({ + 'property1': 'string', + 'property2': 'string', + }), + }), + 'ImageOrientation': 'TopLeft', + 'ImageTags': dict({ + 'Backdrop': 'string', + 'property2': 'string', + }), + 'IndexNumber': 0, + 'IndexNumberEnd': 0, + 'IsFolder': False, + 'IsHD': True, + 'IsKids': True, + 'IsLive': True, + 'IsMovie': True, + 'IsNews': True, + 'IsPlaceHolder': True, + 'IsPremiere': True, + 'IsRepeat': True, + 'IsSeries': True, + 'IsSports': True, + 'IsoSpeedRating': 0, + 'IsoType': 'Dvd', + 'Latitude': 0, + 'LocalTrailerCount': 0, + 'LocationType': 'FileSystem', + 'LockData': True, + 'LockedFields': list([ + 'Cast', + ]), + 'Longitude': 0, + 'MediaSourceCount': 0, + 'MediaSources': list([ + dict({ + 'AnalyzeDurationMs': 0, + 'Bitrate': 0, + 'BufferMs': 0, + 'Container': 'string', + 'DefaultAudioStreamIndex': 0, + 'DefaultSubtitleStreamIndex': 0, + 'ETag': 'string', + 'EncoderPath': 'string', + 'EncoderProtocol': 'File', + 'Formats': list([ + 'string', + ]), + 'GenPtsInput': True, + 'Id': 'string', + 'IgnoreDts': True, + 'IgnoreIndex': True, + 'IsInfiniteStream': True, + 'IsRemote': True, + 'IsoType': 'Dvd', + 'LiveStreamId': 'string', + 'MediaAttachments': list([ + dict({ + 'Codec': 'string', + 'CodecTag': 'string', + 'Comment': 'string', + 'DeliveryUrl': 'string', + 'FileName': 'string', + 'Index': 0, + 'MimeType': 'string', + }), + ]), + 'MediaStreams': list([ + dict({ + 'AspectRatio': 'string', + 'AverageFrameRate': 0, + 'BitDepth': 0, + 'BitRate': 0, + 'BlPresentFlag': 0, + 'ChannelLayout': 'string', + 'Channels': 0, + 'Codec': 'string', + 'CodecTag': 'string', + 'CodecTimeBase': 'string', + 'ColorPrimaries': 'string', + 'ColorRange': 'string', + 'ColorSpace': 'string', + 'ColorTransfer': 'string', + 'Comment': 'string', + 'DeliveryMethod': 'Encode', + 'DeliveryUrl': 'string', + 'DisplayTitle': 'string', + 'DvBlSignalCompatibilityId': 0, + 'DvLevel': 0, + 'DvProfile': 0, + 'DvVersionMajor': 0, + 'DvVersionMinor': 0, + 'ElPresentFlag': 0, + 'Height': 0, + 'Index': 0, + 'IsAVC': True, + 'IsAnamorphic': True, + 'IsDefault': True, + 'IsExternal': True, + 'IsExternalUrl': True, + 'IsForced': True, + 'IsInterlaced': True, + 'IsTextSubtitleStream': True, + 'Language': 'string', + 'Level': 0, + 'LocalizedDefault': 'string', + 'LocalizedExternal': 'string', + 'LocalizedForced': 'string', + 'LocalizedUndefined': 'string', + 'NalLengthSize': 'string', + 'PacketLength': 0, + 'Path': 'string', + 'PixelFormat': 'string', + 'Profile': 'string', + 'RealFrameRate': 0, + 'RefFrames': 0, + 'RpuPresentFlag': 0, + 'SampleRate': 0, + 'Score': 0, + 'SupportsExternalStream': True, + 'TimeBase': 'string', + 'Title': 'string', + 'Type': 'Audio', + 'VideoDoViTitle': 'string', + 'VideoRange': 'string', + 'VideoRangeType': 'string', + 'Width': 0, + }), + ]), + 'Name': 'string', + 'OpenToken': 'string', + 'Path': 'string', + 'Protocol': 'File', + 'ReadAtNativeFramerate': True, + 'RequiredHttpHeaders': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'RequiresClosing': True, + 'RequiresLooping': True, + 'RequiresOpening': True, + 'RunTimeTicks': 0, + 'Size': 0, + 'SupportsDirectPlay': True, + 'SupportsDirectStream': True, + 'SupportsProbing': True, + 'SupportsTranscoding': True, + 'Timestamp': 'None', + 'TranscodingContainer': 'string', + 'TranscodingSubProtocol': 'string', + 'TranscodingUrl': 'string', + 'Type': 'Default', + 'Video3DFormat': 'HalfSideBySide', + 'VideoType': 'VideoFile', + }), + ]), + 'MediaStreams': list([ + dict({ + 'AspectRatio': 'string', + 'AverageFrameRate': 0, + 'BitDepth': 0, + 'BitRate': 0, + 'BlPresentFlag': 0, + 'ChannelLayout': 'string', + 'Channels': 0, + 'Codec': 'string', + 'CodecTag': 'string', + 'CodecTimeBase': 'string', + 'ColorPrimaries': 'string', + 'ColorRange': 'string', + 'ColorSpace': 'string', + 'ColorTransfer': 'string', + 'Comment': 'string', + 'DeliveryMethod': 'Encode', + 'DeliveryUrl': 'string', + 'DisplayTitle': 'string', + 'DvBlSignalCompatibilityId': 0, + 'DvLevel': 0, + 'DvProfile': 0, + 'DvVersionMajor': 0, + 'DvVersionMinor': 0, + 'ElPresentFlag': 0, + 'Height': 0, + 'Index': 0, + 'IsAVC': True, + 'IsAnamorphic': True, + 'IsDefault': True, + 'IsExternal': True, + 'IsExternalUrl': True, + 'IsForced': True, + 'IsInterlaced': True, + 'IsTextSubtitleStream': True, + 'Language': 'string', + 'Level': 0, + 'LocalizedDefault': 'string', + 'LocalizedExternal': 'string', + 'LocalizedForced': 'string', + 'LocalizedUndefined': 'string', + 'NalLengthSize': 'string', + 'PacketLength': 0, + 'Path': 'string', + 'PixelFormat': 'string', + 'Profile': 'string', + 'RealFrameRate': 0, + 'RefFrames': 0, + 'RpuPresentFlag': 0, + 'SampleRate': 0, + 'Score': 0, + 'SupportsExternalStream': True, + 'TimeBase': 'string', + 'Title': 'string', + 'Type': 'Audio', + 'VideoDoViTitle': 'string', + 'VideoRange': 'string', + 'VideoRangeType': 'string', + 'Width': 0, + }), + ]), + 'MediaType': 'string', + 'MovieCount': 0, + 'MusicVideoCount': 0, + 'Name': 'MOVIE', + 'Number': 'string', + 'OfficialRating': 'string', + 'OriginalTitle': 'string', + 'Overview': 'string', + 'ParentArtImageTag': 'string', + 'ParentArtItemId': '10c1875b-b82c-48e8-bae9-939a5e68dc2f', + 'ParentBackdropImageTags': list([ + 'string', + ]), + 'ParentBackdropItemId': '', + 'ParentId': '', + 'ParentIndexNumber': 0, + 'ParentLogoImageTag': 'string', + 'ParentLogoItemId': 'c78d400f-de5c-421e-8714-4fb05d387233', + 'ParentPrimaryImageItemId': 'string', + 'ParentPrimaryImageTag': 'string', + 'ParentThumbImageTag': 'string', + 'ParentThumbItemId': 'ae6ff707-333d-4994-be6d-b83ca1b35f46', + 'PartCount': 0, + 'Path': 'string', + 'People': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'ImageBlurHashes': dict({ + 'Art': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Backdrop': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Banner': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Box': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'BoxRear': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Chapter': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Disc': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Logo': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Menu': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Primary': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Profile': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Screenshot': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'Thumb': dict({ + 'property1': 'string', + 'property2': 'string', + }), + }), + 'Name': 'string', + 'PrimaryImageTag': 'string', + 'Role': 'string', + 'Type': 'string', + }), + ]), + 'PlayAccess': 'Full', + 'PlaylistItemId': 'string', + 'PreferredMetadataCountryCode': 'string', + 'PreferredMetadataLanguage': 'string', + 'PremiereDate': '2019-08-24T14:15:22Z', + 'PrimaryImageAspectRatio': 0, + 'ProductionLocations': list([ + 'string', + ]), + 'ProductionYear': 0, + 'ProgramCount': 0, + 'ProgramId': 'string', + 'ProviderIds': dict({ + 'property1': 'string', + 'property2': 'string', + }), + 'RecursiveItemCount': 0, + 'RemoteTrailers': list([ + dict({ + 'Name': 'string', + 'Url': 'string', + }), + ]), + 'RunTimeTicks': 2000000000, + 'ScreenshotImageTags': list([ + 'string', + ]), + 'SeasonId': 'SEASON-UUID', + 'SeasonName': 'SEASON', + 'SeriesCount': 0, + 'SeriesId': 'SERIES-UUID', + 'SeriesName': 'SERIES', + 'SeriesPrimaryImageTag': 'string', + 'SeriesStudio': 'HASS', + 'SeriesThumbImageTag': 'string', + 'SeriesTimerId': 'string', + 'ServerId': 'SERVER-UUID', + 'ShutterSpeed': 0, + 'Software': 'string', + 'SongCount': 0, + 'SortName': 'string', + 'SourceType': 'string', + 'SpecialFeatureCount': 0, + 'StartDate': '2019-08-24T14:15:22Z', + 'Status': 'string', + 'Studios': list([ + dict({ + 'Id': '38a5a5bb-dc30-49a2-b175-1de0d1488c43', + 'Name': 'string', + }), + ]), + 'SupportsSync': True, + 'Taglines': list([ + 'string', + ]), + 'Tags': list([ + 'string', + ]), + 'TimerId': 'string', + 'TrailerCount': 0, + 'Type': 'Movie', + 'UserData': dict({ + 'IsFavorite': True, + 'ItemId': 'string', + 'Key': 'string', + 'LastPlayedDate': '2019-08-24T14:15:22Z', + 'Likes': True, + 'PlayCount': 0, + 'PlaybackPositionTicks': 0, + 'Played': True, + 'PlayedPercentage': 0, + 'Rating': 0, + 'UnplayedItemCount': 0, + }), + 'Video3DFormat': 'HalfSideBySide', + 'VideoType': 'VideoFile', + 'Width': 0, + }), + 'play_state': dict({ + 'AudioStreamIndex': 0, + 'CanSeek': True, + 'IsMuted': False, + 'IsPaused': False, + 'LiveStreamId': 'string', + 'MediaSourceId': 'string', + 'PlayMethod': 'Transcode', + 'PositionTicks': 230000000, + 'RepeatMode': 'RepeatNone', + 'SubtitleStreamIndex': 0, + 'VolumeLevel': 55, + }), + 'user_id': 'USER-UUID-TWO', + }), + dict({ + 'capabilities': dict({ + 'AppStoreUrl': 'string', + 'DeviceProfile': dict({ + 'AlbumArtPn': 'string', + 'CodecProfiles': list([ + dict({ + 'ApplyConditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Codec': 'string', + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Type': 'Video', + }), + ]), + 'ContainerProfiles': list([ + dict({ + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Type': 'Audio', + }), + ]), + 'DirectPlayProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'Container': 'string', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'EnableAlbumArtInDidl': False, + 'EnableMSMediaReceiverRegistrar': False, + 'EnableSingleAlbumArtLimit': False, + 'EnableSingleSubtitleLimit': False, + 'FriendlyName': 'string', + 'Id': 'string', + 'Identification': dict({ + 'FriendlyName': 'string', + 'Headers': list([ + dict({ + 'Match': 'Equals', + 'Name': 'string', + 'Value': 'string', + }), + ]), + 'Manufacturer': 'string', + 'ManufacturerUrl': 'string', + 'ModelDescription': 'string', + 'ModelName': 'string', + 'ModelNumber': 'string', + 'ModelUrl': 'string', + 'SerialNumber': 'string', + }), + 'IgnoreTranscodeByteRangeRequests': False, + 'Manufacturer': 'string', + 'ManufacturerUrl': 'string', + 'MaxAlbumArtHeight': 0, + 'MaxAlbumArtWidth': 0, + 'MaxIconHeight': 0, + 'MaxIconWidth': 0, + 'MaxStaticBitrate': 0, + 'MaxStaticMusicBitrate': 0, + 'MaxStreamingBitrate': 0, + 'ModelDescription': 'string', + 'ModelName': 'string', + 'ModelNumber': 'string', + 'ModelUrl': 'string', + 'MusicStreamingTranscodingBitrate': 0, + 'Name': 'string', + 'ProtocolInfo': 'string', + 'RequiresPlainFolders': False, + 'RequiresPlainVideoItems': False, + 'ResponseProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'MimeType': 'string', + 'OrgPn': 'string', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'SerialNumber': 'string', + 'SonyAggregationFlags': 'string', + 'SubtitleProfiles': list([ + dict({ + 'Container': 'string', + 'DidlMode': 'string', + 'Format': 'string', + 'Language': 'string', + 'Method': 'Encode', + }), + ]), + 'SupportedMediaTypes': 'string', + 'TimelineOffsetSeconds': 0, + 'TranscodingProfiles': list([ + dict({ + 'AudioCodec': 'string', + 'BreakOnNonKeyFrames': False, + 'Conditions': list([ + dict({ + 'Condition': 'Equals', + 'IsRequired': True, + 'Property': 'AudioChannels', + 'Value': 'string', + }), + ]), + 'Container': 'string', + 'Context': 'Streaming', + 'CopyTimestamps': False, + 'EnableMpegtsM2TsMode': False, + 'EnableSubtitlesInManifest': False, + 'EstimateContentLength': False, + 'MaxAudioChannels': 'string', + 'MinSegments': 0, + 'Protocol': 'string', + 'SegmentLength': 0, + 'TranscodeSeekInfo': 'Auto', + 'Type': 'Audio', + 'VideoCodec': 'string', + }), + ]), + 'UserId': 'string', + 'XmlRootAttributes': list([ + dict({ + 'Name': 'string', + 'Value': 'string', + }), + ]), + }), + 'IconUrl': 'string', + 'MessageCallbackUrl': 'string', + 'PlayableMediaTypes': list([ + 'Video', + ]), + 'SupportedCommands': list([ + 'MoveUp', + ]), + 'SupportsContentUploading': False, + 'SupportsMediaControl': False, + 'SupportsPersistentIdentifier': False, + 'SupportsSync': True, + }), + 'client_name': 'Jellyfin for Developers', + 'client_version': '2.0.0', + 'device_id': 'DEVICE-UUID-THREE', + 'device_name': 'JELLYFIN-DEVICE-THREE', + 'id': 'SESSION-UUID-THREE', + 'now_playing': None, + 'play_state': dict({ + 'AudioStreamIndex': 0, + 'CanSeek': True, + 'IsMuted': True, + 'IsPaused': False, + 'LiveStreamId': 'string', + 'MediaSourceId': 'string', + 'PlayMethod': 'Transcode', + 'PositionTicks': 0, + 'RepeatMode': 'RepeatNone', + 'SubtitleStreamIndex': 0, + 'VolumeLevel': 0, + }), + 'user_id': 'USER-UUID', + }), + dict({ + 'capabilities': dict({ + 'PlayableMediaTypes': list([ + 'Audio', + 'Video', + ]), + 'SupportedCommands': list([ + 'MoveUp', + 'MoveDown', + 'MoveLeft', + 'MoveRight', + 'PageUp', + 'PageDown', + 'PreviousLetter', + 'NextLetter', + 'ToggleOsd', + 'ToggleContextMenu', + 'Select', + 'Back', + 'SendKey', + 'SendString', + 'GoHome', + 'GoToSettings', + 'VolumeUp', + 'VolumeDown', + 'Mute', + 'Unmute', + 'ToggleMute', + 'SetVolume', + 'SetAudioStreamIndex', + 'SetSubtitleStreamIndex', + 'DisplayContent', + 'GoToSearch', + 'DisplayMessage', + 'SetRepeatMode', + 'SetShuffleQueue', + 'ChannelUp', + 'ChannelDown', + 'PlayMediaSource', + 'PlayTrailers', + ]), + 'SupportsContentUploading': False, + 'SupportsMediaControl': True, + 'SupportsPersistentIdentifier': False, + 'SupportsSync': False, + }), + 'client_name': 'Jellyfin Android', + 'client_version': '2.4.4', + 'device_id': 'DEVICE-UUID-FOUR', + 'device_name': 'JELLYFIN DEVICE FOUR', + 'id': 'SESSION-UUID-FOUR', + 'now_playing': dict({ + 'Album': 'ALBUM', + 'AlbumArtist': 'Album Artist', + 'AlbumArtists': list([ + dict({ + 'Id': '9a65b2c222ddb34e51f5cae360fad3a1', + 'Name': 'Album Artist', + }), + ]), + 'AlbumId': 'ALBUM-UUID', + 'ArtistItems': list([ + dict({ + 'Id': '1d864900526d9a9513b489f1cc28f8ca', + 'Name': 'Contributing Artist', + }), + ]), + 'Artists': list([ + 'Contributing Artist', + ]), + 'BackdropImageTags': list([ + ]), + 'ChannelId': None, + 'DateCreated': '2022-10-19T03:09:11.392057Z', + 'EnableMediaSourceDisplay': True, + 'ExternalUrls': list([ + ]), + 'GenreItems': list([ + ]), + 'Genres': list([ + ]), + 'Id': 'MUSIC-UUID', + 'ImageBlurHashes': dict({ + }), + 'ImageTags': dict({ + }), + 'IndexNumber': 1, + 'IsFolder': False, + 'LocalTrailerCount': 0, + 'LocationType': 'FileSystem', + 'MediaStreams': list([ + dict({ + 'BitRate': 256000, + 'ChannelLayout': 'stereo', + 'Channels': 2, + 'Codec': 'mp3', + 'DisplayTitle': 'MP3 - Stereo', + 'Index': 0, + 'IsDefault': False, + 'IsExternal': False, + 'IsForced': False, + 'IsInterlaced': False, + 'IsTextSubtitleStream': False, + 'Level': 0, + 'SampleRate': 44100, + 'SupportsExternalStream': False, + 'TimeBase': '1/14112000', + 'Type': 'Audio', + }), + ]), + 'MediaType': 'Audio', + 'Name': 'MUSIC FILE', + 'ParentId': '4c0343ed1bbcda094178076230051b7e', + 'Path': 'string', + 'ProviderIds': dict({ + }), + 'RunTimeTicks': 736391552, + 'ServerId': 'SERVER-UUID', + 'SpecialFeatureCount': 0, + 'Studios': list([ + ]), + 'Taglines': list([ + ]), + 'Type': 'Audio', + }), + 'play_state': dict({ + 'CanSeek': True, + 'IsMuted': False, + 'IsPaused': False, + 'MediaSourceId': 'a744119f757f88858f95aab1628708c4', + 'PlayMethod': 'DirectPlay', + 'PositionTicks': 220246970, + 'RepeatMode': 'RepeatNone', + 'VolumeLevel': 100, + }), + 'user_id': 'USER-UUID-TWO', + }), + ]), + }) +# --- diff --git a/tests/components/jellyfin/test_diagnostics.py b/tests/components/jellyfin/test_diagnostics.py index 15561f5294c..b56d864eaac 100644 --- a/tests/components/jellyfin/test_diagnostics.py +++ b/tests/components/jellyfin/test_diagnostics.py @@ -1,4 +1,5 @@ """Test Jellyfin diagnostics.""" +from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -11,601 +12,12 @@ async def test_diagnostics( hass: HomeAssistant, init_integration: MockConfigEntry, hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" - entry = init_integration + data = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) - diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert diag - assert diag["entry"] == { - "title": "Jellyfin", - "data": { - "url": "https://example.com", - "username": "test-username", - "password": "**REDACTED**", - "client_device_id": entry.entry_id, - }, - } - assert diag["server"] == { - "id": "SERVER-UUID", - "name": "JELLYFIN-SERVER", - "version": None, - } - assert diag["sessions"] - assert len(diag["sessions"]) == 4 - assert diag["sessions"][0] == { - "id": "SESSION-UUID", - "user_id": "08ba1929-681e-4b24-929b-9245852f65c0", - "device_id": "DEVICE-UUID", - "device_name": "JELLYFIN-DEVICE", - "client_name": "Jellyfin for Developers", - "client_version": "1.0.0", - "capabilities": { - "PlayableMediaTypes": ["Video"], - "SupportedCommands": ["VolumeSet", "Mute"], - "SupportsMediaControl": True, - "SupportsContentUploading": True, - "MessageCallbackUrl": "string", - "SupportsPersistentIdentifier": True, - "SupportsSync": True, - "DeviceProfile": { - "Name": "string", - "Id": "string", - "Identification": { - "FriendlyName": "string", - "ModelNumber": "string", - "SerialNumber": "string", - "ModelName": "string", - "ModelDescription": "string", - "ModelUrl": "string", - "Manufacturer": "string", - "ManufacturerUrl": "string", - "Headers": [ - {"Name": "string", "Value": "string", "Match": "Equals"} - ], - }, - "FriendlyName": "string", - "Manufacturer": "string", - "ManufacturerUrl": "string", - "ModelName": "string", - "ModelDescription": "string", - "ModelNumber": "string", - "ModelUrl": "string", - "SerialNumber": "string", - "EnableAlbumArtInDidl": False, - "EnableSingleAlbumArtLimit": False, - "EnableSingleSubtitleLimit": False, - "SupportedMediaTypes": "string", - "UserId": "string", - "AlbumArtPn": "string", - "MaxAlbumArtWidth": 0, - "MaxAlbumArtHeight": 0, - "MaxIconWidth": 0, - "MaxIconHeight": 0, - "MaxStreamingBitrate": 0, - "MaxStaticBitrate": 0, - "MusicStreamingTranscodingBitrate": 0, - "MaxStaticMusicBitrate": 0, - "SonyAggregationFlags": "string", - "ProtocolInfo": "string", - "TimelineOffsetSeconds": 0, - "RequiresPlainVideoItems": False, - "RequiresPlainFolders": False, - "EnableMSMediaReceiverRegistrar": False, - "IgnoreTranscodeByteRangeRequests": False, - "XmlRootAttributes": [{"Name": "string", "Value": "string"}], - "DirectPlayProfiles": [ - { - "Container": "string", - "AudioCodec": "string", - "VideoCodec": "string", - "Type": "Audio", - } - ], - "TranscodingProfiles": [ - { - "Container": "string", - "Type": "Audio", - "VideoCodec": "string", - "AudioCodec": "string", - "Protocol": "string", - "EstimateContentLength": False, - "EnableMpegtsM2TsMode": False, - "TranscodeSeekInfo": "Auto", - "CopyTimestamps": False, - "Context": "Streaming", - "EnableSubtitlesInManifest": False, - "MaxAudioChannels": "string", - "MinSegments": 0, - "SegmentLength": 0, - "BreakOnNonKeyFrames": False, - "Conditions": [ - { - "Condition": "Equals", - "Property": "AudioChannels", - "Value": "string", - "IsRequired": True, - } - ], - } - ], - "ContainerProfiles": [ - { - "Type": "Audio", - "Conditions": [ - { - "Condition": "Equals", - "Property": "AudioChannels", - "Value": "string", - "IsRequired": True, - } - ], - "Container": "string", - } - ], - "CodecProfiles": [ - { - "Type": "Video", - "Conditions": [ - { - "Condition": "Equals", - "Property": "AudioChannels", - "Value": "string", - "IsRequired": True, - } - ], - "ApplyConditions": [ - { - "Condition": "Equals", - "Property": "AudioChannels", - "Value": "string", - "IsRequired": True, - } - ], - "Codec": "string", - "Container": "string", - } - ], - "ResponseProfiles": [ - { - "Container": "string", - "AudioCodec": "string", - "VideoCodec": "string", - "Type": "Audio", - "OrgPn": "string", - "MimeType": "string", - "Conditions": [ - { - "Condition": "Equals", - "Property": "AudioChannels", - "Value": "string", - "IsRequired": True, - } - ], - } - ], - "SubtitleProfiles": [ - { - "Format": "string", - "Method": "Encode", - "DidlMode": "string", - "Language": "string", - "Container": "string", - } - ], - }, - "AppStoreUrl": "string", - "IconUrl": "string", - }, - "now_playing": { - "Name": "EPISODE", - "OriginalTitle": "string", - "ServerId": "SERVER-UUID", - "Id": "EPISODE-UUID", - "Etag": "string", - "SourceType": "string", - "PlaylistItemId": "string", - "DateCreated": "2019-08-24T14:15:22Z", - "DateLastMediaAdded": "2019-08-24T14:15:22Z", - "ExtraType": "string", - "AirsBeforeSeasonNumber": 0, - "AirsAfterSeasonNumber": 0, - "AirsBeforeEpisodeNumber": 0, - "CanDelete": True, - "CanDownload": True, - "HasSubtitles": True, - "PreferredMetadataLanguage": "string", - "PreferredMetadataCountryCode": "string", - "SupportsSync": True, - "Container": "string", - "SortName": "string", - "ForcedSortName": "string", - "Video3DFormat": "HalfSideBySide", - "PremiereDate": "2019-08-24T14:15:22Z", - "ExternalUrls": [{"Name": "string", "Url": "string"}], - "MediaSources": [ - { - "Protocol": "File", - "Id": "string", - "Path": "string", - "EncoderPath": "string", - "EncoderProtocol": "File", - "Type": "Default", - "Container": "string", - "Size": 0, - "Name": "string", - "IsRemote": True, - "ETag": "string", - "RunTimeTicks": 0, - "ReadAtNativeFramerate": True, - "IgnoreDts": True, - "IgnoreIndex": True, - "GenPtsInput": True, - "SupportsTranscoding": True, - "SupportsDirectStream": True, - "SupportsDirectPlay": True, - "IsInfiniteStream": True, - "RequiresOpening": True, - "OpenToken": "string", - "RequiresClosing": True, - "LiveStreamId": "string", - "BufferMs": 0, - "RequiresLooping": True, - "SupportsProbing": True, - "VideoType": "VideoFile", - "IsoType": "Dvd", - "Video3DFormat": "HalfSideBySide", - "MediaStreams": [ - { - "Codec": "string", - "CodecTag": "string", - "Language": "string", - "ColorRange": "string", - "ColorSpace": "string", - "ColorTransfer": "string", - "ColorPrimaries": "string", - "DvVersionMajor": 0, - "DvVersionMinor": 0, - "DvProfile": 0, - "DvLevel": 0, - "RpuPresentFlag": 0, - "ElPresentFlag": 0, - "BlPresentFlag": 0, - "DvBlSignalCompatibilityId": 0, - "Comment": "string", - "TimeBase": "string", - "CodecTimeBase": "string", - "Title": "string", - "VideoRange": "string", - "VideoRangeType": "string", - "VideoDoViTitle": "string", - "LocalizedUndefined": "string", - "LocalizedDefault": "string", - "LocalizedForced": "string", - "LocalizedExternal": "string", - "DisplayTitle": "string", - "NalLengthSize": "string", - "IsInterlaced": True, - "IsAVC": True, - "ChannelLayout": "string", - "BitRate": 0, - "BitDepth": 0, - "RefFrames": 0, - "PacketLength": 0, - "Channels": 0, - "SampleRate": 0, - "IsDefault": True, - "IsForced": True, - "Height": 0, - "Width": 0, - "AverageFrameRate": 0, - "RealFrameRate": 0, - "Profile": "string", - "Type": "Audio", - "AspectRatio": "string", - "Index": 0, - "Score": 0, - "IsExternal": True, - "DeliveryMethod": "Encode", - "DeliveryUrl": "string", - "IsExternalUrl": True, - "IsTextSubtitleStream": True, - "SupportsExternalStream": True, - "Path": "string", - "PixelFormat": "string", - "Level": 0, - "IsAnamorphic": True, - } - ], - "MediaAttachments": [ - { - "Codec": "string", - "CodecTag": "string", - "Comment": "string", - "Index": 0, - "FileName": "string", - "MimeType": "string", - "DeliveryUrl": "string", - } - ], - "Formats": ["string"], - "Bitrate": 0, - "Timestamp": "None", - "RequiredHttpHeaders": { - "property1": "string", - "property2": "string", - }, - "TranscodingUrl": "string", - "TranscodingSubProtocol": "string", - "TranscodingContainer": "string", - "AnalyzeDurationMs": 0, - "DefaultAudioStreamIndex": 0, - "DefaultSubtitleStreamIndex": 0, - } - ], - "CriticRating": 0, - "ProductionLocations": ["string"], - "Path": "string", - "EnableMediaSourceDisplay": True, - "OfficialRating": "string", - "CustomRating": "string", - "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", - "ChannelName": "string", - "Overview": "string", - "Taglines": ["string"], - "Genres": ["string"], - "CommunityRating": 0, - "CumulativeRunTimeTicks": 0, - "RunTimeTicks": 600000000, - "PlayAccess": "Full", - "AspectRatio": "string", - "ProductionYear": 0, - "IsPlaceHolder": True, - "Number": "string", - "ChannelNumber": "string", - "IndexNumber": 3, - "IndexNumberEnd": 0, - "ParentIndexNumber": 1, - "RemoteTrailers": [{"Url": "string", "Name": "string"}], - "ProviderIds": {"property1": "string", "property2": "string"}, - "IsHD": True, - "IsFolder": False, - "ParentId": "PARENT-UUID", - "Type": "Episode", - "People": [ - { - "Name": "string", - "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", - "Role": "string", - "Type": "string", - "PrimaryImageTag": "string", - "ImageBlurHashes": { - "Primary": {"property1": "string", "property2": "string"}, - "Art": {"property1": "string", "property2": "string"}, - "Backdrop": {"property1": "string", "property2": "string"}, - "Banner": {"property1": "string", "property2": "string"}, - "Logo": {"property1": "string", "property2": "string"}, - "Thumb": {"property1": "string", "property2": "string"}, - "Disc": {"property1": "string", "property2": "string"}, - "Box": {"property1": "string", "property2": "string"}, - "Screenshot": {"property1": "string", "property2": "string"}, - "Menu": {"property1": "string", "property2": "string"}, - "Chapter": {"property1": "string", "property2": "string"}, - "BoxRear": {"property1": "string", "property2": "string"}, - "Profile": {"property1": "string", "property2": "string"}, - }, - } - ], - "Studios": [ - {"Name": "string", "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43"} - ], - "GenreItems": [ - {"Name": "string", "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43"} - ], - "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", - "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", - "ParentBackdropImageTags": ["string"], - "LocalTrailerCount": 0, - "UserData": { - "Rating": 0, - "PlayedPercentage": 0, - "UnplayedItemCount": 0, - "PlaybackPositionTicks": 0, - "PlayCount": 0, - "IsFavorite": True, - "Likes": True, - "LastPlayedDate": "2019-08-24T14:15:22Z", - "Played": True, - "Key": "string", - "ItemId": "string", - }, - "RecursiveItemCount": 0, - "ChildCount": 0, - "SeriesName": "SERIES", - "SeriesId": "SERIES-UUID", - "SeasonId": "SEASON-UUID", - "SpecialFeatureCount": 0, - "DisplayPreferencesId": "string", - "Status": "string", - "AirTime": "string", - "AirDays": ["Sunday"], - "Tags": ["string"], - "PrimaryImageAspectRatio": 0, - "Artists": ["string"], - "ArtistItems": [ - {"Name": "string", "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43"} - ], - "Album": "string", - "CollectionType": "string", - "DisplayOrder": "string", - "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", - "AlbumPrimaryImageTag": "string", - "SeriesPrimaryImageTag": "string", - "AlbumArtist": "string", - "AlbumArtists": [ - {"Name": "string", "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43"} - ], - "SeasonName": "SEASON", - "MediaStreams": [ - { - "Codec": "string", - "CodecTag": "string", - "Language": "string", - "ColorRange": "string", - "ColorSpace": "string", - "ColorTransfer": "string", - "ColorPrimaries": "string", - "DvVersionMajor": 0, - "DvVersionMinor": 0, - "DvProfile": 0, - "DvLevel": 0, - "RpuPresentFlag": 0, - "ElPresentFlag": 0, - "BlPresentFlag": 0, - "DvBlSignalCompatibilityId": 0, - "Comment": "string", - "TimeBase": "string", - "CodecTimeBase": "string", - "Title": "string", - "VideoRange": "string", - "VideoRangeType": "string", - "VideoDoViTitle": "string", - "LocalizedUndefined": "string", - "LocalizedDefault": "string", - "LocalizedForced": "string", - "LocalizedExternal": "string", - "DisplayTitle": "string", - "NalLengthSize": "string", - "IsInterlaced": True, - "IsAVC": True, - "ChannelLayout": "string", - "BitRate": 0, - "BitDepth": 0, - "RefFrames": 0, - "PacketLength": 0, - "Channels": 0, - "SampleRate": 0, - "IsDefault": True, - "IsForced": True, - "Height": 0, - "Width": 0, - "AverageFrameRate": 0, - "RealFrameRate": 0, - "Profile": "string", - "Type": "Audio", - "AspectRatio": "string", - "Index": 0, - "Score": 0, - "IsExternal": True, - "DeliveryMethod": "Encode", - "DeliveryUrl": "string", - "IsExternalUrl": True, - "IsTextSubtitleStream": True, - "SupportsExternalStream": True, - "Path": "string", - "PixelFormat": "string", - "Level": 0, - "IsAnamorphic": True, - } - ], - "VideoType": "VideoFile", - "PartCount": 0, - "MediaSourceCount": 0, - "ImageTags": {"property1": "string", "property2": "string"}, - "BackdropImageTags": ["string"], - "ScreenshotImageTags": ["string"], - "ParentLogoImageTag": "string", - "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", - "ParentArtImageTag": "string", - "SeriesThumbImageTag": "string", - "ImageBlurHashes": { - "Primary": {"property1": "string", "property2": "string"}, - "Art": {"property1": "string", "property2": "string"}, - "Backdrop": {"property1": "string", "property2": "string"}, - "Banner": {"property1": "string", "property2": "string"}, - "Logo": {"property1": "string", "property2": "string"}, - "Thumb": {"property1": "string", "property2": "string"}, - "Disc": {"property1": "string", "property2": "string"}, - "Box": {"property1": "string", "property2": "string"}, - "Screenshot": {"property1": "string", "property2": "string"}, - "Menu": {"property1": "string", "property2": "string"}, - "Chapter": {"property1": "string", "property2": "string"}, - "BoxRear": {"property1": "string", "property2": "string"}, - "Profile": {"property1": "string", "property2": "string"}, - }, - "SeriesStudio": "HASS", - "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", - "ParentThumbImageTag": "string", - "ParentPrimaryImageItemId": "string", - "ParentPrimaryImageTag": "string", - "Chapters": [ - { - "StartPositionTicks": 0, - "Name": "string", - "ImagePath": "string", - "ImageDateModified": "2019-08-24T14:15:22Z", - "ImageTag": "string", - } - ], - "LocationType": "FileSystem", - "IsoType": "Dvd", - "MediaType": "string", - "EndDate": "2019-08-24T14:15:22Z", - "LockedFields": ["Cast"], - "TrailerCount": 0, - "MovieCount": 0, - "SeriesCount": 0, - "ProgramCount": 0, - "EpisodeCount": 0, - "SongCount": 0, - "AlbumCount": 0, - "ArtistCount": 0, - "MusicVideoCount": 0, - "LockData": True, - "Width": 0, - "Height": 0, - "CameraMake": "string", - "CameraModel": "string", - "Software": "string", - "ExposureTime": 0, - "FocalLength": 0, - "ImageOrientation": "TopLeft", - "Aperture": 0, - "ShutterSpeed": 0, - "Latitude": 0, - "Longitude": 0, - "Altitude": 0, - "IsoSpeedRating": 0, - "SeriesTimerId": "string", - "ProgramId": "string", - "ChannelPrimaryImageTag": "string", - "StartDate": "2019-08-24T14:15:22Z", - "CompletionPercentage": 0, - "IsRepeat": True, - "EpisodeTitle": "string", - "ChannelType": "TV", - "Audio": "Mono", - "IsMovie": True, - "IsSports": True, - "IsSeries": True, - "IsLive": True, - "IsNews": True, - "IsKids": True, - "IsPremiere": True, - "TimerId": "string", - "CurrentProgram": {}, - }, - "play_state": { - "PositionTicks": 100000000, - "CanSeek": True, - "IsPaused": True, - "IsMuted": True, - "VolumeLevel": 0, - "AudioStreamIndex": 0, - "SubtitleStreamIndex": 0, - "MediaSourceId": "string", - "PlayMethod": "Transcode", - "RepeatMode": "RepeatNone", - "LiveStreamId": "string", - }, - } + assert data["entry"]["data"]["client_device_id"] == init_integration.entry_id + data["entry"]["data"]["client_device_id"] = "entry-id" + + assert data == snapshot diff --git a/tests/components/kitchen_sink/snapshots/test_lawn_mower.ambr b/tests/components/kitchen_sink/snapshots/test_lawn_mower.ambr new file mode 100644 index 00000000000..879e78d5534 --- /dev/null +++ b/tests/components/kitchen_sink/snapshots/test_lawn_mower.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_states + set({ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mower can do all', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lawn_mower.mower_can_do_all', + 'last_changed': , + 'last_updated': , + 'state': 'docked', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mower can dock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lawn_mower.mower_can_dock', + 'last_changed': , + 'last_updated': , + 'state': 'mowing', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mower can mow', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lawn_mower.mower_can_mow', + 'last_changed': , + 'last_updated': , + 'state': 'docked', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mower can pause', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lawn_mower.mower_can_pause', + 'last_changed': , + 'last_updated': , + 'state': 'docked', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mower is paused', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lawn_mower.mower_is_paused', + 'last_changed': , + 'last_updated': , + 'state': 'paused', + }), + }) +# --- diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index 88f2de5b394..ebd0f781d22 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -212,6 +212,7 @@ async def test_issues_created( "flow_id": ANY, "handler": DOMAIN, "last_step": None, + "preview": None, "step_id": "confirm", "type": "form", } diff --git a/tests/components/kitchen_sink/test_lawn_mower.py b/tests/components/kitchen_sink/test_lawn_mower.py new file mode 100644 index 00000000000..efd1b7485ab --- /dev/null +++ b/tests/components/kitchen_sink/test_lawn_mower.py @@ -0,0 +1,116 @@ +"""The tests for the kitchen_sink lawn mower platform.""" +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.kitchen_sink import DOMAIN +from homeassistant.components.lawn_mower import ( + DOMAIN as LAWN_MOWER_DOMAIN, + SERVICE_DOCK, + SERVICE_PAUSE, + SERVICE_START_MOWING, + LawnMowerActivity, +) +from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import async_capture_events, async_mock_service + +MOWER_SERVICE_ENTITY = "lawn_mower.mower_can_dock" + + +@pytest.fixture +async def lawn_mower_only() -> None: + """Enable only the lawn mower platform.""" + with patch( + "homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM", + [Platform.LAWN_MOWER], + ): + yield + + +@pytest.fixture(autouse=True) +async def setup_comp(hass: HomeAssistant, lawn_mower_only): + """Set up demo component.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + +async def test_states(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test the expected lawn mower entities are added.""" + states = hass.states.async_all() + assert set(states) == snapshot + + +@pytest.mark.parametrize( + ("entity", "service_call", "activity", "next_activity"), + [ + ( + "lawn_mower.mower_can_mow", + SERVICE_START_MOWING, + LawnMowerActivity.DOCKED, + LawnMowerActivity.MOWING, + ), + ( + "lawn_mower.mower_can_pause", + SERVICE_PAUSE, + LawnMowerActivity.DOCKED, + LawnMowerActivity.PAUSED, + ), + ( + "lawn_mower.mower_is_paused", + SERVICE_START_MOWING, + LawnMowerActivity.PAUSED, + LawnMowerActivity.MOWING, + ), + ( + "lawn_mower.mower_can_dock", + SERVICE_DOCK, + LawnMowerActivity.MOWING, + LawnMowerActivity.DOCKED, + ), + ], +) +async def test_mower( + hass: HomeAssistant, + entity: str, + service_call: str, + activity: LawnMowerActivity, + next_activity: LawnMowerActivity, +) -> None: + """Test the activity states of a lawn mower.""" + state = hass.states.get(entity) + + assert state.state == str(activity.value) + await hass.async_block_till_done() + + state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) + await hass.services.async_call( + LAWN_MOWER_DOMAIN, service_call, {ATTR_ENTITY_ID: entity}, blocking=False + ) + await hass.async_block_till_done() + + assert state_changes[0].data["entity_id"] == entity + assert state_changes[0].data["new_state"].state == str(next_activity.value) + + +@pytest.mark.parametrize( + "service_call", + [ + SERVICE_DOCK, + SERVICE_START_MOWING, + SERVICE_PAUSE, + ], +) +async def test_service_calls_mocked(hass: HomeAssistant, service_call) -> None: + """Test the services of a lawn mower.""" + calls = async_mock_service(hass, LAWN_MOWER_DOMAIN, service_call) + await hass.services.async_call( + LAWN_MOWER_DOMAIN, + service_call, + {ATTR_ENTITY_ID: MOWER_SERVICE_ENTITY}, + blocking=True, + ) + assert len(calls) == 1 diff --git a/tests/components/knx/snapshots/test_diagnostic.ambr b/tests/components/knx/snapshots/test_diagnostic.ambr new file mode 100644 index 00000000000..4323dd113cd --- /dev/null +++ b/tests/components/knx/snapshots/test_diagnostic.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_diagnostic_config_error[hass_config0] + dict({ + 'config_entry_data': dict({ + 'connection_type': 'automatic', + 'individual_address': '0.0.240', + 'multicast_group': '224.0.23.12', + 'multicast_port': 3671, + 'rate_limit': 0, + 'state_updater': True, + }), + 'configuration_error': "extra keys not allowed @ data['knx']['wrong_key']", + 'configuration_yaml': dict({ + 'wrong_key': dict({ + }), + }), + 'project_info': None, + 'xknx': dict({ + 'current_address': '0.0.0', + 'version': '0.0.0', + }), + }) +# --- +# name: test_diagnostic_redact[hass_config0] + dict({ + 'config_entry_data': dict({ + 'backbone_key': '**REDACTED**', + 'connection_type': 'automatic', + 'device_authentication': '**REDACTED**', + 'individual_address': '0.0.240', + 'knxkeys_password': '**REDACTED**', + 'multicast_group': '224.0.23.12', + 'multicast_port': 3671, + 'rate_limit': 0, + 'state_updater': True, + 'user_password': '**REDACTED**', + }), + 'configuration_error': None, + 'configuration_yaml': None, + 'project_info': None, + 'xknx': dict({ + 'current_address': '0.0.0', + 'version': '0.0.0', + }), + }) +# --- +# name: test_diagnostics[hass_config0] + dict({ + 'config_entry_data': dict({ + 'connection_type': 'automatic', + 'individual_address': '0.0.240', + 'multicast_group': '224.0.23.12', + 'multicast_port': 3671, + 'rate_limit': 0, + 'state_updater': True, + }), + 'configuration_error': None, + 'configuration_yaml': None, + 'project_info': None, + 'xknx': dict({ + 'current_address': '0.0.0', + 'version': '0.0.0', + }), + }) +# --- +# name: test_diagnostics_project[hass_config0] + dict({ + 'config_entry_data': dict({ + 'connection_type': 'automatic', + 'individual_address': '0.0.240', + 'multicast_group': '224.0.23.12', + 'multicast_port': 3671, + 'rate_limit': 0, + 'state_updater': True, + }), + 'configuration_error': None, + 'configuration_yaml': None, + 'project_info': dict({ + 'created_by': 'ETS5', + 'group_address_style': 'ThreeLevel', + 'guid': '6a019e80-5945-489e-95a3-378735c642d1', + 'language_code': 'de-DE', + 'last_modified': '2023-04-30T09:04:04.4043671Z', + 'name': '**REDACTED**', + 'project_id': 'P-04FF', + 'schema_version': '20', + 'tool_version': '5.7.1428.39779', + 'xknxproject_version': '3.1.0', + }), + 'xknx': dict({ + 'current_address': '0.0.0', + 'version': '0.0.0', + }), + }) +# --- diff --git a/tests/components/knx/test_diagnostic.py b/tests/components/knx/test_diagnostic.py index df8cb71d4af..0b43433c01e 100644 --- a/tests/components/knx/test_diagnostic.py +++ b/tests/components/knx/test_diagnostic.py @@ -1,6 +1,7 @@ """Tests for the diagnostics data provided by the KNX integration.""" import pytest +from syrupy import SnapshotAssertion from xknx.io import DEFAULT_MCAST_GRP, DEFAULT_MCAST_PORT from homeassistant.components.knx.const import ( @@ -36,28 +37,17 @@ async def test_diagnostics( mock_config_entry: MockConfigEntry, knx: KNXTestKit, mock_hass_config: None, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" await knx.setup_integration({}) # Overwrite the version for this test since we don't want to change this with every library bump - knx.xknx.version = "1.0.0" - assert await get_diagnostics_for_config_entry( - hass, hass_client, mock_config_entry - ) == { - "config_entry_data": { - "connection_type": "automatic", - "individual_address": "0.0.240", - "multicast_group": "224.0.23.12", - "multicast_port": 3671, - "rate_limit": 0, - "state_updater": True, - }, - "configuration_error": None, - "configuration_yaml": None, - "project_info": None, - "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, - } + knx.xknx.version = "0.0.0" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) @pytest.mark.parametrize("hass_config", [{"knx": {"wrong_key": {}}}]) @@ -67,28 +57,18 @@ async def test_diagnostic_config_error( hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, knx: KNXTestKit, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" await knx.setup_integration({}) # Overwrite the version for this test since we don't want to change this with every library bump - knx.xknx.version = "1.0.0" - assert await get_diagnostics_for_config_entry( - hass, hass_client, mock_config_entry - ) == { - "config_entry_data": { - "connection_type": "automatic", - "individual_address": "0.0.240", - "multicast_group": "224.0.23.12", - "multicast_port": 3671, - "rate_limit": 0, - "state_updater": True, - }, - "configuration_error": "extra keys not allowed @ data['knx']['wrong_key']", - "configuration_yaml": {"wrong_key": {}}, - "project_info": None, - "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, - } + knx.xknx.version = "0.0.0" + # the snapshot will contain 'configuration_error' key with the voluptuous error message + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) @pytest.mark.parametrize("hass_config", [{}]) @@ -96,6 +76,7 @@ async def test_diagnostic_redact( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_hass_config: None, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics redacting data.""" mock_config_entry: MockConfigEntry = MockConfigEntry( @@ -118,27 +99,11 @@ async def test_diagnostic_redact( await knx.setup_integration({}) # Overwrite the version for this test since we don't want to change this with every library bump - knx.xknx.version = "1.0.0" - assert await get_diagnostics_for_config_entry( - hass, hass_client, mock_config_entry - ) == { - "config_entry_data": { - "connection_type": "automatic", - "individual_address": "0.0.240", - "multicast_group": "224.0.23.12", - "multicast_port": 3671, - "rate_limit": 0, - "state_updater": True, - "knxkeys_password": "**REDACTED**", - "user_password": "**REDACTED**", - "device_authentication": "**REDACTED**", - "backbone_key": "**REDACTED**", - }, - "configuration_error": None, - "configuration_yaml": None, - "project_info": None, - "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, - } + knx.xknx.version = "0.0.0" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) @pytest.mark.parametrize("hass_config", [{}]) @@ -149,21 +114,13 @@ async def test_diagnostics_project( knx: KNXTestKit, mock_hass_config: None, load_knxproj: None, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" await knx.setup_integration({}) - diag = await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) - - assert "config_entry_data" in diag - assert "configuration_error" in diag - assert "configuration_yaml" in diag - assert "project_info" in diag - assert "xknx" in diag - # project specific fields - assert "created_by" in diag["project_info"] - assert "group_address_style" in diag["project_info"] - assert "last_modified" in diag["project_info"] - assert "schema_version" in diag["project_info"] - assert "tool_version" in diag["project_info"] - assert "language_code" in diag["project_info"] - assert diag["project_info"]["name"] == "**REDACTED**" + knx.xknx.version = "0.0.0" + # snapshot will contain project specific fields in `project_info` + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) diff --git a/tests/components/lastfm/snapshots/test_sensor.ambr b/tests/components/lastfm/snapshots/test_sensor.ambr index e64cf6b2629..30ad40df428 100644 --- a/tests/components/lastfm/snapshots/test_sensor.ambr +++ b/tests/components/lastfm/snapshots/test_sensor.ambr @@ -4,14 +4,14 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Last.fm', 'entity_picture': 'image', - 'friendly_name': 'testaccount1', + 'friendly_name': 'LastFM testaccount1', 'icon': 'mdi:radio-fm', 'last_played': 'artist - title', 'play_count': 1, 'top_played': 'artist - title', }), 'context': , - 'entity_id': 'sensor.testaccount1', + 'entity_id': 'sensor.lastfm_testaccount1', 'last_changed': , 'last_updated': , 'state': 'artist - title', @@ -22,14 +22,14 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Last.fm', 'entity_picture': 'image', - 'friendly_name': 'testaccount1', + 'friendly_name': 'LastFM testaccount1', 'icon': 'mdi:radio-fm', 'last_played': None, 'play_count': 0, 'top_played': None, }), 'context': , - 'entity_id': 'sensor.testaccount1', + 'entity_id': 'sensor.lastfm_testaccount1', 'last_changed': , 'last_updated': , 'state': 'Not Scrobbling', diff --git a/tests/components/lastfm/test_init.py b/tests/components/lastfm/test_init.py index 8f731385e6f..2f126af11a3 100644 --- a/tests/components/lastfm/test_init.py +++ b/tests/components/lastfm/test_init.py @@ -20,11 +20,11 @@ async def test_load_unload_entry( await setup_integration(config_entry, default_user) entry = hass.config_entries.async_entries(DOMAIN)[0] - state = hass.states.get("sensor.testaccount1") + state = hass.states.get("sensor.lastfm_testaccount1") assert state await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("sensor.testaccount1") + state = hass.states.get("sensor.lastfm_testaccount1") assert not state diff --git a/tests/components/lastfm/test_sensor.py b/tests/components/lastfm/test_sensor.py index fa29862d012..f5723215e2a 100644 --- a/tests/components/lastfm/test_sensor.py +++ b/tests/components/lastfm/test_sensor.py @@ -55,7 +55,7 @@ async def test_sensors( user = request.getfixturevalue(fixture) await setup_integration(config_entry, user) - entity_id = "sensor.testaccount1" + entity_id = "sensor.lastfm_testaccount1" state = hass.states.get(entity_id) diff --git a/tests/components/lawn_mower/__init__.py b/tests/components/lawn_mower/__init__.py new file mode 100644 index 00000000000..0f96921206e --- /dev/null +++ b/tests/components/lawn_mower/__init__.py @@ -0,0 +1 @@ +"""Tests for the lawn mower integration.""" diff --git a/tests/components/lawn_mower/test_init.py b/tests/components/lawn_mower/test_init.py new file mode 100644 index 00000000000..39d594e1e17 --- /dev/null +++ b/tests/components/lawn_mower/test_init.py @@ -0,0 +1,178 @@ +"""The tests for the lawn mower integration.""" +from collections.abc import Generator +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.lawn_mower import ( + DOMAIN as LAWN_MOWER_DOMAIN, + LawnMowerActivity, + LawnMowerEntity, + LawnMowerEntityFeature, +) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +class MockLawnMowerEntity(LawnMowerEntity): + """Mock lawn mower device to use in tests.""" + + def __init__( + self, + unique_id: str = "mock_lawn_mower", + name: str = "Lawn Mower", + features: LawnMowerEntityFeature = LawnMowerEntityFeature(0), + ) -> None: + """Initialize the lawn mower.""" + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_supported_features = features + + def start_mowing(self) -> None: + """Start mowing.""" + self._attr_activity = LawnMowerActivity.MOWING + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +async def test_lawn_mower_setup(hass: HomeAssistant) -> None: + """Test setup and tear down of lawn mower platform and entity.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup( + config_entry, Platform.LAWN_MOWER + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload up test config entry.""" + await hass.config_entries.async_unload_platforms( + config_entry, [Platform.LAWN_MOWER] + ) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + entity1 = MockLawnMowerEntity() + entity1.entity_id = "lawn_mower.mock_lawn_mower" + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test platform via config entry.""" + async_add_entities([entity1]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{LAWN_MOWER_DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + assert hass.states.get(entity1.entity_id) + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + entity_state = hass.states.get(entity1.entity_id) + + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + +async def test_sync_start_mowing(hass: HomeAssistant) -> None: + """Test if async mowing calls sync mowing.""" + lawn_mower = MockLawnMowerEntity() + lawn_mower.hass = hass + + lawn_mower.start_mowing = MagicMock() + await lawn_mower.async_start_mowing() + + assert lawn_mower.start_mowing.called + + +async def test_sync_dock(hass: HomeAssistant) -> None: + """Test if async dock calls sync dock.""" + lawn_mower = MockLawnMowerEntity() + lawn_mower.hass = hass + + lawn_mower.dock = MagicMock() + await lawn_mower.async_dock() + + assert lawn_mower.dock.called + + +async def test_sync_pause(hass: HomeAssistant) -> None: + """Test if async pause calls sync pause.""" + lawn_mower = MockLawnMowerEntity() + lawn_mower.hass = hass + + lawn_mower.pause = MagicMock() + await lawn_mower.async_pause() + + assert lawn_mower.pause.called + + +async def test_lawn_mower_default(hass: HomeAssistant) -> None: + """Test lawn mower entity with defaults.""" + lawn_mower = MockLawnMowerEntity() + lawn_mower.hass = hass + + assert lawn_mower.state is None + + +async def test_lawn_mower_state(hass: HomeAssistant) -> None: + """Test lawn mower entity returns state.""" + lawn_mower = MockLawnMowerEntity( + "lawn_mower_1", "Test lawn mower", LawnMowerActivity.MOWING + ) + lawn_mower.hass = hass + lawn_mower.start_mowing() + + assert lawn_mower.state == str(LawnMowerActivity.MOWING) diff --git a/tests/components/lifx/test_binary_sensor.py b/tests/components/lifx/test_binary_sensor.py index 4b583eed475..d71a7eeaf0b 100644 --- a/tests/components/lifx/test_binary_sensor.py +++ b/tests/components/lifx/test_binary_sensor.py @@ -21,7 +21,6 @@ from homeassistant.util import dt as dt_util from . import ( DEFAULT_ENTRY_TITLE, IP_ADDRESS, - MAC_ADDRESS, SERIAL, _mocked_clean_bulb, _patch_config_flow_try_connect, @@ -38,7 +37,7 @@ async def test_hev_cycle_state(hass: HomeAssistant) -> None: domain=lifx.DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_clean_bulb() diff --git a/tests/components/lifx/test_button.py b/tests/components/lifx/test_button.py index b166aa05d66..d527229fe78 100644 --- a/tests/components/lifx/test_button.py +++ b/tests/components/lifx/test_button.py @@ -14,7 +14,6 @@ from homeassistant.setup import async_setup_component from . import ( DEFAULT_ENTRY_TITLE, IP_ADDRESS, - MAC_ADDRESS, SERIAL, _mocked_bulb, _patch_config_flow_try_connect, @@ -38,7 +37,7 @@ async def test_button_restart(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_bulb() @@ -70,7 +69,7 @@ async def test_button_identify(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_bulb() diff --git a/tests/components/lifx/test_diagnostics.py b/tests/components/lifx/test_diagnostics.py index 581f0516184..a72695502a4 100644 --- a/tests/components/lifx/test_diagnostics.py +++ b/tests/components/lifx/test_diagnostics.py @@ -7,7 +7,7 @@ from homeassistant.setup import async_setup_component from . import ( DEFAULT_ENTRY_TITLE, IP_ADDRESS, - MAC_ADDRESS, + SERIAL, _mocked_bulb, _mocked_clean_bulb, _mocked_infrared_bulb, @@ -30,7 +30,7 @@ async def test_bulb_diagnostics( domain=lifx.DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_bulb() @@ -77,7 +77,7 @@ async def test_clean_bulb_diagnostics( domain=lifx.DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_clean_bulb() @@ -129,7 +129,7 @@ async def test_infrared_bulb_diagnostics( domain=lifx.DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_infrared_bulb() @@ -177,7 +177,7 @@ async def test_legacy_multizone_bulb_diagnostics( domain=lifx.DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_light_strip() @@ -288,7 +288,7 @@ async def test_multizone_bulb_diagnostics( domain=lifx.DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_light_strip() diff --git a/tests/components/lifx/test_init.py b/tests/components/lifx/test_init.py index 3c813840faf..3f16cc44f41 100644 --- a/tests/components/lifx/test_init.py +++ b/tests/components/lifx/test_init.py @@ -5,6 +5,8 @@ from datetime import timedelta import socket from unittest.mock import patch +import pytest + from homeassistant.components import lifx from homeassistant.components.lifx import DOMAIN, discovery from homeassistant.config_entries import ConfigEntryState @@ -149,3 +151,23 @@ async def test_dns_error_at_startup(hass: HomeAssistant) -> None: await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) await hass.async_block_till_done() assert already_migrated_config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_wrong_serial( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test config entry enters setup retry when serial mismatches.""" + mismatched_serial = f"{SERIAL[:-1]}0" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=mismatched_serial + ) + already_migrated_config_entry.add_to_hass(hass) + with _patch_discovery(), _patch_config_flow_try_connect(), _patch_device(): + await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}}) + await hass.async_block_till_done() + assert already_migrated_config_entry.state == ConfigEntryState.SETUP_RETRY + + assert ( + "Unexpected device found at 127.0.0.1; expected aa:bb:cc:dd:ee:c0, found aa:bb:cc:dd:ee:cc" + in caplog.text + ) diff --git a/tests/components/lifx/test_select.py b/tests/components/lifx/test_select.py index d190cbe6b10..aa705418d55 100644 --- a/tests/components/lifx/test_select.py +++ b/tests/components/lifx/test_select.py @@ -13,7 +13,6 @@ from homeassistant.util import dt as dt_util from . import ( DEFAULT_ENTRY_TITLE, IP_ADDRESS, - MAC_ADDRESS, SERIAL, MockLifxCommand, _mocked_infrared_bulb, @@ -32,7 +31,7 @@ async def test_theme_select(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_light_strip() @@ -70,7 +69,7 @@ async def test_infrared_brightness(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_infrared_bulb() @@ -100,7 +99,7 @@ async def test_set_infrared_brightness_25_percent(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_infrared_bulb() @@ -139,7 +138,7 @@ async def test_set_infrared_brightness_50_percent(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_infrared_bulb() @@ -178,7 +177,7 @@ async def test_set_infrared_brightness_100_percent(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_infrared_bulb() @@ -217,7 +216,7 @@ async def test_disable_infrared(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_infrared_bulb() @@ -256,7 +255,7 @@ async def test_invalid_infrared_brightness(hass: HomeAssistant) -> None: domain=DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_infrared_bulb() diff --git a/tests/components/lifx/test_sensor.py b/tests/components/lifx/test_sensor.py index a36e151849b..5fe69c8dabc 100644 --- a/tests/components/lifx/test_sensor.py +++ b/tests/components/lifx/test_sensor.py @@ -20,7 +20,7 @@ from homeassistant.util import dt as dt_util from . import ( DEFAULT_ENTRY_TITLE, IP_ADDRESS, - MAC_ADDRESS, + SERIAL, _mocked_bulb, _mocked_bulb_old_firmware, _patch_config_flow_try_connect, @@ -38,7 +38,7 @@ async def test_rssi_sensor(hass: HomeAssistant) -> None: domain=lifx.DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_bulb() @@ -89,7 +89,7 @@ async def test_rssi_sensor_old_firmware(hass: HomeAssistant) -> None: domain=lifx.DOMAIN, title=DEFAULT_ENTRY_TITLE, data={CONF_HOST: IP_ADDRESS}, - unique_id=MAC_ADDRESS, + unique_id=SERIAL, ) config_entry.add_to_hass(hass) bulb = _mocked_bulb_old_firmware() diff --git a/tests/components/litejet/test_config_flow.py b/tests/components/litejet/test_config_flow.py index 69cd1d3d2e3..e2b2829de9e 100644 --- a/tests/components/litejet/test_config_flow.py +++ b/tests/components/litejet/test_config_flow.py @@ -6,7 +6,8 @@ from serial import SerialException from homeassistant import config_entries, data_entry_flow from homeassistant.components.litejet.const import CONF_DEFAULT_TRANSITION, DOMAIN from homeassistant.const import CONF_PORT -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +import homeassistant.helpers.issue_registry as ir from tests.common import MockConfigEntry @@ -67,7 +68,7 @@ async def test_flow_open_failed(hass: HomeAssistant) -> None: assert result["errors"][CONF_PORT] == "open_failed" -async def test_import_step(hass: HomeAssistant) -> None: +async def test_import_step(hass: HomeAssistant, mock_litejet) -> None: """Test initializing via import step.""" test_data = {CONF_PORT: "/dev/imported"} result = await hass.config_entries.flow.async_init( @@ -78,6 +79,51 @@ async def test_import_step(hass: HomeAssistant) -> None: assert result["title"] == test_data[CONF_PORT] assert result["data"] == test_data + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_yaml_litejet" + ) + assert issue.translation_key == "deprecated_yaml" + + +async def test_import_step_fails(hass: HomeAssistant) -> None: + """Test initializing via import step fails due to can't open port.""" + test_data = {CONF_PORT: "/dev/test"} + with patch("pylitejet.LiteJet") as mock_pylitejet: + mock_pylitejet.side_effect = SerialException + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=test_data + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {"port": "open_failed"} + + issue_registry = ir.async_get(hass) + assert issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_serial_exception") + + +async def test_import_step_already_exist(hass: HomeAssistant) -> None: + """Test initializing via import step when entry already exist.""" + first_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_PORT: "/dev/imported"}, + ) + first_entry.add_to_hass(hass) + + test_data = {CONF_PORT: "/dev/imported"} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=test_data + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_yaml_litejet" + ) + assert issue.translation_key == "deprecated_yaml" + async def test_options(hass: HomeAssistant) -> None: """Test updating options.""" diff --git a/tests/components/melnor/conftest.py b/tests/components/melnor/conftest.py index ab51bf44a57..3e87a4e646f 100644 --- a/tests/components/melnor/conftest.py +++ b/tests/components/melnor/conftest.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Generator -from datetime import datetime, time, timedelta, timezone +from datetime import UTC, datetime, time, timedelta from unittest.mock import AsyncMock, patch from melnor_bluetooth.device import Device @@ -73,7 +73,7 @@ class MockFrequency: self._interval = 0 self._is_watering = False self._start_time = time(12, 0) - self._next_run_time = datetime(2021, 1, 1, 12, 0, tzinfo=timezone.utc) + self._next_run_time = datetime(2021, 1, 1, 12, 0, tzinfo=UTC) @property def duration_minutes(self) -> int: diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 18a0a860ad4..9e0363b3611 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -70,7 +70,6 @@ from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient ENTITY_CLIMATE = "climate.test" - DEFAULT_CONFIG = { mqtt.DOMAIN: { climate.DOMAIN: { @@ -82,7 +81,6 @@ DEFAULT_CONFIG = { "temperature_high_command_topic": "temperature-high-topic", "fan_mode_command_topic": "fan-mode-topic", "swing_mode_command_topic": "swing-mode-topic", - "aux_command_topic": "aux-topic", "preset_mode_command_topic": "preset-mode-topic", "preset_modes": [ "eco", @@ -225,7 +223,6 @@ async def test_supported_features( | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.AUX_HEAT | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TARGET_HUMIDITY ) @@ -1249,11 +1246,16 @@ async def test_set_preset_mode_pessimistic( assert state.attributes.get("preset_mode") == "home" +# Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC +# and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 +# Support will be removed in HA Core 2024.3 @pytest.mark.parametrize( "hass_config", [ help_custom_config( - climate.DOMAIN, DEFAULT_CONFIG, ({"aux_state_topic": "aux-state"},) + climate.DOMAIN, + DEFAULT_CONFIG, + ({"aux_command_topic": "aux-topic", "aux_state_topic": "aux-state"},), ) ], ) @@ -1283,7 +1285,18 @@ async def test_set_aux_pessimistic( assert state.attributes.get("aux_heat") == "off" -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +# Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC +# and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 +# Support will be removed in HA Core 2024.3 +# "aux_command_topic": "aux-topic" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + climate.DOMAIN, DEFAULT_CONFIG, ({"aux_command_topic": "aux-topic"},) + ) + ], +) async def test_set_aux( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: @@ -1303,6 +1316,18 @@ async def test_set_aux( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("aux_heat") == "off" + support = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.AUX_HEAT + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TARGET_HUMIDITY + ) + + assert state.attributes.get("supported_features") == support + @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( @@ -1548,6 +1573,10 @@ async def test_get_with_templates( assert state.attributes.get("preset_mode") == "eco" # Aux mode + + # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC + # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 + # Support will be removed in HA Core 2024.3 assert state.attributes.get("aux_heat") == "off" async_fire_mqtt_message(hass, "aux-state", "switchmeon") state = hass.states.get(ENTITY_CLIMATE) diff --git a/tests/components/nexia/test_binary_sensor.py b/tests/components/nexia/test_binary_sensor.py index 78753383b03..f59e968d634 100644 --- a/tests/components/nexia/test_binary_sensor.py +++ b/tests/components/nexia/test_binary_sensor.py @@ -14,7 +14,7 @@ async def test_create_binary_sensors(hass: HomeAssistant) -> None: assert state.state == STATE_ON expected_attributes = { "attribution": "Data provided by Trane Technologies", - "friendly_name": "Master Suite Blower Active", + "friendly_name": "Master Suite Blower active", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -26,7 +26,7 @@ async def test_create_binary_sensors(hass: HomeAssistant) -> None: assert state.state == STATE_OFF expected_attributes = { "attribution": "Data provided by Trane Technologies", - "friendly_name": "Downstairs East Wing Blower Active", + "friendly_name": "Downstairs East Wing Blower active", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears diff --git a/tests/components/nexia/test_init.py b/tests/components/nexia/test_init.py index 5409181f00e..f920592f8a6 100644 --- a/tests/components/nexia/test_init.py +++ b/tests/components/nexia/test_init.py @@ -53,7 +53,7 @@ async def test_device_remove_devices( is False ) - entity = registry.entities["sensor.master_suite_relative_humidity"] + entity = registry.entities["sensor.master_suite_humidity"] live_thermostat_device_entry = device_registry.async_get(entity.device_id) assert ( await remove_device( diff --git a/tests/components/nexia/test_sensor.py b/tests/components/nexia/test_sensor.py index 4d693261a9d..23a92af71c8 100644 --- a/tests/components/nexia/test_sensor.py +++ b/tests/components/nexia/test_sensor.py @@ -29,7 +29,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: assert state.state == "Permanent Hold" expected_attributes = { "attribution": "Data provided by Trane Technologies", - "friendly_name": "Nick Office Zone Setpoint Status", + "friendly_name": "Nick Office Zone setpoint status", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -42,7 +42,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: expected_attributes = { "attribution": "Data provided by Trane Technologies", - "friendly_name": "Nick Office Zone Status", + "friendly_name": "Nick Office Zone status", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -55,7 +55,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: expected_attributes = { "attribution": "Data provided by Trane Technologies", - "friendly_name": "Master Suite Air Cleaner Mode", + "friendly_name": "Master Suite Air cleaner mode", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears @@ -68,7 +68,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: expected_attributes = { "attribution": "Data provided by Trane Technologies", - "friendly_name": "Master Suite Current Compressor Speed", + "friendly_name": "Master Suite Current compressor speed", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -83,7 +83,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: expected_attributes = { "attribution": "Data provided by Trane Technologies", "device_class": "temperature", - "friendly_name": "Master Suite Outdoor Temperature", + "friendly_name": "Master Suite Outdoor temperature", "unit_of_measurement": UnitOfTemperature.CELSIUS, } # Only test for a subset of attributes in case @@ -92,13 +92,13 @@ async def test_create_sensors(hass: HomeAssistant) -> None: state.attributes[key] == expected_attributes[key] for key in expected_attributes ) - state = hass.states.get("sensor.master_suite_relative_humidity") + state = hass.states.get("sensor.master_suite_humidity") assert state.state == "52.0" expected_attributes = { "attribution": "Data provided by Trane Technologies", "device_class": "humidity", - "friendly_name": "Master Suite Relative Humidity", + "friendly_name": "Master Suite Humidity", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -112,7 +112,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: expected_attributes = { "attribution": "Data provided by Trane Technologies", - "friendly_name": "Master Suite Requested Compressor Speed", + "friendly_name": "Master Suite Requested compressor speed", "unit_of_measurement": PERCENTAGE, } # Only test for a subset of attributes in case @@ -126,7 +126,7 @@ async def test_create_sensors(hass: HomeAssistant) -> None: expected_attributes = { "attribution": "Data provided by Trane Technologies", - "friendly_name": "Master Suite System Status", + "friendly_name": "Master Suite System status", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears diff --git a/tests/components/nina/test_binary_sensor.py b/tests/components/nina/test_binary_sensor.py index c6fd5bdd830..8532415c6b1 100644 --- a/tests/components/nina/test_binary_sensor.py +++ b/tests/components/nina/test_binary_sensor.py @@ -6,6 +6,7 @@ from unittest.mock import patch from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.nina.const import ( + ATTR_AFFECTED_AREAS, ATTR_DESCRIPTION, ATTR_EXPIRES, ATTR_HEADLINE, @@ -38,6 +39,13 @@ ENTRY_DATA_NO_CORONA: dict[str, Any] = { "regions": {"083350000000": "Aach, Stadt"}, } +ENTRY_DATA_NO_AREA: dict[str, Any] = { + "slots": 5, + "corona_filter": False, + "area_filter": ".*nagold.*", + "regions": {"083350000000": "Aach, Stadt"}, +} + async def test_sensors(hass: HomeAssistant) -> None: """Test the creation and values of the NINA sensors.""" @@ -70,6 +78,10 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state_w1.attributes.get(ATTR_SENDER) == "Deutscher Wetterdienst" assert state_w1.attributes.get(ATTR_SEVERITY) == "Minor" assert state_w1.attributes.get(ATTR_RECOMMENDED_ACTIONS) == "" + assert ( + state_w1.attributes.get(ATTR_AFFECTED_AREAS) + == "Gemeinde Oberreichenbach, Gemeinde Neuweiler, Stadt Nagold, Stadt Neubulach, Gemeinde Schömberg, Gemeinde Simmersfeld, Gemeinde Simmozheim, Gemeinde Rohrdorf, Gemeinde Ostelsheim, Gemeinde Ebhausen, Gemeinde Egenhausen, Gemeinde Dobel, Stadt Bad Liebenzell, Stadt Solingen, Stadt Haiterbach, Stadt Bad Herrenalb, Gemeinde Höfen an der Enz, Gemeinde Gechingen, Gemeinde Enzklösterle, Gemeinde Gutach (Schwarzwaldbahn) und 3392 weitere." + ) assert state_w1.attributes.get(ATTR_ID) == "mow.DE-NW-BN-SE030-20201014-30-000" assert state_w1.attributes.get(ATTR_SENT) == "2021-10-11T05:20:00+01:00" assert state_w1.attributes.get(ATTR_START) == "2021-11-01T05:20:00+01:00" @@ -87,6 +99,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state_w2.attributes.get(ATTR_SENDER) is None assert state_w2.attributes.get(ATTR_SEVERITY) is None assert state_w2.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w2.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w2.attributes.get(ATTR_ID) is None assert state_w2.attributes.get(ATTR_SENT) is None assert state_w2.attributes.get(ATTR_START) is None @@ -104,6 +117,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state_w3.attributes.get(ATTR_SENDER) is None assert state_w3.attributes.get(ATTR_SEVERITY) is None assert state_w3.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w3.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w3.attributes.get(ATTR_ID) is None assert state_w3.attributes.get(ATTR_SENT) is None assert state_w3.attributes.get(ATTR_START) is None @@ -121,6 +135,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state_w4.attributes.get(ATTR_SENDER) is None assert state_w4.attributes.get(ATTR_SEVERITY) is None assert state_w4.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w4.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w4.attributes.get(ATTR_ID) is None assert state_w4.attributes.get(ATTR_SENT) is None assert state_w4.attributes.get(ATTR_START) is None @@ -138,6 +153,7 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state_w5.attributes.get(ATTR_SENDER) is None assert state_w5.attributes.get(ATTR_SEVERITY) is None assert state_w5.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w5.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w5.attributes.get(ATTR_ID) is None assert state_w5.attributes.get(ATTR_SENT) is None assert state_w5.attributes.get(ATTR_START) is None @@ -184,6 +200,10 @@ async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: state_w1.attributes.get(ATTR_RECOMMENDED_ACTIONS) == "Waschen sich regelmäßig und gründlich die Hände." ) + assert ( + state_w1.attributes.get(ATTR_AFFECTED_AREAS) + == "Bundesland: Freie Hansestadt Bremen, Land Berlin, Land Hessen, Land Nordrhein-Westfalen, Land Brandenburg, Freistaat Bayern, Land Mecklenburg-Vorpommern, Land Rheinland-Pfalz, Freistaat Sachsen, Land Schleswig-Holstein, Freie und Hansestadt Hamburg, Freistaat Thüringen, Land Niedersachsen, Land Saarland, Land Sachsen-Anhalt, Land Baden-Württemberg" + ) assert state_w1.attributes.get(ATTR_ID) == "mow.DE-BW-S-SE018-20211102-18-001" assert state_w1.attributes.get(ATTR_SENT) == "2021-11-02T20:07:16+01:00" assert state_w1.attributes.get(ATTR_START) == "" @@ -201,6 +221,10 @@ async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: state_w2.attributes.get(ATTR_DESCRIPTION) == "Es treten Sturmböen mit Geschwindigkeiten zwischen 70 km/h (20m/s, 38kn, Bft 8) und 85 km/h (24m/s, 47kn, Bft 9) aus westlicher Richtung auf. In Schauernähe sowie in exponierten Lagen muss mit schweren Sturmböen bis 90 km/h (25m/s, 48kn, Bft 10) gerechnet werden." ) + assert ( + state_w2.attributes.get(ATTR_AFFECTED_AREAS) + == "Gemeinde Oberreichenbach, Gemeinde Neuweiler, Stadt Nagold, Stadt Neubulach, Gemeinde Schömberg, Gemeinde Simmersfeld, Gemeinde Simmozheim, Gemeinde Rohrdorf, Gemeinde Ostelsheim, Gemeinde Ebhausen, Gemeinde Egenhausen, Gemeinde Dobel, Stadt Bad Liebenzell, Stadt Solingen, Stadt Haiterbach, Stadt Bad Herrenalb, Gemeinde Höfen an der Enz, Gemeinde Gechingen, Gemeinde Enzklösterle, Gemeinde Gutach (Schwarzwaldbahn) und 3392 weitere." + ) assert state_w2.attributes.get(ATTR_SENDER) == "Deutscher Wetterdienst" assert state_w2.attributes.get(ATTR_SEVERITY) == "Minor" assert state_w2.attributes.get(ATTR_RECOMMENDED_ACTIONS) == "" @@ -221,6 +245,7 @@ async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: assert state_w3.attributes.get(ATTR_SENDER) is None assert state_w3.attributes.get(ATTR_SEVERITY) is None assert state_w3.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w3.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w3.attributes.get(ATTR_ID) is None assert state_w3.attributes.get(ATTR_SENT) is None assert state_w3.attributes.get(ATTR_START) is None @@ -238,6 +263,7 @@ async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: assert state_w4.attributes.get(ATTR_SENDER) is None assert state_w4.attributes.get(ATTR_SEVERITY) is None assert state_w4.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w4.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w4.attributes.get(ATTR_ID) is None assert state_w4.attributes.get(ATTR_SENT) is None assert state_w4.attributes.get(ATTR_START) is None @@ -255,6 +281,7 @@ async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: assert state_w5.attributes.get(ATTR_SENDER) is None assert state_w5.attributes.get(ATTR_SEVERITY) is None assert state_w5.attributes.get(ATTR_RECOMMENDED_ACTIONS) is None + assert state_w5.attributes.get(ATTR_AFFECTED_AREAS) is None assert state_w5.attributes.get(ATTR_ID) is None assert state_w5.attributes.get(ATTR_SENT) is None assert state_w5.attributes.get(ATTR_START) is None @@ -262,3 +289,63 @@ async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: assert entry_w5.unique_id == "083350000000-5" assert state_w5.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + +async def test_sensors_with_area_filter(hass: HomeAssistant) -> None: + """Test the creation and values of the NINA sensors with an area filter.""" + + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + wraps=mocked_request_function, + ): + conf_entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, title="NINA", data=ENTRY_DATA_NO_AREA + ) + + entity_registry: er = er.async_get(hass) + conf_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(conf_entry.entry_id) + await hass.async_block_till_done() + + assert conf_entry.state == ConfigEntryState.LOADED + + state_w1 = hass.states.get("binary_sensor.warning_aach_stadt_1") + entry_w1 = entity_registry.async_get("binary_sensor.warning_aach_stadt_1") + + assert state_w1.state == STATE_ON + + assert entry_w1.unique_id == "083350000000-1" + assert state_w1.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + state_w2 = hass.states.get("binary_sensor.warning_aach_stadt_2") + entry_w2 = entity_registry.async_get("binary_sensor.warning_aach_stadt_2") + + assert state_w2.state == STATE_OFF + + assert entry_w2.unique_id == "083350000000-2" + assert state_w2.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + state_w3 = hass.states.get("binary_sensor.warning_aach_stadt_3") + entry_w3 = entity_registry.async_get("binary_sensor.warning_aach_stadt_3") + + assert state_w3.state == STATE_OFF + + assert entry_w3.unique_id == "083350000000-3" + assert state_w3.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + state_w4 = hass.states.get("binary_sensor.warning_aach_stadt_4") + entry_w4 = entity_registry.async_get("binary_sensor.warning_aach_stadt_4") + + assert state_w4.state == STATE_OFF + + assert entry_w4.unique_id == "083350000000-4" + assert state_w4.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY + + state_w5 = hass.states.get("binary_sensor.warning_aach_stadt_5") + entry_w5 = entity_registry.async_get("binary_sensor.warning_aach_stadt_5") + + assert state_w5.state == STATE_OFF + + assert entry_w5.unique_id == "083350000000-5" + assert state_w5.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py index 194f0298dd5..aad24691f42 100644 --- a/tests/components/nina/test_config_flow.py +++ b/tests/components/nina/test_config_flow.py @@ -10,6 +10,7 @@ from pynina import ApiError from homeassistant import data_entry_flow from homeassistant.components.nina.const import ( + CONF_AREA_FILTER, CONF_HEADLINE_FILTER, CONF_MESSAGE_SLOTS, CONF_REGIONS, @@ -38,6 +39,7 @@ DUMMY_DATA: dict[str, Any] = { CONST_REGION_R_TO_U: ["072320000000_0", "072320000000_1"], CONST_REGION_V_TO_Z: ["081270000000_0", "081270000000_1"], CONF_HEADLINE_FILTER: ".*corona.*", + CONF_AREA_FILTER: ".*", } DUMMY_RESPONSE_REGIONS: dict[str, Any] = json.loads( @@ -146,6 +148,7 @@ async def test_options_flow_init(hass: HomeAssistant) -> None: title="NINA", data={ CONF_HEADLINE_FILTER: deepcopy(DUMMY_DATA[CONF_HEADLINE_FILTER]), + CONF_AREA_FILTER: deepcopy(DUMMY_DATA[CONF_AREA_FILTER]), CONF_MESSAGE_SLOTS: deepcopy(DUMMY_DATA[CONF_MESSAGE_SLOTS]), CONST_REGION_A_TO_D: deepcopy(DUMMY_DATA[CONST_REGION_A_TO_D]), CONF_REGIONS: {"095760000000": "Aach"}, @@ -184,6 +187,7 @@ async def test_options_flow_init(hass: HomeAssistant) -> None: assert dict(config_entry.data) == { CONF_HEADLINE_FILTER: deepcopy(DUMMY_DATA[CONF_HEADLINE_FILTER]), + CONF_AREA_FILTER: deepcopy(DUMMY_DATA[CONF_AREA_FILTER]), CONF_MESSAGE_SLOTS: deepcopy(DUMMY_DATA[CONF_MESSAGE_SLOTS]), CONST_REGION_A_TO_D: ["072350000000_1"], CONST_REGION_E_TO_H: [], diff --git a/tests/components/nina/test_init.py b/tests/components/nina/test_init.py index 826b8e422ed..da73c8d8711 100644 --- a/tests/components/nina/test_init.py +++ b/tests/components/nina/test_init.py @@ -16,6 +16,7 @@ from tests.common import MockConfigEntry ENTRY_DATA: dict[str, Any] = { "slots": 5, "headline_filter": ".*corona.*", + "area_filter": ".*", "regions": {"083350000000": "Aach, Stadt"}, } diff --git a/tests/components/nws/snapshots/test_weather.ambr b/tests/components/nws/snapshots/test_weather.ambr new file mode 100644 index 00000000000..0dddca954be --- /dev/null +++ b/tests/components/nws/snapshots/test_weather.ambr @@ -0,0 +1,229 @@ +# serializer version: 1 +# name: test_forecast_service + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service.1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service.2 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service.3 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service.4 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_service.5 + dict({ + 'forecast': list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]), + }) +# --- +# name: test_forecast_subscription[hourly-weather.abc_daynight] + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]) +# --- +# name: test_forecast_subscription[hourly-weather.abc_daynight].1 + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]) +# --- +# name: test_forecast_subscription[hourly] + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]) +# --- +# name: test_forecast_subscription[hourly].1 + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]) +# --- +# name: test_forecast_subscription[twice_daily-weather.abc_hourly] + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]) +# --- +# name: test_forecast_subscription[twice_daily-weather.abc_hourly].1 + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]) +# --- +# name: test_forecast_subscription[twice_daily] + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]) +# --- +# name: test_forecast_subscription[twice_daily].1 + list([ + dict({ + 'condition': 'lightning-rainy', + 'datetime': '2019-08-12T20:00:00-04:00', + 'detailed_description': 'A detailed forecast.', + 'dew_point': -15.6, + 'humidity': 75, + 'is_daytime': False, + 'precipitation_probability': 89, + 'temperature': -12.2, + 'wind_bearing': 180, + 'wind_speed': 16.09, + }), + ]) +# --- diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index 06d2c2006d8..54069eec02c 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -3,7 +3,9 @@ from datetime import timedelta from unittest.mock import patch import aiohttp +from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import nws from homeassistant.components.weather import ( @@ -11,6 +13,7 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_FORECAST, DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, ) from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -31,6 +34,7 @@ from .const import ( ) from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator @pytest.mark.parametrize( @@ -354,10 +358,10 @@ async def test_error_forecast_hourly( assert state.state == ATTR_CONDITION_SUNNY -async def test_forecast_hourly_disable_enable( - hass: HomeAssistant, mock_simple_nws, no_sensor -) -> None: - """Test error during update forecast hourly.""" +async def test_new_config_entry(hass: HomeAssistant, no_sensor) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + entry = MockConfigEntry( domain=nws.DOMAIN, data=NWS_CONFIG, @@ -367,17 +371,203 @@ async def test_forecast_hourly_disable_enable( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert len(hass.states.async_entity_ids("weather")) == 1 + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 1 + + +async def test_legacy_config_entry(hass: HomeAssistant, no_sensor) -> None: + """Test the expected entities are created.""" registry = er.async_get(hass) - entry = registry.async_get_or_create( + # Pre-create the hourly entity + registry.async_get_or_create( WEATHER_DOMAIN, nws.DOMAIN, "35_-75_hourly", ) - assert entry.disabled is True - # Test enabling entity - updated_entry = registry.async_update_entity( - entry.entity_id, **{"disabled_by": None} + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, ) - assert updated_entry != entry - assert updated_entry.disabled is False + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids("weather")) == 2 + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2 + + +async def test_forecast_service( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + mock_simple_nws, + no_sensor, +) -> None: + """Test multiple forecast.""" + instance = mock_simple_nws.return_value + + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + instance.update_observation.assert_called_once() + instance.update_forecast.assert_called_once() + instance.update_forecast_hourly.assert_called_once() + + for forecast_type in ("twice_daily", "hourly"): + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.abc_daynight", + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot + + # Calling the services should use cached data + instance.update_observation.assert_called_once() + instance.update_forecast.assert_called_once() + instance.update_forecast_hourly.assert_called_once() + + # Trigger data refetch + freezer.tick(nws.DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert instance.update_observation.call_count == 2 + assert instance.update_forecast.call_count == 2 + assert instance.update_forecast_hourly.call_count == 1 + + for forecast_type in ("twice_daily", "hourly"): + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.abc_daynight", + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot + + # Calling the services should update the hourly forecast + assert instance.update_observation.call_count == 2 + assert instance.update_forecast.call_count == 2 + assert instance.update_forecast_hourly.call_count == 2 + + # third update fails, but data is cached + instance.update_forecast_hourly.side_effect = aiohttp.ClientError + freezer.tick(nws.DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.abc_daynight", + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot + + # after additional 35 minutes data caching expires, data is no longer shown + freezer.tick(timedelta(minutes=35)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.abc_daynight", + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] == [] + + +@pytest.mark.parametrize( + ("forecast_type", "entity_id"), + [("hourly", "weather.abc_daynight"), ("twice_daily", "weather.abc_hourly")], +) +async def test_forecast_subscription( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + mock_simple_nws, + no_sensor, + forecast_type: str, + entity_id: str, +) -> None: + """Test multiple forecast.""" + client = await hass_ws_client(hass) + + registry = er.async_get(hass) + # Pre-create the hourly entity + registry.async_get_or_create( + WEATHER_DOMAIN, + nws.DOMAIN, + "35_-75_hourly", + suggested_object_id="abc_hourly", + ) + + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": forecast_type, + "entity_id": entity_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert forecast1 != [] + assert forecast1 == snapshot + + freezer.tick(nws.DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) + await hass.async_block_till_done() + msg = await client.receive_json() + + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast2 = msg["event"]["forecast"] + + assert forecast2 != [] + assert forecast2 == snapshot diff --git a/tests/components/nzbget/test_sensor.py b/tests/components/nzbget/test_sensor.py index 980fe14970a..e9365e36b24 100644 --- a/tests/components/nzbget/test_sensor.py +++ b/tests/components/nzbget/test_sensor.py @@ -34,14 +34,14 @@ async def test_sensors(hass: HomeAssistant, nzbget_api) -> None: ), "average_speed": ( "AverageDownloadRate", - "1.19", + "1.250000", UnitOfDataRate.MEGABYTES_PER_SECOND, SensorDeviceClass.DATA_RATE, ), "download_paused": ("DownloadPaused", "False", None, None), "speed": ( "DownloadRate", - "2.38", + "2.500000", UnitOfDataRate.MEGABYTES_PER_SECOND, SensorDeviceClass.DATA_RATE, ), @@ -68,7 +68,7 @@ async def test_sensors(hass: HomeAssistant, nzbget_api) -> None: "uptime": ("UpTimeSec", uptime.isoformat(), None, SensorDeviceClass.TIMESTAMP), "speed_limit": ( "DownloadLimit", - "0.95", + "1.000000", UnitOfDataRate.MEGABYTES_PER_SECOND, SensorDeviceClass.DATA_RATE, ), diff --git a/tests/components/octoprint/test_sensor.py b/tests/components/octoprint/test_sensor.py index a388aeae106..2ba657c77d5 100644 --- a/tests/components/octoprint/test_sensor.py +++ b/tests/components/octoprint/test_sensor.py @@ -1,5 +1,5 @@ """The tests for Octoptint binary sensor module.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import patch from homeassistant.core import HomeAssistant @@ -24,7 +24,7 @@ async def test_sensors(hass: HomeAssistant) -> None: } with patch( "homeassistant.util.dt.utcnow", - return_value=datetime(2020, 2, 20, 9, 10, 13, 543, tzinfo=timezone.utc), + return_value=datetime(2020, 2, 20, 9, 10, 13, 543, tzinfo=UTC), ): await init_integration(hass, "sensor", printer=printer, job=job) diff --git a/tests/components/opensky/__init__.py b/tests/components/opensky/__init__.py index f985f068ab1..e746521c72c 100644 --- a/tests/components/opensky/__init__.py +++ b/tests/components/opensky/__init__.py @@ -1,9 +1,20 @@ """Opensky tests.""" +import json from unittest.mock import patch +from python_opensky import StatesResponse + +from tests.common import load_fixture + def patch_setup_entry() -> bool: """Patch interface.""" return patch( "homeassistant.components.opensky.async_setup_entry", return_value=True ) + + +def get_states_response_fixture(fixture: str) -> StatesResponse: + """Return the states response from json.""" + json_fixture = load_fixture(fixture) + return StatesResponse.parse_obj(json.loads(json_fixture)) diff --git a/tests/components/opensky/conftest.py b/tests/components/opensky/conftest.py index 7cf3074a2a3..f74c18773f5 100644 --- a/tests/components/opensky/conftest.py +++ b/tests/components/opensky/conftest.py @@ -6,8 +6,18 @@ from unittest.mock import patch import pytest from python_opensky import StatesResponse -from homeassistant.components.opensky.const import CONF_ALTITUDE, DOMAIN -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS +from homeassistant.components.opensky.const import ( + CONF_ALTITUDE, + CONF_CONTRIBUTING_USER, + DOMAIN, +) +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_PASSWORD, + CONF_RADIUS, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -50,6 +60,26 @@ def mock_config_entry_altitude() -> MockConfigEntry: ) +@pytest.fixture(name="config_entry_authenticated") +def mock_config_entry_authenticated() -> MockConfigEntry: + """Create authenticated Opensky entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title="OpenSky", + data={ + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + }, + options={ + CONF_RADIUS: 10.0, + CONF_ALTITUDE: 12500.0, + CONF_USERNAME: "asd", + CONF_PASSWORD: "secret", + CONF_CONTRIBUTING_USER: True, + }, + ) + + @pytest.fixture(name="setup_integration") async def mock_setup_integration( hass: HomeAssistant, diff --git a/tests/components/opensky/test_config_flow.py b/tests/components/opensky/test_config_flow.py index 5dee2764cff..7fa19762ddf 100644 --- a/tests/components/opensky/test_config_flow.py +++ b/tests/components/opensky/test_config_flow.py @@ -1,15 +1,31 @@ """Test OpenSky config flow.""" from typing import Any +from unittest.mock import patch import pytest +from python_opensky.exceptions import OpenSkyUnauthenticatedError -from homeassistant.components.opensky.const import CONF_ALTITUDE, DEFAULT_NAME, DOMAIN +from homeassistant import data_entry_flow +from homeassistant.components.opensky.const import ( + CONF_ALTITUDE, + CONF_CONTRIBUTING_USER, + DEFAULT_NAME, + DOMAIN, +) from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_RADIUS +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + CONF_PASSWORD, + CONF_RADIUS, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import patch_setup_entry +from . import get_states_response_fixture, patch_setup_entry +from .conftest import ComponentSetup from tests.common import MockConfigEntry @@ -149,3 +165,109 @@ async def test_importing_already_exists_flow(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("user_input", "error"), + [ + ( + {CONF_USERNAME: "homeassistant", CONF_CONTRIBUTING_USER: False}, + "password_missing", + ), + ({CONF_PASSWORD: "secret", CONF_CONTRIBUTING_USER: False}, "username_missing"), + ({CONF_CONTRIBUTING_USER: True}, "no_authentication"), + ( + { + CONF_USERNAME: "homeassistant", + CONF_PASSWORD: "secret", + CONF_CONTRIBUTING_USER: True, + }, + "invalid_auth", + ), + ], +) +async def test_options_flow_failures( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, + user_input: dict[str, Any], + error: str, +) -> None: + """Test load and unload entry.""" + await setup_integration(config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + with patch( + "python_opensky.OpenSky.authenticate", + side_effect=OpenSkyUnauthenticatedError(), + ): + result = await hass.config_entries.options.async_init(entry.entry_id) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_RADIUS: 10000, **user_input}, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"]["base"] == error + with patch("python_opensky.OpenSky.authenticate"), patch( + "python_opensky.OpenSky.get_states", + return_value=get_states_response_fixture("opensky/states_1.json"), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_RADIUS: 10000, + CONF_USERNAME: "homeassistant", + CONF_PASSWORD: "secret", + CONF_CONTRIBUTING_USER: True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RADIUS: 10000, + CONF_USERNAME: "homeassistant", + CONF_PASSWORD: "secret", + CONF_CONTRIBUTING_USER: True, + } + + +async def test_options_flow( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, +) -> None: + """Test options flow.""" + await setup_integration(config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + result = await hass.config_entries.options.async_init(entry.entry_id) + await hass.async_block_till_done() + with patch("python_opensky.OpenSky.authenticate"), patch( + "python_opensky.OpenSky.get_states", + return_value=get_states_response_fixture("opensky/states_1.json"), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_RADIUS: 10000, + CONF_USERNAME: "homeassistant", + CONF_PASSWORD: "secret", + CONF_CONTRIBUTING_USER: True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_RADIUS: 10000, + CONF_USERNAME: "homeassistant", + CONF_PASSWORD: "secret", + CONF_CONTRIBUTING_USER: True, + } diff --git a/tests/components/opensky/test_init.py b/tests/components/opensky/test_init.py index 961aaab61fc..4c6cb9c3a33 100644 --- a/tests/components/opensky/test_init.py +++ b/tests/components/opensky/test_init.py @@ -4,6 +4,7 @@ from __future__ import annotations from unittest.mock import patch from python_opensky import OpenSkyError +from python_opensky.exceptions import OpenSkyUnauthenticatedError from homeassistant.components.opensky.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -48,3 +49,19 @@ async def test_load_entry_failure( await hass.async_block_till_done() entry = hass.config_entries.async_entries(DOMAIN)[0] assert entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_load_entry_authentication_failure( + hass: HomeAssistant, + config_entry_authenticated: MockConfigEntry, +) -> None: + """Test auth failure while loading.""" + config_entry_authenticated.add_to_hass(hass) + with patch( + "python_opensky.OpenSky.authenticate", + side_effect=OpenSkyUnauthenticatedError(), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/powerwall/test_binary_sensor.py b/tests/components/powerwall/test_binary_sensor.py index acea33186a8..b0a62f42368 100644 --- a/tests/components/powerwall/test_binary_sensor.py +++ b/tests/components/powerwall/test_binary_sensor.py @@ -26,47 +26,50 @@ async def test_sensors(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.grid_services_active") + state = hass.states.get("binary_sensor.mysite_grid_services_active") assert state.state == STATE_ON expected_attributes = { - "friendly_name": "Grid Services Active", + "friendly_name": "MySite Grid services active", "device_class": "power", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all(item in state.attributes.items() for item in expected_attributes.items()) - state = hass.states.get("binary_sensor.grid_status") - assert state.state == STATE_ON - expected_attributes = {"friendly_name": "Grid Status", "device_class": "power"} - # Only test for a subset of attributes in case - # HA changes the implementation and a new one appears - assert all(item in state.attributes.items() for item in expected_attributes.items()) - - state = hass.states.get("binary_sensor.powerwall_status") + state = hass.states.get("binary_sensor.mysite_grid_status") assert state.state == STATE_ON expected_attributes = { - "friendly_name": "Powerwall Status", + "friendly_name": "MySite Grid status", "device_class": "power", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all(item in state.attributes.items() for item in expected_attributes.items()) - state = hass.states.get("binary_sensor.powerwall_connected_to_tesla") + state = hass.states.get("binary_sensor.mysite_status") assert state.state == STATE_ON expected_attributes = { - "friendly_name": "Powerwall Connected to Tesla", + "friendly_name": "MySite Status", + "device_class": "power", + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all(item in state.attributes.items() for item in expected_attributes.items()) + + state = hass.states.get("binary_sensor.mysite_connected_to_tesla") + assert state.state == STATE_ON + expected_attributes = { + "friendly_name": "MySite Connected to Tesla", "device_class": "connectivity", } # Only test for a subset of attributes in case # HA changes the implementation and a new one appears assert all(item in state.attributes.items() for item in expected_attributes.items()) - state = hass.states.get("binary_sensor.powerwall_charging") + state = hass.states.get("binary_sensor.mysite_charging") assert state.state == STATE_ON expected_attributes = { - "friendly_name": "Powerwall Charging", + "friendly_name": "MySite Charging", "device_class": "battery_charging", } # Only test for a subset of attributes in case diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py index a0d4d7f9e96..e7772571c86 100644 --- a/tests/components/powerwall/test_sensor.py +++ b/tests/components/powerwall/test_sensor.py @@ -47,58 +47,58 @@ async def test_sensors( assert reg_device.manufacturer == "Tesla" assert reg_device.name == "MySite" - state = hass.states.get("sensor.powerwall_load_now") + state = hass.states.get("sensor.mysite_load_power") assert state.state == "1.971" attributes = state.attributes assert attributes[ATTR_DEVICE_CLASS] == "power" assert attributes[ATTR_UNIT_OF_MEASUREMENT] == "kW" assert attributes[ATTR_STATE_CLASS] == "measurement" - assert attributes[ATTR_FRIENDLY_NAME] == "Powerwall Load Now" + assert attributes[ATTR_FRIENDLY_NAME] == "MySite Load power" - state = hass.states.get("sensor.powerwall_load_frequency_now") + state = hass.states.get("sensor.mysite_load_frequency") assert state.state == "60" attributes = state.attributes assert attributes[ATTR_DEVICE_CLASS] == "frequency" assert attributes[ATTR_UNIT_OF_MEASUREMENT] == "Hz" assert attributes[ATTR_STATE_CLASS] == "measurement" - assert attributes[ATTR_FRIENDLY_NAME] == "Powerwall Load Frequency Now" + assert attributes[ATTR_FRIENDLY_NAME] == "MySite Load frequency" - state = hass.states.get("sensor.powerwall_load_average_voltage_now") + state = hass.states.get("sensor.mysite_load_voltage") assert state.state == "120.7" attributes = state.attributes assert attributes[ATTR_DEVICE_CLASS] == "voltage" assert attributes[ATTR_UNIT_OF_MEASUREMENT] == "V" assert attributes[ATTR_STATE_CLASS] == "measurement" - assert attributes[ATTR_FRIENDLY_NAME] == "Powerwall Load Average Voltage Now" + assert attributes[ATTR_FRIENDLY_NAME] == "MySite Load voltage" - state = hass.states.get("sensor.powerwall_load_average_current_now") + state = hass.states.get("sensor.mysite_load_current") assert state.state == "0" attributes = state.attributes assert attributes[ATTR_DEVICE_CLASS] == "current" assert attributes[ATTR_UNIT_OF_MEASUREMENT] == "A" assert attributes[ATTR_STATE_CLASS] == "measurement" - assert attributes[ATTR_FRIENDLY_NAME] == "Powerwall Load Average Current Now" + assert attributes[ATTR_FRIENDLY_NAME] == "MySite Load current" - assert float(hass.states.get("sensor.powerwall_load_export").state) == 1056.8 - assert float(hass.states.get("sensor.powerwall_load_import").state) == 4693.0 + assert float(hass.states.get("sensor.mysite_load_export").state) == 1056.8 + assert float(hass.states.get("sensor.mysite_load_import").state) == 4693.0 - state = hass.states.get("sensor.powerwall_battery_now") + state = hass.states.get("sensor.mysite_battery_power") assert state.state == "-8.55" - assert float(hass.states.get("sensor.powerwall_battery_export").state) == 3620.0 - assert float(hass.states.get("sensor.powerwall_battery_import").state) == 4216.2 + assert float(hass.states.get("sensor.mysite_battery_export").state) == 3620.0 + assert float(hass.states.get("sensor.mysite_battery_import").state) == 4216.2 - state = hass.states.get("sensor.powerwall_solar_now") + state = hass.states.get("sensor.mysite_solar_power") assert state.state == "10.49" - assert float(hass.states.get("sensor.powerwall_solar_export").state) == 9864.2 - assert float(hass.states.get("sensor.powerwall_solar_import").state) == 28.2 + assert float(hass.states.get("sensor.mysite_solar_export").state) == 9864.2 + assert float(hass.states.get("sensor.mysite_solar_import").state) == 28.2 - state = hass.states.get("sensor.powerwall_charge") + state = hass.states.get("sensor.mysite_charge") assert state.state == "47" expected_attributes = { "unit_of_measurement": PERCENTAGE, - "friendly_name": "Powerwall Charge", + "friendly_name": "MySite Charge", "device_class": "battery", } # Only test for a subset of attributes in case @@ -106,11 +106,11 @@ async def test_sensors( for key, value in expected_attributes.items(): assert state.attributes[key] == value - state = hass.states.get("sensor.powerwall_backup_reserve") + state = hass.states.get("sensor.mysite_backup_reserve") assert state.state == "15" expected_attributes = { "unit_of_measurement": PERCENTAGE, - "friendly_name": "Powerwall Backup Reserve", + "friendly_name": "MySite Backup reserve", "device_class": "battery", } # Only test for a subset of attributes in case diff --git a/tests/components/prosegur/test_alarm_control_panel.py b/tests/components/prosegur/test_alarm_control_panel.py index 51086e74b00..d5244de1b43 100644 --- a/tests/components/prosegur/test_alarm_control_panel.py +++ b/tests/components/prosegur/test_alarm_control_panel.py @@ -59,7 +59,7 @@ async def test_entity_registry( state = hass.states.get(PROSEGUR_ALARM_ENTITY) - assert state.attributes.get(ATTR_FRIENDLY_NAME) == f"contract {CONTRACT}" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == f"Contract {CONTRACT}" assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 3 diff --git a/tests/components/prosegur/test_camera.py b/tests/components/prosegur/test_camera.py index ba2e478f5cd..58017085aed 100644 --- a/tests/components/prosegur/test_camera.py +++ b/tests/components/prosegur/test_camera.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import HomeAssistantError async def test_camera(hass: HomeAssistant, init_integration) -> None: """Test prosegur get_image.""" - image = await camera.async_get_image(hass, "camera.test_cam") + image = await camera.async_get_image(hass, "camera.contract_1234abcd_test_cam") assert image == Image(content_type="image/jpeg", content=b"ABC") @@ -36,7 +36,7 @@ async def test_camera_fail( with caplog.at_level( logging.ERROR, logger="homeassistant.components.prosegur" ), pytest.raises(HomeAssistantError) as exc: - await camera.async_get_image(hass, "camera.test_cam") + await camera.async_get_image(hass, "camera.contract_1234abcd_test_cam") assert "Unable to get image" in str(exc.value) @@ -51,7 +51,7 @@ async def test_request_image( await hass.services.async_call( DOMAIN, "request_image", - {ATTR_ENTITY_ID: "camera.test_cam"}, + {ATTR_ENTITY_ID: "camera.contract_1234abcd_test_cam"}, ) await hass.async_block_till_done() @@ -72,7 +72,7 @@ async def test_request_image_fail( await hass.services.async_call( DOMAIN, "request_image", - {ATTR_ENTITY_ID: "camera.test_cam"}, + {ATTR_ENTITY_ID: "camera.contract_1234abcd_test_cam"}, ) await hass.async_block_till_done() diff --git a/tests/components/prusalink/test_sensor.py b/tests/components/prusalink/test_sensor.py index 6a0944bdf36..0f2a966b4e4 100644 --- a/tests/components/prusalink/test_sensor.py +++ b/tests/components/prusalink/test_sensor.py @@ -1,6 +1,6 @@ """Test Prusalink sensors.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import PropertyMock, patch import pytest @@ -125,7 +125,7 @@ async def test_sensors_active_job( """Test sensors while active job.""" with patch( "homeassistant.components.prusalink.sensor.utcnow", - return_value=datetime(2022, 8, 27, 14, 0, 0, tzinfo=timezone.utc), + return_value=datetime(2022, 8, 27, 14, 0, 0, tzinfo=UTC), ): assert await async_setup_component(hass, "prusalink", {}) diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index ecfd188db8e..a7b15a7f12d 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -1,6 +1,6 @@ """Test util methods.""" from collections.abc import Callable -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import os from pathlib import Path import sqlite3 @@ -948,7 +948,7 @@ def test_execute_stmt_lambda_element( assert rows == ["mock_row"] -@pytest.mark.freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=timezone.utc)) +@pytest.mark.freeze_time(datetime(2022, 10, 21, 7, 25, tzinfo=UTC)) async def test_resolve_period(hass: HomeAssistant) -> None: """Test statistic_during_period.""" diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index d649baeb937..e2bd622bb43 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -85,7 +85,7 @@ async def test_firmware_error_twice( assert config_entry.state == ConfigEntryState.LOADED - entity_id = f"{Platform.UPDATE}.{TEST_NVR_NAME}_update" + entity_id = f"{Platform.UPDATE}.{TEST_NVR_NAME}_firmware" assert hass.states.is_state(entity_id, STATE_OFF) async_fire_time_changed( diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index c82337b484f..6c9b51a7cf6 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -313,6 +313,7 @@ async def test_fix_issue( "flow_id": ANY, "handler": domain, "last_step": None, + "preview": None, "step_id": step, "type": "form", } diff --git a/tests/components/roku/conftest.py b/tests/components/roku/conftest.py index 677a10c697c..c1ceb23934e 100644 --- a/tests/components/roku/conftest.py +++ b/tests/components/roku/conftest.py @@ -81,9 +81,13 @@ def mock_roku( @pytest.fixture async def init_integration( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_roku: MagicMock + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_device: RokuDevice, + mock_roku: MagicMock, ) -> MockConfigEntry: """Set up the Roku integration for testing.""" + mock_config_entry.unique_id = mock_device.info.serial_number mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/roku/fixtures/roku3-diagnostics-data.json b/tests/components/roku/fixtures/roku3-diagnostics-data.json deleted file mode 100644 index a3084b010c9..00000000000 --- a/tests/components/roku/fixtures/roku3-diagnostics-data.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "app": { - "app_id": null, - "name": "Roku", - "screensaver": false, - "version": null - }, - "apps": [ - { - "app_id": "11", - "name": "Roku Channel Store", - "screensaver": false, - "version": null - }, - { - "app_id": "12", - "name": "Netflix", - "screensaver": false, - "version": null - }, - { - "app_id": "13", - "name": "Amazon Video on Demand", - "screensaver": false, - "version": null - }, - { - "app_id": "14", - "name": "MLB.TV®", - "screensaver": false, - "version": null - }, - { - "app_id": "26", - "name": "Free FrameChannel Service", - "screensaver": false, - "version": null - }, - { - "app_id": "27", - "name": "Mediafly", - "screensaver": false, - "version": null - }, - { - "app_id": "28", - "name": "Pandora", - "screensaver": false, - "version": null - }, - { - "app_id": "74519", - "name": "Pluto TV - It's Free TV", - "screensaver": false, - "version": "5.2.0" - } - ], - "channel": null, - "channels": [], - "info": { - "brand": "Roku", - "device_location": null, - "device_type": "box", - "ethernet_mac": "b0:a7:37:96:4d:fa", - "ethernet_support": true, - "headphones_connected": false, - "model_name": "Roku 3", - "model_number": "4200X", - "name": "My Roku 3", - "network_name": null, - "network_type": "ethernet", - "serial_number": "1GU48T017973", - "supports_airplay": false, - "supports_find_remote": false, - "supports_private_listening": false, - "supports_wake_on_wlan": false, - "version": "7.5.0", - "wifi_mac": "b0:a7:37:96:4d:fb" - }, - "media": null, - "state": { - "at": "2022-01-23T21:05:03.154737", - "available": true, - "standby": false - } -} diff --git a/tests/components/roku/snapshots/test_diagnostics.ambr b/tests/components/roku/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..1742d1f7ee0 --- /dev/null +++ b/tests/components/roku/snapshots/test_diagnostics.ambr @@ -0,0 +1,98 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'app': dict({ + 'app_id': None, + 'name': 'Roku', + 'screensaver': False, + 'version': None, + }), + 'apps': list([ + dict({ + 'app_id': '11', + 'name': 'Roku Channel Store', + 'screensaver': False, + 'version': None, + }), + dict({ + 'app_id': '12', + 'name': 'Netflix', + 'screensaver': False, + 'version': None, + }), + dict({ + 'app_id': '13', + 'name': 'Amazon Video on Demand', + 'screensaver': False, + 'version': None, + }), + dict({ + 'app_id': '14', + 'name': 'MLB.TV®', + 'screensaver': False, + 'version': None, + }), + dict({ + 'app_id': '26', + 'name': 'Free FrameChannel Service', + 'screensaver': False, + 'version': None, + }), + dict({ + 'app_id': '27', + 'name': 'Mediafly', + 'screensaver': False, + 'version': None, + }), + dict({ + 'app_id': '28', + 'name': 'Pandora', + 'screensaver': False, + 'version': None, + }), + dict({ + 'app_id': '74519', + 'name': "Pluto TV - It's Free TV", + 'screensaver': False, + 'version': '5.2.0', + }), + ]), + 'channel': None, + 'channels': list([ + ]), + 'info': dict({ + 'brand': 'Roku', + 'device_location': None, + 'device_type': 'box', + 'ethernet_mac': 'b0:a7:37:96:4d:fa', + 'ethernet_support': True, + 'headphones_connected': False, + 'model_name': 'Roku 3', + 'model_number': '4200X', + 'name': 'My Roku 3', + 'network_name': None, + 'network_type': 'ethernet', + 'serial_number': '1GU48T017973', + 'supports_airplay': False, + 'supports_find_remote': False, + 'supports_private_listening': False, + 'supports_wake_on_wlan': False, + 'version': '7.5.0', + 'wifi_mac': 'b0:a7:37:96:4d:fb', + }), + 'media': None, + 'state': dict({ + 'at': '2023-08-15T17:00:00+00:00', + 'available': True, + 'standby': False, + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '192.168.1.160', + }), + 'unique_id': '1GU48T017973', + }), + }) +# --- diff --git a/tests/components/roku/test_diagnostics.py b/tests/components/roku/test_diagnostics.py index 860d0424624..708e6d3f5e3 100644 --- a/tests/components/roku/test_diagnostics.py +++ b/tests/components/roku/test_diagnostics.py @@ -1,9 +1,11 @@ """Tests for the diagnostics data provided by the Roku integration.""" -import json +from rokuecp import Device as RokuDevice +from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -11,27 +13,14 @@ from tests.typing import ClientSessionGenerator async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, + mock_device: RokuDevice, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for config entry.""" - diagnostics_data = json.loads(load_fixture("roku/roku3-diagnostics-data.json")) + mock_device.state.at = dt_util.parse_datetime("2023-08-15 17:00:00-00:00") - result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) - - assert isinstance(result, dict) - assert isinstance(result["entry"], dict) - assert result["entry"]["data"] == {"host": "192.168.1.160"} - assert result["entry"]["unique_id"] == "1GU48T017973" - - assert isinstance(result["data"], dict) - assert result["data"]["app"] == diagnostics_data["app"] - assert result["data"]["apps"] == diagnostics_data["apps"] - assert result["data"]["channel"] == diagnostics_data["channel"] - assert result["data"]["channels"] == diagnostics_data["channels"] - assert result["data"]["info"] == diagnostics_data["info"] - assert result["data"]["media"] == diagnostics_data["media"] - - data_state = result["data"]["state"] - assert isinstance(data_state, dict) - assert data_state["available"] == diagnostics_data["state"]["available"] - assert data_state["standby"] == diagnostics_data["state"]["standby"] + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/roku/test_init.py b/tests/components/roku/test_init.py index f8820e711a2..2ebad1e0b2b 100644 --- a/tests/components/roku/test_init.py +++ b/tests/components/roku/test_init.py @@ -26,6 +26,25 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_config_entry_no_unique_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_roku: AsyncMock, +) -> None: + """Test the Roku configuration entry with missing unique id.""" + mock_config_entry.unique_id = None + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.entry_id in hass.data[DOMAIN] + assert mock_config_entry.state is ConfigEntryState.LOADED + assert ( + hass.data[DOMAIN][mock_config_entry.entry_id].device_id + == mock_config_entry.entry_id + ) + + async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/roku/test_sensor.py b/tests/components/roku/test_sensor.py index 7cfb6f7f7c9..ab7b9ac00f5 100644 --- a/tests/components/roku/test_sensor.py +++ b/tests/components/roku/test_sensor.py @@ -34,7 +34,7 @@ async def test_roku_sensors( assert entry.unique_id == f"{UPNP_SERIAL}_active_app" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == "Roku" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Active App" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Active app" assert state.attributes.get(ATTR_ICON) == "mdi:application" assert ATTR_DEVICE_CLASS not in state.attributes @@ -45,7 +45,7 @@ async def test_roku_sensors( assert entry.unique_id == f"{UPNP_SERIAL}_active_app_id" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == STATE_UNKNOWN - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Active App ID" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Active app ID" assert state.attributes.get(ATTR_ICON) == "mdi:application-cog" assert ATTR_DEVICE_CLASS not in state.attributes @@ -83,7 +83,7 @@ async def test_rokutv_sensors( assert entry.unique_id == "YN00H5555555_active_app" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == "Antenna TV" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Active App' + assert state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Active app' assert state.attributes.get(ATTR_ICON) == "mdi:application" assert ATTR_DEVICE_CLASS not in state.attributes @@ -94,7 +94,7 @@ async def test_rokutv_sensors( assert entry.unique_id == "YN00H5555555_active_app_id" assert entry.entity_category == EntityCategory.DIAGNOSTIC assert state.state == "tvinput.dtv" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Active App ID' + assert state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Active app ID' assert state.attributes.get(ATTR_ICON) == "mdi:application-cog" assert ATTR_DEVICE_CLASS not in state.attributes diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py index 9c6c5e0b4de..9e1895f3a58 100644 --- a/tests/components/scrape/test_config_flow.py +++ b/tests/components/scrape/test_config_flow.py @@ -22,6 +22,7 @@ from homeassistant.const import ( CONF_METHOD, CONF_NAME, CONF_PASSWORD, + CONF_PAYLOAD, CONF_RESOURCE, CONF_TIMEOUT, CONF_UNIQUE_ID, @@ -99,6 +100,68 @@ async def test_form( assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_with_post( + hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form using POST method.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + with patch( + "homeassistant.components.rest.RestData", + return_value=get_data, + ) as mock_data: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_PAYLOAD: "POST", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10.0, + }, + ) + await hass.async_block_till_done() + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_NAME: "Current version", + CONF_SELECT: ".current-version h1", + CONF_INDEX: 0.0, + CONF_DEVICE_CLASS: NONE_SENTINEL, + CONF_STATE_CLASS: NONE_SENTINEL, + CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["version"] == 1 + assert result3["options"] == { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_METHOD: "GET", + CONF_PAYLOAD: "POST", + CONF_VERIFY_SSL: True, + CONF_TIMEOUT: 10.0, + CONF_ENCODING: "UTF-8", + "sensor": [ + { + CONF_NAME: "Current version", + CONF_SELECT: ".current-version h1", + CONF_INDEX: 0.0, + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + } + ], + } + + assert len(mock_data.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_flow_fails( hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock ) -> None: diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index c5406a85fc0..1f836ad9095 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Generator -from datetime import date, datetime, timezone +from datetime import UTC, date, datetime from decimal import Decimal from typing import Any @@ -177,7 +177,7 @@ async def test_datetime_conversion( enable_custom_integrations: None, ) -> None: """Test conversion of datetime.""" - test_timestamp = datetime(2017, 12, 19, 18, 29, 42, tzinfo=timezone.utc) + test_timestamp = datetime(2017, 12, 19, 18, 29, 42, tzinfo=UTC) test_local_timestamp = test_timestamp.astimezone( dt_util.get_time_zone("Europe/Amsterdam") ) @@ -233,7 +233,7 @@ async def test_a_sensor_with_a_non_numeric_device_class( A non numeric sensor with a valid device class should never be handled as numeric because it has a device class. """ - test_timestamp = datetime(2017, 12, 19, 18, 29, 42, tzinfo=timezone.utc) + test_timestamp = datetime(2017, 12, 19, 18, 29, 42, tzinfo=UTC) test_local_timestamp = test_timestamp.astimezone( dt_util.get_time_zone("Europe/Amsterdam") ) @@ -334,7 +334,7 @@ RESTORE_DATA = { "native_unit_of_measurement": None, "native_value": { "__type": "", - "isoformat": datetime(2020, 2, 8, 15, tzinfo=timezone.utc).isoformat(), + "isoformat": datetime(2020, 2, 8, 15, tzinfo=UTC).isoformat(), }, }, "Decimal": { @@ -375,7 +375,7 @@ RESTORE_DATA = { ), (date(2020, 2, 8), dict, RESTORE_DATA["date"], SensorDeviceClass.DATE, None), ( - datetime(2020, 2, 8, 15, tzinfo=timezone.utc), + datetime(2020, 2, 8, 15, tzinfo=UTC), dict, RESTORE_DATA["datetime"], SensorDeviceClass.TIMESTAMP, @@ -433,7 +433,7 @@ async def test_restore_sensor_save_state( (123.0, float, RESTORE_DATA["float"], SensorDeviceClass.TEMPERATURE, "°F"), (date(2020, 2, 8), date, RESTORE_DATA["date"], SensorDeviceClass.DATE, None), ( - datetime(2020, 2, 8, 15, tzinfo=timezone.utc), + datetime(2020, 2, 8, 15, tzinfo=UTC), datetime, RESTORE_DATA["datetime"], SensorDeviceClass.TIMESTAMP, @@ -1861,13 +1861,17 @@ async def test_device_classes_with_invalid_unit_of_measurement( ], ) @pytest.mark.parametrize( - "native_value", + ("native_value", "problem"), [ - "", - "abc", - "13.7.1", - datetime(2012, 11, 10, 7, 35, 1), - date(2012, 11, 10), + ("", "non-numeric"), + ("abc", "non-numeric"), + ("13.7.1", "non-numeric"), + (datetime(2012, 11, 10, 7, 35, 1), "non-numeric"), + (date(2012, 11, 10), "non-numeric"), + ("inf", "non-finite"), + (float("inf"), "non-finite"), + ("nan", "non-finite"), + (float("nan"), "non-finite"), ], ) async def test_non_numeric_validation_error( @@ -1875,6 +1879,7 @@ async def test_non_numeric_validation_error( caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, native_value: Any, + problem: str, device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, unit: str | None, @@ -1899,7 +1904,7 @@ async def test_non_numeric_validation_error( assert ( "thus indicating it has a numeric value; " - f"however, it has the non-numeric value: '{native_value}'" + f"however, it has the {problem} value: '{native_value}'" ) in caplog.text diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 96e888d7509..797673265a6 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -3,8 +3,8 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock, PropertyMock, patch -from aioshelly.block_device import BlockDevice -from aioshelly.rpc_device import RpcDevice, UpdateType +from aioshelly.block_device import BlockDevice, BlockUpdateType +from aioshelly.rpc_device import RpcDevice, RpcUpdateType import pytest from homeassistant.components.shelly.const import ( @@ -131,6 +131,16 @@ MOCK_BLOCKS = [ description="emeter_0", type="emeter", ), + Mock( + sensor_ids={"valve": "closed"}, + valve="closed", + channel="0", + description="valve_0", + type="valve", + set_state=AsyncMock( + side_effect=lambda go: {"state": "opening" if go == "open" else "closing"} + ), + ), ] MOCK_CONFIG = { @@ -247,7 +257,14 @@ async def mock_block_device(): with patch("aioshelly.block_device.BlockDevice.create") as block_device_mock: def update(): - block_device_mock.return_value.subscribe_updates.call_args[0][0]({}) + block_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, BlockUpdateType.COAP_PERIODIC + ) + + def update_reply(): + block_device_mock.return_value.subscribe_updates.call_args[0][0]( + {}, BlockUpdateType.COAP_REPLY + ) device = Mock( spec=BlockDevice, @@ -263,6 +280,9 @@ async def mock_block_device(): type(device).name = PropertyMock(return_value="Test name") block_device_mock.return_value = device block_device_mock.return_value.mock_update = Mock(side_effect=update) + block_device_mock.return_value.mock_update_reply = Mock( + side_effect=update_reply + ) yield block_device_mock.return_value @@ -291,7 +311,7 @@ async def mock_pre_ble_rpc_device(): def update(): rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, UpdateType.STATUS + {}, RpcUpdateType.STATUS ) device = _mock_rpc_device("0.11.0") @@ -310,17 +330,17 @@ async def mock_rpc_device(): def update(): rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, UpdateType.STATUS + {}, RpcUpdateType.STATUS ) def event(): rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, UpdateType.EVENT + {}, RpcUpdateType.EVENT ) def disconnected(): rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, UpdateType.DISCONNECTED + {}, RpcUpdateType.DISCONNECTED ) device = _mock_rpc_device("0.12.0") diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index c806cb5e742..08ec548d3f0 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -32,6 +32,7 @@ from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data SENSOR_BLOCK_ID = 3 DEVICE_BLOCK_ID = 4 EMETER_BLOCK_ID = 5 +GAS_VALVE_BLOCK_ID = 6 ENTITY_ID = f"{CLIMATE_DOMAIN}.test_name" @@ -47,6 +48,7 @@ async def test_climate_hvac_mode( ) monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp") + monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") await init_integration(hass, 1, sleep_period=1000, model="SHTRV-01") # Make device online @@ -103,6 +105,7 @@ async def test_climate_set_temperature( """Test climate set temperature service.""" monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) + monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") await init_integration(hass, 1, sleep_period=1000) # Make device online @@ -144,6 +147,7 @@ async def test_climate_set_preset_mode( ) -> None: """Test climate set preset mode service.""" monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") + monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "mode", None) await init_integration(hass, 1, sleep_period=1000, model="SHTRV-01") @@ -198,6 +202,7 @@ async def test_block_restored_climate( ) -> None: """Test block restored climate.""" monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") + monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp") entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) @@ -261,6 +266,7 @@ async def test_block_restored_climate_us_customery( """Test block restored climate with US CUSTOMATY unit system.""" hass.config.units = US_CUSTOMARY_SYSTEM monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") + monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp") entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index eb546ce5835..5a8bb234f30 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -36,7 +36,6 @@ from . import ( mock_rest_update, register_entity, ) -from .conftest import MOCK_BLOCKS from tests.common import async_fire_time_changed @@ -259,24 +258,25 @@ async def test_block_device_push_updates_failure( """Test block device with push updates failure.""" issue_registry: ir.IssueRegistry = ir.async_get(hass) - monkeypatch.setattr( - mock_block_device, - "update", - AsyncMock(return_value=MOCK_BLOCKS), - ) await init_integration(hass, 1) - # Move time to force polling - for _ in range(MAX_PUSH_UPDATE_FAILURES + 1): - async_fire_time_changed( - hass, dt_util.utcnow() + timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15) - ) + # Updates with COAP_REPLAY type should create an issue + for _ in range(MAX_PUSH_UPDATE_FAILURES): + mock_block_device.mock_update_reply() await hass.async_block_till_done() assert issue_registry.async_get_issue( domain=DOMAIN, issue_id=f"push_update_{MOCK_MAC}" ) + # An update with COAP_PERIODIC type should clear the issue + mock_block_device.mock_update() + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue( + domain=DOMAIN, issue_id=f"push_update_{MOCK_MAC}" + ) + async def test_block_button_click_event( hass: HomeAssistant, mock_block_device, events, monkeypatch diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index a62dfda82f9..1fdfc9d4304 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -3,7 +3,11 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch -from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError +from aioshelly.exceptions import ( + DeviceConnectionError, + InvalidAuthError, + MacAddressMismatchError, +) import pytest from homeassistant.components.shelly.const import ( @@ -86,6 +90,22 @@ async def test_device_connection_error( assert entry.state == ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize("gen", [1, 2]) +async def test_mac_mismatch_error( + hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch +) -> None: + """Test device MAC address mismatch error.""" + monkeypatch.setattr( + mock_block_device, "initialize", AsyncMock(side_effect=MacAddressMismatchError) + ) + monkeypatch.setattr( + mock_rpc_device, "initialize", AsyncMock(side_effect=MacAddressMismatchError) + ) + + entry = await init_integration(hass, gen) + assert entry.state == ConfigEntryState.SETUP_RETRY + + @pytest.mark.parametrize("gen", [1, 2]) async def test_device_auth_error( hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index a93d752f9e2..a53c5dc185b 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -9,6 +9,7 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_ICON, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -16,10 +17,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from . import init_integration RELAY_BLOCK_ID = 0 +GAS_VALVE_BLOCK_ID = 6 async def test_block_device_services(hass: HomeAssistant, mock_block_device) -> None: @@ -226,3 +229,51 @@ async def test_rpc_auth_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == entry.entry_id + + +async def test_block_device_gas_valve( + hass: HomeAssistant, mock_block_device, monkeypatch +) -> None: + """Test block device Shelly Gas with Valve addon.""" + registry = er.async_get(hass) + await init_integration(hass, 1, "SHGS-1") + entity_id = "switch.test_name_valve" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-valve_0-valve" + + assert hass.states.get(entity_id).state == STATE_OFF # valve is closed + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON # valve is open + assert state.attributes.get(ATTR_ICON) == "mdi:valve-open" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF # valve is closed + assert state.attributes.get(ATTR_ICON) == "mdi:valve-closed" + + monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "opened") + mock_block_device.mock_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON # valve is open + assert state.attributes.get(ATTR_ICON) == "mdi:valve-open" diff --git a/tests/components/subaru/api_responses.py b/tests/components/subaru/api_responses.py index e2fdf9ae508..52c57e7348a 100644 --- a/tests/components/subaru/api_responses.py +++ b/tests/components/subaru/api_responses.py @@ -1,6 +1,6 @@ """Sample API response data for tests.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from homeassistant.components.subaru.const import ( API_GEN_1, @@ -58,7 +58,7 @@ VEHICLE_DATA = { }, } -MOCK_DATETIME = datetime.fromtimestamp(1595560000, timezone.utc) +MOCK_DATETIME = datetime.fromtimestamp(1595560000, UTC) VEHICLE_STATUS_EV = { VEHICLE_STATUS: { diff --git a/tests/components/subaru/test_config_flow.py b/tests/components/subaru/test_config_flow.py index fc959fc434d..c3df10ed618 100644 --- a/tests/components/subaru/test_config_flow.py +++ b/tests/components/subaru/test_config_flow.py @@ -264,6 +264,7 @@ async def test_pin_form_init(pin_form) -> None: "step_id": "pin", "type": "form", "last_step": None, + "preview": None, } assert pin_form == expected diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 1e6b2cc3840..e43163f66fc 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -1,5 +1,5 @@ """The tests for the Template Binary sensor platform.""" -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta import logging from unittest.mock import patch @@ -1276,9 +1276,7 @@ async def test_trigger_entity_restore_state_auto_off( fake_extra_data = { "auto_off_time": { "__type": "", - "isoformat": datetime( - 2022, 2, 2, 12, 2, 2, tzinfo=timezone.utc - ).isoformat(), + "isoformat": datetime(2022, 2, 2, 12, 2, 2, tzinfo=UTC).isoformat(), }, } mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) @@ -1336,9 +1334,7 @@ async def test_trigger_entity_restore_state_auto_off_expired( fake_extra_data = { "auto_off_time": { "__type": "", - "isoformat": datetime( - 2022, 2, 2, 12, 2, 0, tzinfo=timezone.utc - ).isoformat(), + "isoformat": datetime(2022, 2, 2, 12, 2, 0, tzinfo=UTC).isoformat(), }, } mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index f3fd3e03ce0..bfdb9352767 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -97,7 +97,7 @@ async def test_all_optional_config(hass: HomeAssistant, calls) -> None: _TEST_OPTIONS_BUTTON, ) - now = dt.datetime.now(dt.timezone.utc) + now = dt.datetime.now(dt.UTC) with patch("homeassistant.util.dt.utcnow", return_value=now): await hass.services.async_call( diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 38cf439987d..97965a5643e 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -2,6 +2,7 @@ import pytest from homeassistant.components.weather import ( + ATTR_FORECAST, ATTR_WEATHER_APPARENT_TEMPERATURE, ATTR_WEATHER_CLOUD_COVERAGE, ATTR_WEATHER_DEW_POINT, @@ -13,13 +14,15 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, - DOMAIN, + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + Forecast, ) from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( "config", [ @@ -74,3 +77,419 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: assert state is not None assert state.state == "sunny" assert state.attributes.get(v_attr) == value + + +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "weather": [ + { + "platform": "template", + "name": "forecast", + "condition_template": "sunny", + "forecast_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_daily_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_hourly_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_twice_daily_template": "{{ states.weather.forecast_twice_daily.attributes.forecast }}", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + }, + ] + }, + ], +) +async def test_forecasts(hass: HomeAssistant, start_ha) -> None: + """Test forecast service.""" + for attr, _v_attr, value in [ + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ]: + hass.states.async_set(attr, value) + await hass.async_block_till_done() + + hass.states.async_set( + "weather.forecast", + "sunny", + { + ATTR_FORECAST: [ + Forecast( + condition="cloudy", + datetime="2023-02-17T14:00:00+00:00", + temperature=14.2, + ) + ] + }, + ) + hass.states.async_set( + "weather.forecast_twice_daily", + "fog", + { + ATTR_FORECAST: [ + Forecast( + condition="fog", + datetime="2023-02-17T14:00:00+00:00", + temperature=14.2, + is_daytime=True, + ) + ] + }, + ) + await hass.async_block_till_done() + state = hass.states.get("weather.forecast") + assert state is not None + assert state.state == "sunny" + state2 = hass.states.get("weather.forecast_twice_daily") + assert state2 is not None + assert state2.state == "fog" + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "daily"}, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "condition": "cloudy", + "datetime": "2023-02-17T14:00:00+00:00", + "temperature": 14.2, + } + ] + } + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "hourly"}, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "condition": "cloudy", + "datetime": "2023-02-17T14:00:00+00:00", + "temperature": 14.2, + } + ] + } + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "twice_daily"}, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "condition": "fog", + "datetime": "2023-02-17T14:00:00+00:00", + "temperature": 14.2, + "is_daytime": True, + } + ] + } + + hass.states.async_set( + "weather.forecast", + "sunny", + { + ATTR_FORECAST: [ + Forecast( + condition="cloudy", + datetime="2023-02-17T14:00:00+00:00", + temperature=16.9, + ) + ] + }, + ) + await hass.async_block_till_done() + state = hass.states.get("weather.forecast") + assert state is not None + assert state.state == "sunny" + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "daily"}, + blocking=True, + return_response=True, + ) + assert response == { + "forecast": [ + { + "condition": "cloudy", + "datetime": "2023-02-17T14:00:00+00:00", + "temperature": 16.9, + } + ] + } + + +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "weather": [ + { + "platform": "template", + "name": "forecast", + "condition_template": "sunny", + "forecast_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_daily_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_hourly_template": "{{ states.weather.forecast_hourly.attributes.forecast }}", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + }, + ] + }, + ], +) +async def test_forecast_invalid( + hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture +) -> None: + """Test invalid forecasts.""" + for attr, _v_attr, value in [ + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ]: + hass.states.async_set(attr, value) + await hass.async_block_till_done() + + hass.states.async_set( + "weather.forecast", + "sunny", + { + ATTR_FORECAST: [ + Forecast( + condition="cloudy", + datetime="2023-02-17T14:00:00+00:00", + temperature=14.2, + not_correct=1, + ) + ] + }, + ) + hass.states.async_set( + "weather.forecast_hourly", + "sunny", + {ATTR_FORECAST: None}, + ) + await hass.async_block_till_done() + state = hass.states.get("weather.forecast_hourly") + assert state is not None + assert state.state == "sunny" + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "daily"}, + blocking=True, + return_response=True, + ) + assert response == {"forecast": []} + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "hourly"}, + blocking=True, + return_response=True, + ) + assert response == {"forecast": []} + assert "Only valid keys in Forecast are allowed" in caplog.text + + +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "weather": [ + { + "platform": "template", + "name": "forecast", + "condition_template": "sunny", + "forecast_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_twice_daily_template": "{{ states.weather.forecast_twice_daily.attributes.forecast }}", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + }, + ] + }, + ], +) +async def test_forecast_invalid_is_daytime_missing_in_twice_daily( + hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture +) -> None: + """Test forecast service invalid when is_daytime missing in twice_daily forecast.""" + for attr, _v_attr, value in [ + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ]: + hass.states.async_set(attr, value) + await hass.async_block_till_done() + + hass.states.async_set( + "weather.forecast_twice_daily", + "sunny", + { + ATTR_FORECAST: [ + Forecast( + condition="cloudy", + datetime="2023-02-17T14:00:00+00:00", + temperature=14.2, + ) + ] + }, + ) + await hass.async_block_till_done() + state = hass.states.get("weather.forecast_twice_daily") + assert state is not None + assert state.state == "sunny" + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "twice_daily"}, + blocking=True, + return_response=True, + ) + assert response == {"forecast": []} + assert "`is_daytime` is missing in twice_daily forecast" in caplog.text + + +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "weather": [ + { + "platform": "template", + "name": "forecast", + "condition_template": "sunny", + "forecast_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_twice_daily_template": "{{ states.weather.forecast_twice_daily.attributes.forecast }}", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + }, + ] + }, + ], +) +async def test_forecast_invalid_datetime_missing( + hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture +) -> None: + """Test forecast service invalid when datetime missing.""" + for attr, _v_attr, value in [ + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ]: + hass.states.async_set(attr, value) + await hass.async_block_till_done() + + hass.states.async_set( + "weather.forecast_twice_daily", + "sunny", + { + ATTR_FORECAST: [ + Forecast( + condition="cloudy", + temperature=14.2, + is_daytime=True, + ) + ] + }, + ) + await hass.async_block_till_done() + state = hass.states.get("weather.forecast_twice_daily") + assert state is not None + assert state.state == "sunny" + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "twice_daily"}, + blocking=True, + return_response=True, + ) + assert response == {"forecast": []} + assert "`datetime` is required in forecasts" in caplog.text + + +@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "weather": [ + { + "platform": "template", + "name": "forecast", + "condition_template": "sunny", + "forecast_template": "{{ states.weather.forecast.attributes.forecast }}", + "forecast_daily_template": "{{ states.weather.forecast_daily.attributes.forecast }}", + "forecast_hourly_template": "{{ states.weather.forecast_hourly.attributes.forecast }}", + "temperature_template": "{{ states('sensor.temperature') | float }}", + "humidity_template": "{{ states('sensor.humidity') | int }}", + }, + ] + }, + ], +) +async def test_forecast_format_error( + hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture +) -> None: + """Test forecast service invalid on incorrect format.""" + for attr, _v_attr, value in [ + ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), + ("sensor.humidity", ATTR_WEATHER_HUMIDITY, 60), + ]: + hass.states.async_set(attr, value) + await hass.async_block_till_done() + + hass.states.async_set( + "weather.forecast_daily", + "sunny", + { + ATTR_FORECAST: [ + "cloudy", + "2023-02-17T14:00:00+00:00", + 14.2, + 1, + ] + }, + ) + hass.states.async_set( + "weather.forecast_hourly", + "sunny", + { + ATTR_FORECAST: { + "condition": "cloudy", + "temperature": 14.2, + "is_daytime": True, + } + }, + ) + + await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "daily"}, + blocking=True, + return_response=True, + ) + assert "Forecasts is not a list, see Weather documentation" in caplog.text + await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + {"entity_id": "weather.forecast", "type": "hourly"}, + blocking=True, + return_response=True, + ) + assert "Forecast in list is not a dict, see Weather documentation" in caplog.text diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index 77102f92019..d8822a7d536 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -546,9 +546,32 @@ async def test_set_preferred_border_agent_id(hass: HomeAssistant) -> None: assert await dataset_store.async_get_preferred_dataset(hass) is None await dataset_store.async_add_dataset( - hass, "source", DATASET_1, preferred_border_agent_id="blah" + hass, "source", DATASET_3, preferred_border_agent_id="blah" ) store = await dataset_store.async_get_store(hass) assert len(store.datasets) == 1 assert list(store.datasets.values())[0].preferred_border_agent_id == "blah" + + await dataset_store.async_add_dataset( + hass, "source", DATASET_3, preferred_border_agent_id="bleh" + ) + assert list(store.datasets.values())[0].preferred_border_agent_id == "blah" + + await dataset_store.async_add_dataset(hass, "source", DATASET_2) + assert len(store.datasets) == 2 + assert list(store.datasets.values())[1].preferred_border_agent_id is None + + await dataset_store.async_add_dataset( + hass, "source", DATASET_2, preferred_border_agent_id="blah" + ) + assert list(store.datasets.values())[1].preferred_border_agent_id == "blah" + + await dataset_store.async_add_dataset(hass, "source", DATASET_1) + assert len(store.datasets) == 3 + assert list(store.datasets.values())[2].preferred_border_agent_id is None + + await dataset_store.async_add_dataset( + hass, "source", DATASET_1_LARGER_TIMESTAMP, preferred_border_agent_id="blah" + ) + assert list(store.datasets.values())[1].preferred_border_agent_id == "blah" diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index c091fc5cc59..8e3e215e717 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -1518,3 +1518,90 @@ async def test_wlan_switches( mock_unifi_websocket(state=WebsocketState.RUNNING) await hass.async_block_till_done() assert hass.states.get("switch.ssid_1").state == STATE_OFF + + +async def test_port_forwarding_switches( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket +) -> None: + """Test control of UniFi port forwarding.""" + _data = { + "_id": "5a32aa4ee4b0412345678911", + "dst_port": "12345", + "enabled": True, + "fwd_port": "23456", + "fwd": "10.0.0.2", + "name": "plex", + "pfwd_interface": "wan", + "proto": "tcp_udp", + "site_id": "5a32aa4ee4b0412345678910", + "src": "any", + } + config_entry = await setup_unifi_integration( + hass, aioclient_mock, port_forward_response=[_data.copy()] + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + + ent_reg = er.async_get(hass) + ent_reg_entry = ent_reg.async_get("switch.unifi_network_plex") + assert ent_reg_entry.unique_id == "port_forward-5a32aa4ee4b0412345678911" + assert ent_reg_entry.entity_category is EntityCategory.CONFIG + + # Validate state object + switch_1 = hass.states.get("switch.unifi_network_plex") + assert switch_1 is not None + assert switch_1.state == STATE_ON + assert switch_1.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.SWITCH + + # Update state object + data = _data.copy() + data["enabled"] = False + mock_unifi_websocket(message=MessageKey.PORT_FORWARD_UPDATED, data=data) + await hass.async_block_till_done() + assert hass.states.get("switch.unifi_network_plex").state == STATE_OFF + + # Disable port forward + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{controller.host}:1234/api/s/{controller.site}" + + f"/rest/portforward/{data['_id']}", + ) + + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.unifi_network_plex"}, + blocking=True, + ) + assert aioclient_mock.call_count == 1 + data = _data.copy() + data["enabled"] = False + assert aioclient_mock.mock_calls[0][2] == data + + # Enable port forward + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_on", + {"entity_id": "switch.unifi_network_plex"}, + blocking=True, + ) + assert aioclient_mock.call_count == 2 + assert aioclient_mock.mock_calls[1][2] == _data + + # Availability signalling + + # Controller disconnects + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + assert hass.states.get("switch.unifi_network_plex").state == STATE_UNAVAILABLE + + # Controller reconnects + mock_unifi_websocket(state=WebsocketState.RUNNING) + await hass.async_block_till_done() + assert hass.states.get("switch.unifi_network_plex").state == STATE_OFF + + # Remove entity on deleted message + mock_unifi_websocket(message=MessageKey.PORT_FORWARD_DELETED, data=_data) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 diff --git a/tests/components/uvc/test_camera.py b/tests/components/uvc/test_camera.py index 0c532d9007d..dd42cfc2977 100644 --- a/tests/components/uvc/test_camera.py +++ b/tests/components/uvc/test_camera.py @@ -1,5 +1,5 @@ """The tests for UVC camera module.""" -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from unittest.mock import call, patch import pytest @@ -368,7 +368,7 @@ async def test_motion_recording_mode_properties( assert state assert state.state != STATE_RECORDING assert state.attributes["last_recording_start_time"] == datetime( - 2021, 1, 8, 1, 56, 32, 367000, tzinfo=timezone.utc + 2021, 1, 8, 1, 56, 32, 367000, tzinfo=UTC ) mock_remote.return_value.get_camera.return_value["recordingIndicator"] = "DISABLED" diff --git a/tests/components/wake_word/snapshots/test_init.ambr b/tests/components/wake_word/snapshots/test_init.ambr index ca6d5d950f0..cf7c09cd730 100644 --- a/tests/components/wake_word/snapshots/test_init.ambr +++ b/tests/components/wake_word/snapshots/test_init.ambr @@ -1,4 +1,7 @@ # serializer version: 1 +# name: test_detected_entity + None +# --- # name: test_ws_detect dict({ 'event': dict({ diff --git a/tests/components/wake_word/test_init.py b/tests/components/wake_word/test_init.py index 954cbe6dc8c..d37cb3aa540 100644 --- a/tests/components/wake_word/test_init.py +++ b/tests/components/wake_word/test_init.py @@ -3,9 +3,11 @@ from collections.abc import AsyncIterable, Generator from pathlib import Path import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components import wake_word from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, State from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.setup import async_setup_component @@ -147,7 +149,10 @@ async def test_config_entry_unload( async def test_detected_entity( - hass: HomeAssistant, tmp_path: Path, setup: MockProviderEntity + hass: HomeAssistant, + tmp_path: Path, + setup: MockProviderEntity, + snapshot: SnapshotAssertion, ) -> None: """Test successful detection through entity.""" @@ -158,9 +163,13 @@ async def test_detected_entity( timestamp += _MS_PER_CHUNK # Need 2 seconds to trigger + state = setup.state result = await setup.async_process_audio_stream(three_second_stream()) assert result == wake_word.DetectionResult("test_ww", 2048) + assert state != setup.state + assert state == snapshot + async def test_not_detected_entity( hass: HomeAssistant, setup: MockProviderEntity @@ -174,9 +183,13 @@ async def test_not_detected_entity( timestamp += _MS_PER_CHUNK # Need 2 seconds to trigger + state = setup.state result = await setup.async_process_audio_stream(one_second_stream()) assert result is None + # State should only change when there's a detection + assert state == setup.state + async def test_default_engine_none(hass: HomeAssistant, tmp_path: Path) -> None: """Test async_default_engine.""" @@ -224,3 +237,10 @@ async def test_restore_state( state = hass.states.get(entity_id) assert state assert state.state == timestamp + + +async def test_entity_attributes( + hass: HomeAssistant, mock_provider_entity: MockProviderEntity +) -> None: + """Test that the provider entity attributes match expectations.""" + assert mock_provider_entity.entity_category == EntityCategory.DIAGNOSTIC diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 8e8c5513097..3155d588e14 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -1,5 +1,5 @@ """Test the Whirlpool Sensor domain.""" -from datetime import datetime, timezone +from datetime import UTC, datetime from unittest.mock import MagicMock import pytest @@ -50,7 +50,7 @@ async def test_dryer_sensor_values( ) -> None: """Test the sensor value callbacks.""" hass.state = CoreState.not_running - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, timezone.utc) + thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, ( @@ -114,7 +114,7 @@ async def test_washer_sensor_values( ) -> None: """Test the sensor value callbacks.""" hass.state = CoreState.not_running - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, timezone.utc) + thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, ( @@ -281,7 +281,7 @@ async def test_restore_state( """Test sensor restore state.""" # Home assistant is not running yet hass.state = CoreState.not_running - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, timezone.utc) + thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, ( @@ -334,7 +334,7 @@ async def test_callback( ) -> None: """Test callback timestamp callback function.""" hass.state = CoreState.not_running - thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, timezone.utc) + thetimestamp: datetime = datetime(2022, 11, 29, 00, 00, 00, 00, UTC) mock_restore_cache_with_extra_data( hass, ( diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index a8cea01a864..51280c8d75c 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -200,7 +200,7 @@ async def test_setup_faulty_country( state = hass.states.get("binary_sensor.workday_sensor") assert state is None - assert "There is no country" in caplog.text + assert "Selected country ZZ is not valid" in caplog.text async def test_setup_faulty_province( @@ -215,7 +215,7 @@ async def test_setup_faulty_province( state = hass.states.get("binary_sensor.workday_sensor") assert state is None - assert "There is no subdivision" in caplog.text + assert "Selected province ZZ for country DE is not valid" in caplog.text async def test_setup_incorrect_add_remove( diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index b5c8cc1716e..80fc1bf2241 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1221,7 +1221,7 @@ def test_enum() -> None: schema("value3") -def test_socket_timeout(): # pylint: disable=invalid-name +def test_socket_timeout(): """Test socket timeout validator.""" schema = vol.Schema(cv.socket_timeout) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 56ee3f74140..803a57e12ed 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -11,7 +11,7 @@ import voluptuous as vol # To prevent circular import when running just this file from homeassistant import exceptions from homeassistant.auth.permissions import PolicyPermissions -import homeassistant.components # noqa: F401, pylint: disable=unused-import +import homeassistant.components # noqa: F401 from homeassistant.const import ( ATTR_ENTITY_ID, ENTITY_MATCH_ALL, diff --git a/tests/test_config.py b/tests/test_config.py index 407ca9ef54d..aeb25313302 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -311,7 +311,6 @@ def test_remove_lib_on_upgrade( mock_open = mock.mock_open() with patch("homeassistant.config.open", mock_open, create=True): opened_file = mock_open.return_value - # pylint: disable=no-member opened_file.readline.return_value = ha_version hass.config.path = mock.Mock() config_util.process_ha_config_upgrade(hass) @@ -335,7 +334,6 @@ def test_remove_lib_on_upgrade_94( mock_open = mock.mock_open() with patch("homeassistant.config.open", mock_open, create=True): opened_file = mock_open.return_value - # pylint: disable=no-member opened_file.readline.return_value = ha_version hass.config.path = mock.Mock() config_util.process_ha_config_upgrade(hass) @@ -356,7 +354,6 @@ def test_process_config_upgrade(hass: HomeAssistant) -> None: config_util, "__version__", "0.91.0" ): opened_file = mock_open.return_value - # pylint: disable=no-member opened_file.readline.return_value = ha_version config_util.process_ha_config_upgrade(hass) @@ -372,7 +369,6 @@ def test_config_upgrade_same_version(hass: HomeAssistant) -> None: mock_open = mock.mock_open() with patch("homeassistant.config.open", mock_open, create=True): opened_file = mock_open.return_value - # pylint: disable=no-member opened_file.readline.return_value = ha_version config_util.process_ha_config_upgrade(hass) @@ -386,7 +382,6 @@ def test_config_upgrade_no_file(hass: HomeAssistant) -> None: mock_open.side_effect = [FileNotFoundError(), mock.DEFAULT, mock.DEFAULT] with patch("homeassistant.config.open", mock_open, create=True): opened_file = mock_open.return_value - # pylint: disable=no-member config_util.process_ha_config_upgrade(hass) assert opened_file.write.call_count == 1 assert opened_file.write.call_args == mock.call(__version__) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 3485162cbb3..f04f033b49f 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1178,6 +1178,27 @@ async def test_entry_options_abort( ) +async def test_entry_options_unknown_config_entry( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test that we can abort options flow.""" + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) + + class TestFlow: + """Test flow.""" + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Test options flow.""" + + with pytest.raises(config_entries.UnknownEntry): + await manager.options.async_create_flow( + "blah", context={"source": "test"}, data=None + ) + + async def test_entry_setup_succeed( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -3919,3 +3940,75 @@ async def test_task_tracking(hass: HomeAssistant) -> None: hass.loop.call_soon(event.set) await entry._async_process_on_unload(hass) assert results == ["on_unload", "background", "normal"] + + +async def test_preview_supported( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test preview support.""" + + preview_calls = [] + + class MockFlowHandler(config_entries.ConfigFlow): + """Define a mock flow handler.""" + + VERSION = 1 + + async def async_step_test1(self, data): + """Mock Reauth.""" + return self.async_show_form(step_id="next") + + async def async_step_test2(self, data): + """Mock Reauth.""" + return self.async_show_form(step_id="next", preview="test") + + @callback + @staticmethod + def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview.""" + preview_calls.append(None) + + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) + + assert len(preview_calls) == 0 + + with patch.dict( + config_entries.HANDLERS, {"comp": MockFlowHandler, "test": MockFlowHandler} + ): + result = await manager.flow.async_init("test", context={"source": "test1"}) + + assert len(preview_calls) == 0 + assert result["preview"] is None + + result = await manager.flow.async_init("test", context={"source": "test2"}) + + assert len(preview_calls) == 1 + assert result["preview"] == "test" + + +async def test_preview_not_supported( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test preview support.""" + + class MockFlowHandler(config_entries.ConfigFlow): + """Define a mock flow handler.""" + + VERSION = 1 + + async def async_step_user(self, data): + """Mock Reauth.""" + return self.async_show_form(step_id="user_confirm") + + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) + + with patch.dict( + config_entries.HANDLERS, {"comp": MockFlowHandler, "test": MockFlowHandler} + ): + result = await manager.flow.async_init( + "test", context={"source": config_entries.SOURCE_USER} + ) + + assert result["preview"] is None diff --git a/tests/testing_config/custom_components/test/datetime.py b/tests/testing_config/custom_components/test/datetime.py index 7fca8d57881..ba511e81648 100644 --- a/tests/testing_config/custom_components/test/datetime.py +++ b/tests/testing_config/custom_components/test/datetime.py @@ -2,7 +2,7 @@ Call init before using it in your tests to ensure clean test data. """ -from datetime import datetime, timezone +from datetime import UTC, datetime from homeassistant.components.datetime import DateTimeEntity @@ -37,7 +37,7 @@ def init(empty=False): MockDateTimeEntity( name="test", unique_id=UNIQUE_DATETIME, - native_value=datetime(2020, 1, 1, 1, 2, 3, tzinfo=timezone.utc), + native_value=datetime(2020, 1, 1, 1, 2, 3, tzinfo=UTC), ), ] ) diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 60b1fd547fc..7c5e959aabc 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -31,7 +31,6 @@ GAMUT_INVALID_4 = color_util.GamutType( ) -# pylint: disable=invalid-name def test_color_RGB_to_xy_brightness() -> None: """Test color_RGB_to_xy_brightness.""" assert color_util.color_RGB_to_xy_brightness(0, 0, 0) == (0, 0, 0) diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index bd99889234f..4f60c5836b5 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -354,8 +354,6 @@ def load_yaml(fname, string, secrets=None): class TestSecrets(unittest.TestCase): """Test the secrets parameter in the yaml utility.""" - # pylint: disable=invalid-name - def setUp(self): """Create & load secrets file.""" config_dir = get_test_config_dir()