diff --git a/.coveragerc b/.coveragerc index fa0bf2fbd4c..d7048e4288f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -73,6 +73,10 @@ omit = homeassistant/components/apple_tv/browse_media.py homeassistant/components/apple_tv/media_player.py homeassistant/components/apple_tv/remote.py + homeassistant/components/aprilaire/__init__.py + homeassistant/components/aprilaire/climate.py + homeassistant/components/aprilaire/coordinator.py + homeassistant/components/aprilaire/entity.py homeassistant/components/aqualogic/* homeassistant/components/aquostv/media_player.py homeassistant/components/arcam_fmj/__init__.py @@ -484,6 +488,7 @@ omit = homeassistant/components/gpsd/sensor.py homeassistant/components/greenwave/light.py homeassistant/components/growatt_server/__init__.py + homeassistant/components/growatt_server/const.py homeassistant/components/growatt_server/sensor.py homeassistant/components/growatt_server/sensor_types/* homeassistant/components/gstreamer/media_player.py @@ -872,6 +877,7 @@ omit = homeassistant/components/notion/__init__.py homeassistant/components/notion/binary_sensor.py homeassistant/components/notion/sensor.py + homeassistant/components/notion/util.py homeassistant/components/nsw_fuel_station/sensor.py homeassistant/components/nuki/__init__.py homeassistant/components/nuki/binary_sensor.py @@ -1533,6 +1539,7 @@ omit = homeassistant/components/vicare/entity.py homeassistant/components/vicare/number.py homeassistant/components/vicare/sensor.py + homeassistant/components/vicare/types.py homeassistant/components/vicare/utils.py homeassistant/components/vicare/water_heater.py homeassistant/components/vilfo/__init__.py @@ -1691,8 +1698,10 @@ omit = homeassistant/components/myuplink/__init__.py homeassistant/components/myuplink/api.py homeassistant/components/myuplink/application_credentials.py + homeassistant/components/myuplink/binary_sensor.py homeassistant/components/myuplink/coordinator.py homeassistant/components/myuplink/entity.py + homeassistant/components/myuplink/helpers.py homeassistant/components/myuplink/sensor.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 522544bba80..e3ced9b9692 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1079,7 +1079,7 @@ jobs: uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) if: needs.info.outputs.test_full_suite == 'true' - uses: Wandalen/wretry.action@v1.3.0 + uses: Wandalen/wretry.action@v1.4.4 with: action: codecov/codecov-action@v3.1.3 with: | @@ -1090,7 +1090,7 @@ jobs: attempt_delay: 30000 - name: Upload coverage to Codecov (partial coverage) if: needs.info.outputs.test_full_suite == 'false' - uses: Wandalen/wretry.action@v1.3.0 + uses: Wandalen/wretry.action@v1.4.4 with: action: codecov/codecov-action@v3.1.3 with: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index bdec74a3aff..89d8f3b4a71 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.23.2 + uses: github/codeql-action/init@v3.24.1 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.23.2 + uses: github/codeql-action/analyze@v3.24.1 with: category: "/language:python" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc3be5c2391..4b96b5ee2aa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.15 + rev: v0.2.1 hooks: - id: ruff args: diff --git a/.strict-typing b/.strict-typing index bd92da2fc50..74535719bb3 100644 --- a/.strict-typing +++ b/.strict-typing @@ -80,6 +80,7 @@ homeassistant.components.anthemav.* homeassistant.components.apache_kafka.* homeassistant.components.apcupsd.* homeassistant.components.api.* +homeassistant.components.apple_tv.* homeassistant.components.apprise.* homeassistant.components.aprs.* homeassistant.components.aqualogic.* diff --git a/CODEOWNERS b/CODEOWNERS index af196548bb3..b691457f066 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -104,6 +104,8 @@ build.json @home-assistant/supervisor /tests/components/application_credentials/ @home-assistant/core /homeassistant/components/apprise/ @caronc /tests/components/apprise/ @caronc +/homeassistant/components/aprilaire/ @chamberlain2007 +/tests/components/aprilaire/ @chamberlain2007 /homeassistant/components/aprs/ @PhilRW /tests/components/aprs/ @PhilRW /homeassistant/components/aranet/ @aschmitz @thecode @@ -584,6 +586,8 @@ build.json @home-assistant/supervisor /tests/components/humidifier/ @home-assistant/core @Shulyaka /homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock /tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock +/homeassistant/components/husqvarna_automower/ @Thomas55555 +/tests/components/husqvarna_automower/ @Thomas55555 /homeassistant/components/huum/ @frwickst /tests/components/huum/ @frwickst /homeassistant/components/hvv_departures/ @vigonotion @@ -786,8 +790,6 @@ build.json @home-assistant/supervisor /homeassistant/components/media_source/ @hunterjm /tests/components/media_source/ @hunterjm /homeassistant/components/mediaroom/ @dgomes -/homeassistant/components/melcloud/ @vilppuvuorinen -/tests/components/melcloud/ @vilppuvuorinen /homeassistant/components/melissa/ @kennedyshead /tests/components/melissa/ @kennedyshead /homeassistant/components/melnor/ @vanstinator @@ -1460,7 +1462,8 @@ build.json @home-assistant/supervisor /tests/components/valve/ @home-assistant/core /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra -/homeassistant/components/velux/ @Julius2342 +/homeassistant/components/velux/ @Julius2342 @DeerMaximum +/tests/components/velux/ @Julius2342 @DeerMaximum /homeassistant/components/venstar/ @garbled1 @jhollowe /tests/components/venstar/ @garbled1 @jhollowe /homeassistant/components/versasense/ @imstevenxyz diff --git a/build.yaml b/build.yaml index d0baa4ac18e..f6ffac3bd1d 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.02.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.02.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.02.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.02.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.02.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.02.1 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.02.1 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.02.1 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.02.1 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.02.1 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index cc3d87319d0..83aa8cb893d 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -35,6 +35,7 @@ from .helpers import ( recorder, restore_state, template, + translation, ) from .helpers.dispatcher import async_dispatcher_send from .helpers.typing import ConfigType @@ -217,7 +218,7 @@ async def async_setup_hass( ) # Ask integrations to shut down. It's messy but we can't # do a clean stop without knowing what is broken - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): async with hass.timeout.async_timeout(10): await hass.async_stop() @@ -291,6 +292,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None: platform.uname().processor # pylint: disable=expression-not-assigned # Load the registries and cache the result of platform.uname().processor + translation.async_setup(hass) entity.async_setup(hass) template.async_setup(hass) await asyncio.gather( @@ -738,7 +740,7 @@ async def _async_set_up_integrations( STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME ): await async_setup_multi_components(hass, stage_1_domains, config) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Setup timed out for stage 1 - moving forward") # Add after dependencies when setting up stage 2 domains @@ -751,7 +753,7 @@ async def _async_set_up_integrations( STAGE_2_TIMEOUT, cool_down=COOLDOWN_TIME ): await async_setup_multi_components(hass, stage_2_domains, config) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Setup timed out for stage 2 - moving forward") # Wrap up startup @@ -759,7 +761,7 @@ async def _async_set_up_integrations( try: async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME): await hass.async_block_till_done() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Setup timed out for bootstrap - moving forward") watch_task.cancel() diff --git a/homeassistant/brands/tplink.json b/homeassistant/brands/tplink.json index bc8d38b3e71..06ab621ed32 100644 --- a/homeassistant/brands/tplink.json +++ b/homeassistant/brands/tplink.json @@ -1,6 +1,6 @@ { "domain": "tplink", "name": "TP-Link", - "integrations": ["tplink", "tplink_omada", "tplink_lte"], + "integrations": ["tplink", "tplink_omada", "tplink_lte", "tplink_tapo"], "iot_standards": ["matter"] } diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py index b1d113dad73..b3fc7872c85 100644 --- a/homeassistant/components/accuweather/config_flow.py +++ b/homeassistant/components/accuweather/config_flow.py @@ -1,7 +1,6 @@ """Adds config flow for AccuWeather.""" from __future__ import annotations -import asyncio from asyncio import timeout from typing import Any @@ -61,7 +60,7 @@ class AccuWeatherFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): longitude=user_input[CONF_LONGITUDE], ) await accuweather.async_get_location() - except (ApiError, ClientConnectorError, asyncio.TimeoutError, ClientError): + except (ApiError, ClientConnectorError, TimeoutError, ClientError): errors["base"] = "cannot_connect" except InvalidApiKeyError: errors[CONF_API_KEY] = "invalid_api_key" diff --git a/homeassistant/components/acmeda/config_flow.py b/homeassistant/components/acmeda/config_flow.py index b0dd287f428..56a11aff200 100644 --- a/homeassistant/components/acmeda/config_flow.py +++ b/homeassistant/components/acmeda/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Rollease Acmeda Automate Pulse Hub.""" from __future__ import annotations -import asyncio from asyncio import timeout from contextlib import suppress from typing import Any @@ -42,7 +41,7 @@ class AcmedaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } hubs: list[aiopulse.Hub] = [] - with suppress(asyncio.TimeoutError): + with suppress(TimeoutError): async with timeout(5): async for hub in aiopulse.Hub.discover(): if hub.id not in already_configured: diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index 1f80553031b..84d9e29a518 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -303,7 +303,7 @@ class AdsEntity(Entity): try: async with timeout(10): await self._event.wait() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.debug("Variable %s: Timeout during first update", ads_var) @property diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py index d7caaa120fc..a494ac0c93f 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -77,9 +77,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_data = entry.data.copy() del new_data[CONF_RADIUS] - entry.version = 2 hass.config_entries.async_update_entry( - entry, data=new_data, options=new_options + entry, data=new_data, options=new_options, version=2 ) _LOGGER.info("Migration to version %s successful", entry.version) diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index bd1c481ce65..89afddad76e 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -88,9 +88,13 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity): _attr_name = None _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, ac_number, info): """Initialize the climate device.""" @@ -192,9 +196,14 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity): _attr_has_entity_name = True _attr_name = None - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = AT_GROUP_MODES + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, group_number, info): """Initialize the climate device.""" diff --git a/homeassistant/components/airtouch5/climate.py b/homeassistant/components/airtouch5/climate.py index 829915ce6d1..ee92f68c0ed 100644 --- a/homeassistant/components/airtouch5/climate.py +++ b/homeassistant/components/airtouch5/climate.py @@ -120,15 +120,12 @@ class Airtouch5ClimateEntity(ClimateEntity, Airtouch5Entity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = 1 _attr_name = None + _enable_turn_on_off_backwards_compatibility = False class Airtouch5AC(Airtouch5ClimateEntity): """Representation of the AC unit. Used to control the overall HVAC Mode.""" - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE - ) - def __init__(self, client: Airtouch5SimpleClient, ability: AcAbility) -> None: """Initialise the Climate Entity.""" super().__init__(client) @@ -152,6 +149,14 @@ class Airtouch5AC(Airtouch5ClimateEntity): if ability.supports_mode_heat: self._attr_hvac_modes.append(HVACMode.HEAT) + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + if len(self.hvac_modes) > 1: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + self._attr_fan_modes = [] if ability.supports_fan_speed_quiet: self._attr_fan_modes.append(FAN_DIFFUSE) @@ -262,7 +267,10 @@ class Airtouch5Zone(Airtouch5ClimateEntity): _attr_hvac_modes = [HVACMode.OFF, HVACMode.FAN_ONLY] _attr_preset_modes = [PRESET_NONE, PRESET_BOOST] _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) def __init__( diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 1d5babee6d7..42cc1e1fade 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -242,7 +242,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 1 -> 2: One geography per config entry if version == 1: - version = entry.version = 2 + version = 2 # Update the config entry to only include the first geography (there is always # guaranteed to be at least one): @@ -255,6 +255,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unique_id=first_id, title=f"Cloud API ({first_id})", data={CONF_API_KEY: entry.data[CONF_API_KEY], **first_geography}, + version=version, ) # For any geographies that remain, create a new config entry for each one: @@ -379,7 +380,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: }, ) else: - entry.version = version + hass.config_entries.async_update_entry(entry, version=version) LOGGER.info("Migration to version %s successful", version) diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index 73333d346c5..1bab9dd6c33 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -144,11 +144,6 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): """Define an Airzone Cloud climate.""" _attr_name = None - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _enable_turn_on_off_backwards_compatibility = False @@ -180,6 +175,12 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): class AirzoneDeviceClimate(AirzoneClimate): """Define an Airzone Cloud Device base class.""" + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + async def async_turn_on(self) -> None: """Turn the entity on.""" params = { @@ -217,6 +218,12 @@ class AirzoneDeviceClimate(AirzoneClimate): class AirzoneDeviceGroupClimate(AirzoneClimate): """Define an Airzone Cloud DeviceGroup base class.""" + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + async def async_turn_on(self) -> None: """Turn the entity on.""" params = { diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 3df3c0dbe0a..d1c7bc5668b 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -1,5 +1,4 @@ """The aladdin_connect component.""" -import asyncio import logging from typing import Final @@ -29,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: await acc.login() - except (ClientError, asyncio.TimeoutError, Aladdin.ConnectionError) as ex: + except (ClientError, TimeoutError, Aladdin.ConnectionError) as ex: raise ConfigEntryNotReady("Can not connect to host") from ex except Aladdin.InvalidPasswordError as ex: raise ConfigEntryAuthFailed("Incorrect Password") from ex diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index e5170e9b0a2..d14b7b7c35e 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Aladdin Connect cover integration.""" from __future__ import annotations -import asyncio from collections.abc import Mapping from typing import Any @@ -42,7 +41,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: ) try: await acc.login() - except (ClientError, asyncio.TimeoutError, Aladdin.ConnectionError) as ex: + except (ClientError, TimeoutError, Aladdin.ConnectionError) as ex: raise ex except Aladdin.InvalidPasswordError as ex: @@ -81,7 +80,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except InvalidAuth: errors["base"] = "invalid_auth" - except (ClientError, asyncio.TimeoutError, Aladdin.ConnectionError): + except (ClientError, TimeoutError, Aladdin.ConnectionError): errors["base"] = "cannot_connect" else: @@ -117,7 +116,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except InvalidAuth: errors["base"] = "invalid_auth" - except (ClientError, asyncio.TimeoutError, Aladdin.ConnectionError): + except (ClientError, TimeoutError, Aladdin.ConnectionError): errors["base"] = "cannot_connect" else: diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py index 527e51b5390..10a7be4967e 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -122,7 +122,7 @@ class Auth: allow_redirects=True, ) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.error("Timeout calling LWA to get auth token") return None diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index a1ab1d77081..02aaed25742 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -29,12 +29,20 @@ class AbstractConfig(ABC): """Initialize abstract config.""" self.hass = hass self._enable_proactive_mode_lock = asyncio.Lock() + self._on_deinitialize: list[CALLBACK_TYPE] = [] async def async_initialize(self) -> None: """Perform async initialization of config.""" self._store = AlexaConfigStore(self.hass) await self._store.async_load() + @callback + def async_deinitialize(self) -> None: + """Remove listeners.""" + _LOGGER.debug("async_deinitialize") + while self._on_deinitialize: + self._on_deinitialize.pop()() + @property def supports_auth(self) -> bool: """Return if config supports auth.""" diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 20e66dfa084..3ad863747e5 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -1,7 +1,6 @@ """Alexa state report code.""" from __future__ import annotations -import asyncio from asyncio import timeout from http import HTTPStatus import json @@ -375,7 +374,7 @@ async def async_send_changereport_message( allow_redirects=True, ) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.error("Timeout sending report to Alexa for %s", alexa_entity.entity_id) return @@ -531,7 +530,7 @@ async def async_send_doorbell_event_message( allow_redirects=True, ) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.error("Timeout sending report to Alexa for %s", alexa_entity.entity_id) return diff --git a/homeassistant/components/amberelectric/config_flow.py b/homeassistant/components/amberelectric/config_flow.py index 4011f442ee2..765e219b6d7 100644 --- a/homeassistant/components/amberelectric/config_flow.py +++ b/homeassistant/components/amberelectric/config_flow.py @@ -3,18 +3,46 @@ from __future__ import annotations import amberelectric from amberelectric.api import amber_api -from amberelectric.model.site import Site +from amberelectric.model.site import Site, SiteStatus import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_TOKEN from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) -from .const import CONF_SITE_ID, CONF_SITE_NAME, CONF_SITE_NMI, DOMAIN +from .const import CONF_SITE_ID, CONF_SITE_NAME, DOMAIN API_URL = "https://app.amber.com.au/developers" +def generate_site_selector_name(site: Site) -> str: + """Generate the name to show in the site drop down in the configuration flow.""" + if site.status == SiteStatus.CLOSED: + return site.nmi + " (Closed: " + site.closed_on.isoformat() + ")" # type: ignore[no-any-return] + if site.status == SiteStatus.PENDING: + return site.nmi + " (Pending)" # type: ignore[no-any-return] + return site.nmi # type: ignore[no-any-return] + + +def filter_sites(sites: list[Site]) -> list[Site]: + """Deduplicates the list of sites.""" + filtered: list[Site] = [] + filtered_nmi: set[str] = set() + + for site in sorted(sites, key=lambda site: site.status.value): + if site.status == SiteStatus.ACTIVE or site.nmi not in filtered_nmi: + filtered.append(site) + filtered_nmi.add(site.nmi) + + return filtered + + class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -31,7 +59,7 @@ class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api: amber_api.AmberApi = amber_api.AmberApi.create(configuration) try: - sites: list[Site] = api.get_sites() + sites: list[Site] = filter_sites(api.get_sites()) if len(sites) == 0: self._errors[CONF_API_TOKEN] = "no_site" return None @@ -86,38 +114,31 @@ class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): assert self._sites is not None assert self._api_token is not None - api_token = self._api_token if user_input is not None: - site_nmi = user_input[CONF_SITE_NMI] - sites = [site for site in self._sites if site.nmi == site_nmi] - site = sites[0] - site_id = site.id + site_id = user_input[CONF_SITE_ID] name = user_input.get(CONF_SITE_NAME, site_id) return self.async_create_entry( title=name, - data={ - CONF_SITE_ID: site_id, - CONF_API_TOKEN: api_token, - CONF_SITE_NMI: site.nmi, - }, + data={CONF_SITE_ID: site_id, CONF_API_TOKEN: self._api_token}, ) - user_input = { - CONF_API_TOKEN: api_token, - CONF_SITE_NMI: "", - CONF_SITE_NAME: "", - } - return self.async_show_form( step_id="site", data_schema=vol.Schema( { - vol.Required( - CONF_SITE_NMI, default=user_input[CONF_SITE_NMI] - ): vol.In([site.nmi for site in self._sites]), - vol.Optional( - CONF_SITE_NAME, default=user_input[CONF_SITE_NAME] - ): str, + vol.Required(CONF_SITE_ID): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + value=site.id, + label=generate_site_selector_name(site), + ) + for site in self._sites + ], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_SITE_NAME): str, } ), errors=self._errors, diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index 8416b7ca33c..6166b21c19f 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -6,7 +6,6 @@ from homeassistant.const import Platform DOMAIN = "amberelectric" CONF_SITE_NAME = "site_name" CONF_SITE_ID = "site_id" -CONF_SITE_NMI = "site_nmi" ATTRIBUTION = "Data provided by Amber Electric" diff --git a/homeassistant/components/amberelectric/manifest.json b/homeassistant/components/amberelectric/manifest.json index 29de18d96de..13a9f257adb 100644 --- a/homeassistant/components/amberelectric/manifest.json +++ b/homeassistant/components/amberelectric/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/amberelectric", "iot_class": "cloud_polling", "loggers": ["amberelectric"], - "requirements": ["amberelectric==1.0.4"] + "requirements": ["amberelectric==1.1.0"] } diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 1718b559fde..7dd6b455e73 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -111,7 +111,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: en_reg = er.async_get(hass) en_reg.async_clear_config_entry(entry.entry_id) - version = entry.version = 2 + version = 2 + hass.config_entries.async_update_entry(entry, version=version) LOGGER.info("Migration to version %s successful", version) diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 1c81eacd14a..bce4b69ecf1 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -329,7 +329,7 @@ class Analytics: response.status, self.endpoint, ) - except asyncio.TimeoutError: + except TimeoutError: LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL) except aiohttp.ClientError as err: LOGGER.error( diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py index a59d2a1c97c..23965a9fcb5 100644 --- a/homeassistant/components/analytics_insights/__init__.py +++ b/homeassistant/components/analytics_insights/__init__.py @@ -3,11 +3,15 @@ from __future__ import annotations from dataclasses import dataclass -from python_homeassistant_analytics import HomeassistantAnalyticsClient +from python_homeassistant_analytics import ( + HomeassistantAnalyticsClient, + HomeassistantAnalyticsConnectionError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_TRACKED_INTEGRATIONS, DOMAIN @@ -28,7 +32,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Homeassistant Analytics from a config entry.""" client = HomeassistantAnalyticsClient(session=async_get_clientsession(hass)) - integrations = await client.get_integrations() + try: + integrations = await client.get_integrations() + except HomeassistantAnalyticsConnectionError as ex: + raise ConfigEntryNotReady("Could not fetch integration list") from ex names = {} for integration in entry.options[CONF_TRACKED_INTEGRATIONS]: diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index b409a9c0fb9..d2ebdd943a2 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -53,10 +53,25 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the initial step.""" self._async_abort_entries_match() - if user_input: - return self.async_create_entry( - title="Home Assistant Analytics Insights", data={}, options=user_input - ) + errors: dict[str, str] = {} + if user_input is not None: + if not user_input.get(CONF_TRACKED_INTEGRATIONS) and not user_input.get( + CONF_TRACKED_CUSTOM_INTEGRATIONS + ): + errors["base"] = "no_integrations_selected" + else: + return self.async_create_entry( + title="Home Assistant Analytics Insights", + data={}, + options={ + CONF_TRACKED_INTEGRATIONS: user_input.get( + CONF_TRACKED_INTEGRATIONS, [] + ), + CONF_TRACKED_CUSTOM_INTEGRATIONS: user_input.get( + CONF_TRACKED_CUSTOM_INTEGRATIONS, [] + ), + }, + ) client = HomeassistantAnalyticsClient( session=async_get_clientsession(self.hass) @@ -78,16 +93,17 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): ] return self.async_show_form( step_id="user", + errors=errors, data_schema=vol.Schema( { - vol.Required(CONF_TRACKED_INTEGRATIONS): SelectSelector( + vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector( SelectSelectorConfig( options=options, multiple=True, sort=True, ) ), - vol.Required(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector( + vol.Optional(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector( SelectSelectorConfig( options=list(custom_integrations), multiple=True, @@ -106,8 +122,24 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the options.""" - if user_input: - return self.async_create_entry(title="", data=user_input) + errors: dict[str, str] = {} + if user_input is not None: + if not user_input.get(CONF_TRACKED_INTEGRATIONS) and not user_input.get( + CONF_TRACKED_CUSTOM_INTEGRATIONS + ): + errors["base"] = "no_integrations_selected" + else: + return self.async_create_entry( + title="", + data={ + CONF_TRACKED_INTEGRATIONS: user_input.get( + CONF_TRACKED_INTEGRATIONS, [] + ), + CONF_TRACKED_CUSTOM_INTEGRATIONS: user_input.get( + CONF_TRACKED_CUSTOM_INTEGRATIONS, [] + ), + }, + ) client = HomeassistantAnalyticsClient( session=async_get_clientsession(self.hass) @@ -129,17 +161,18 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry): ] return self.async_show_form( step_id="init", + errors=errors, data_schema=self.add_suggested_values_to_schema( vol.Schema( { - vol.Required(CONF_TRACKED_INTEGRATIONS): SelectSelector( + vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector( SelectSelectorConfig( options=options, multiple=True, sort=True, ) ), - vol.Required(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector( + vol.Optional(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector( SelectSelectorConfig( options=list(custom_integrations), multiple=True, diff --git a/homeassistant/components/analytics_insights/sensor.py b/homeassistant/components/analytics_insights/sensor.py index e0fe2c79413..90e9ff51b87 100644 --- a/homeassistant/components/analytics_insights/sensor.py +++ b/homeassistant/components/analytics_insights/sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -93,6 +94,7 @@ class HomeassistantAnalyticsSensor( """Home Assistant Analytics Sensor.""" _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC entity_description: AnalyticsSensorEntityDescription diff --git a/homeassistant/components/analytics_insights/strings.json b/homeassistant/components/analytics_insights/strings.json index 96ec59f299b..6de1ab9dbe4 100644 --- a/homeassistant/components/analytics_insights/strings.json +++ b/homeassistant/components/analytics_insights/strings.json @@ -3,25 +3,41 @@ "step": { "user": { "data": { - "tracked_integrations": "Integrations" + "tracked_integrations": "Integrations", + "tracked_custom_integrations": "Custom integrations" + }, + "data_description": { + "tracked_integrations": "Select the integrations you want to track", + "tracked_custom_integrations": "Select the custom integrations you want to track" } } }, "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "error": { + "no_integration_selected": "You must select at least one integration to track" } }, "options": { "step": { "init": { "data": { - "tracked_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_integrations%]" + "tracked_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_integrations%]", + "tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data::tracked_custom_integrations%]" + }, + "data_description": { + "tracked_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_integrations%]", + "tracked_custom_integrations": "[%key:component::analytics_insights::config::step::user::data_description::tracked_custom_integrations%]" } } }, "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "error": { + "no_integration_selected": "[%key:component::analytics_insights::config::error::no_integration_selected%]" } }, "entity": { diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index c78321589a9..9e99a93efa6 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -1,7 +1,6 @@ """The Android TV Remote integration.""" from __future__ import annotations -import asyncio from asyncio import timeout import logging @@ -50,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except InvalidAuth as exc: # The Android TV is hard reset or the certificate and key files were deleted. raise ConfigEntryAuthFailed from exc - except (CannotConnect, ConnectionClosed, asyncio.TimeoutError) as exc: + except (CannotConnect, ConnectionClosed, TimeoutError) as exc: # The Android TV is network unreachable. Raise exception and let Home Assistant retry # later. If device gets a new IP address the zeroconf flow will update the config. raise ConfigEntryNotReady from exc diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json index 436918ae772..21580b87286 100644 --- a/homeassistant/components/aosmith/manifest.json +++ b/homeassistant/components/aosmith/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aosmith", "iot_class": "cloud_polling", - "requirements": ["py-aosmith==1.0.6"] + "requirements": ["py-aosmith==1.0.8"] } diff --git a/homeassistant/components/apcupsd/config_flow.py b/homeassistant/components/apcupsd/config_flow.py index 99c78fd5d33..25a1ccf7e02 100644 --- a/homeassistant/components/apcupsd/config_flow.py +++ b/homeassistant/components/apcupsd/config_flow.py @@ -1,7 +1,6 @@ """Config flow for APCUPSd integration.""" from __future__ import annotations -import asyncio from typing import Any import voluptuous as vol @@ -54,7 +53,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): coordinator = APCUPSdCoordinator(self.hass, host, port) await coordinator.async_request_refresh() - if isinstance(coordinator.last_exception, (UpdateFailed, asyncio.TimeoutError)): + if isinstance(coordinator.last_exception, (UpdateFailed, TimeoutError)): errors = {"base": "cannot_connect"} return self.async_show_form( step_id="user", data_schema=_SCHEMA, errors=errors diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index d012dfc372f..38d791d0dda 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -175,7 +175,7 @@ class APIEventStream(HomeAssistantView): msg = f"data: {payload}\n\n" _LOGGER.debug("STREAM %s WRITING %s", id(stop_obj), msg.strip()) await response.write(msg.encode("UTF-8")) - except asyncio.TimeoutError: + except TimeoutError: await to_write.put(STREAM_PING_PAYLOAD) except asyncio.CancelledError: @@ -222,7 +222,7 @@ class APIStatesView(HomeAssistantView): if entity_perm(state.entity_id, "read") ) response = web.Response( - body=b"[" + b",".join(states) + b"]", + body=b"".join((b"[", b",".join(states), b"]")), content_type=CONTENT_TYPE_JSON, zlib_executor_size=32768, ) diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index 8f52db13cfa..c369b07de36 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -1,8 +1,10 @@ """The Apple TV integration.""" +from __future__ import annotations + import asyncio import logging from random import randrange -from typing import TYPE_CHECKING, cast +from typing import Any, cast from pyatv import connect, exceptions, scan from pyatv.conf import AppleTV @@ -25,8 +27,8 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo @@ -40,7 +42,8 @@ from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Apple TV" +DEFAULT_NAME_TV = "Apple TV" +DEFAULT_NAME_HP = "HomePod" BACKOFF_TIME_LOWER_LIMIT = 15 # seconds BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes @@ -56,14 +59,39 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: manager = AppleTVManager(hass, entry) if manager.is_on: - await manager.connect_once(raise_missing_credentials=True) - if not manager.atv: - address = entry.data[CONF_ADDRESS] - raise ConfigEntryNotReady(f"Not found at {address}, waiting for discovery") + address = entry.data[CONF_ADDRESS] + + try: + await manager.async_first_connect() + except ( + exceptions.AuthenticationError, + exceptions.InvalidCredentialsError, + exceptions.NoCredentialsError, + ) as ex: + raise ConfigEntryAuthFailed( + f"{address}: Authentication failed, try reconfiguring device: {ex}" + ) from ex + except ( + asyncio.CancelledError, + exceptions.ConnectionLostError, + exceptions.ConnectionFailedError, + ) as ex: + raise ConfigEntryNotReady(f"{address}: {ex}") from ex + except ( + exceptions.ProtocolError, + exceptions.NoServiceError, + exceptions.PairingError, + exceptions.BackOffError, + exceptions.DeviceIdMissingError, + ) as ex: + _LOGGER.debug( + "Error setting up apple_tv at %s: %s", address, ex, exc_info=ex + ) + raise ConfigEntryNotReady(f"{address}: {ex}") from ex hass.data.setdefault(DOMAIN, {})[entry.unique_id] = manager - async def on_hass_stop(event): + async def on_hass_stop(event: Event) -> None: """Stop push updates when hass stops.""" await manager.disconnect() @@ -94,33 +122,29 @@ class AppleTVEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True _attr_name = None + atv: AppleTVInterface | None = None - def __init__( - self, name: str, identifier: str | None, manager: "AppleTVManager" - ) -> None: + def __init__(self, name: str, identifier: str, manager: AppleTVManager) -> None: """Initialize device.""" - self.atv: AppleTVInterface = None # type: ignore[assignment] self.manager = manager - if TYPE_CHECKING: - assert identifier is not None self._attr_unique_id = identifier self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, identifier)}, name=name, ) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle when an entity is about to be added to Home Assistant.""" @callback - def _async_connected(atv): + def _async_connected(atv: AppleTVInterface) -> None: """Handle that a connection was made to a device.""" self.atv = atv self.async_device_connected(atv) self.async_write_ha_state() @callback - def _async_disconnected(): + def _async_disconnected() -> None: """Handle that a connection to a device was lost.""" self.async_device_disconnected() self.atv = None @@ -143,10 +167,10 @@ class AppleTVEntity(Entity): ) ) - def async_device_connected(self, atv): + def async_device_connected(self, atv: AppleTVInterface) -> None: """Handle when connection is made to device.""" - def async_device_disconnected(self): + def async_device_disconnected(self) -> None: """Handle when connection was lost to device.""" @@ -158,22 +182,23 @@ class AppleTVManager(DeviceListener): in case of problems. """ + atv: AppleTVInterface | None = None + _connection_attempts = 0 + _connection_was_lost = False + _task: asyncio.Task[None] | None = None + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize power manager.""" self.config_entry = config_entry self.hass = hass - self.atv: AppleTVInterface | None = None self.is_on = not config_entry.options.get(CONF_START_OFF, False) - self._connection_attempts = 0 - self._connection_was_lost = False - self._task = None - async def init(self): + async def init(self) -> None: """Initialize power management.""" if self.is_on: await self.connect() - def connection_lost(self, _): + def connection_lost(self, exception: Exception) -> None: """Device was unexpectedly disconnected. This is a callback function from pyatv.interface.DeviceListener. @@ -184,14 +209,14 @@ class AppleTVManager(DeviceListener): self._connection_was_lost = True self._handle_disconnect() - def connection_closed(self): + def connection_closed(self) -> None: """Device connection was (intentionally) closed. This is a callback function from pyatv.interface.DeviceListener. """ self._handle_disconnect() - def _handle_disconnect(self): + def _handle_disconnect(self) -> None: """Handle that the device disconnected and restart connect loop.""" if self.atv: self.atv.close() @@ -199,12 +224,12 @@ class AppleTVManager(DeviceListener): self._dispatch_send(SIGNAL_DISCONNECTED) self._start_connect_loop() - async def connect(self): + async def connect(self) -> None: """Connect to device.""" self.is_on = True self._start_connect_loop() - async def disconnect(self): + async def disconnect(self) -> None: """Disconnect from device.""" _LOGGER.debug("Disconnecting from device") self.is_on = False @@ -218,7 +243,7 @@ class AppleTVManager(DeviceListener): except Exception: # pylint: disable=broad-except _LOGGER.exception("An error occurred while disconnecting") - def _start_connect_loop(self): + def _start_connect_loop(self) -> None: """Start background connect loop to device.""" if not self._task and self.atv is None and self.is_on: self._task = asyncio.create_task(self._connect_loop()) @@ -227,11 +252,25 @@ class AppleTVManager(DeviceListener): "Not starting connect loop (%s, %s)", self.atv is None, self.is_on ) + async def _connect_once(self, raise_missing_credentials: bool) -> None: + """Connect to device once.""" + if conf := await self._scan(): + await self._connect(conf, raise_missing_credentials) + + async def async_first_connect(self) -> None: + """Connect to device for the first time.""" + connect_ok = False + try: + await self._connect_once(raise_missing_credentials=True) + connect_ok = True + finally: + if not connect_ok: + await self.disconnect() + async def connect_once(self, raise_missing_credentials: bool) -> None: """Try to connect once.""" try: - if conf := await self._scan(): - await self._connect(conf, raise_missing_credentials) + await self._connect_once(raise_missing_credentials) except exceptions.AuthenticationError: self.config_entry.async_start_reauth(self.hass) await self.disconnect() @@ -244,9 +283,9 @@ class AppleTVManager(DeviceListener): pass except Exception: # pylint: disable=broad-except _LOGGER.exception("Failed to connect") - self.atv = None + await self.disconnect() - async def _connect_loop(self): + async def _connect_loop(self) -> None: """Connect loop background task function.""" _LOGGER.debug("Starting connect loop") @@ -255,7 +294,8 @@ class AppleTVManager(DeviceListener): while self.is_on and self.atv is None: await self.connect_once(raise_missing_credentials=False) if self.atv is not None: - break + # Calling self.connect_once may have set self.atv + break # type: ignore[unreachable] self._connection_attempts += 1 backoff = min( max( @@ -352,13 +392,17 @@ class AppleTVManager(DeviceListener): self._connection_was_lost = False @callback - def _async_setup_device_registry(self): + def _async_setup_device_registry(self) -> None: attrs = { ATTR_IDENTIFIERS: {(DOMAIN, self.config_entry.unique_id)}, ATTR_MANUFACTURER: "Apple", ATTR_NAME: self.config_entry.data[CONF_NAME], } - attrs[ATTR_SUGGESTED_AREA] = attrs[ATTR_NAME].removesuffix(f" {DEFAULT_NAME}") + attrs[ATTR_SUGGESTED_AREA] = ( + attrs[ATTR_NAME] + .removesuffix(f" {DEFAULT_NAME_TV}") + .removesuffix(f" {DEFAULT_NAME_HP}") + ) if self.atv: dev_info = self.atv.device_info @@ -379,18 +423,18 @@ class AppleTVManager(DeviceListener): ) @property - def is_connecting(self): + def is_connecting(self) -> bool: """Return true if connection is in progress.""" return self._task is not None - def _address_updated(self, address): + def _address_updated(self, address: str) -> None: """Update cached address in config entry.""" _LOGGER.debug("Changing address to %s", address) self.hass.config_entries.async_update_entry( self.config_entry, data={**self.config_entry.data, CONF_ADDRESS: address} ) - def _dispatch_send(self, signal, *args): + def _dispatch_send(self, signal: str, *args: Any) -> None: """Dispatch a signal to all entities managed by this manager.""" async_dispatcher_send( self.hass, f"{signal}_{self.config_entry.unique_id}", *args diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 11d408ee2ca..2bb4608dca1 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from collections import deque -from collections.abc import Mapping +from collections.abc import Awaitable, Callable, Mapping from ipaddress import ip_address import logging from random import randrange @@ -13,12 +13,13 @@ from pyatv import exceptions, pair, scan from pyatv.const import DeviceModel, PairingRequirement, Protocol from pyatv.convert import model_str, protocol_str from pyatv.helpers import get_unique_id +from pyatv.interface import BaseConfig, PairingHandler import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PIN -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -49,10 +50,12 @@ OPTIONS_FLOW = { } -async def device_scan(hass, identifier, loop): +async def device_scan( + hass: HomeAssistant, identifier: str | None, loop: asyncio.AbstractEventLoop +) -> tuple[BaseConfig | None, list[str] | None]: """Scan for a specific device using identifier as filter.""" - def _filter_device(dev): + def _filter_device(dev: BaseConfig) -> bool: if identifier is None: return True if identifier == str(dev.address): @@ -61,9 +64,12 @@ async def device_scan(hass, identifier, loop): return True return any(service.identifier == identifier for service in dev.services) - def _host_filter(): + def _host_filter() -> list[str] | None: + if identifier is None: + return None try: - return [ip_address(identifier)] + ip_address(identifier) + return [identifier] except ValueError: return None @@ -84,6 +90,13 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + scan_filter: str | None = None + atv: BaseConfig | None = None + atv_identifiers: list[str] | None = None + protocol: Protocol | None = None + pairing: PairingHandler | None = None + protocols_to_pair: deque[Protocol] | None = None + @staticmethod @callback def async_get_options_flow( @@ -92,18 +105,12 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get options flow for this handler.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) - def __init__(self): + def __init__(self) -> None: """Initialize a new AppleTVConfigFlow.""" - self.scan_filter = None - self.atv = None - self.atv_identifiers = None - self.protocol = None - self.pairing = None - self.credentials = {} # Protocol -> credentials - self.protocols_to_pair = deque() + self.credentials: dict[int, str | None] = {} # Protocol -> credentials @property - def device_identifier(self): + def device_identifier(self) -> str | None: """Return a identifier for the config entry. A device has multiple unique identifiers, but Home Assistant only supports one @@ -118,6 +125,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): existing config entry. If that's the case, the unique_id from that entry is re-used, otherwise the newly discovered identifier is used instead. """ + assert self.atv all_identifiers = set(self.atv.all_identifiers) if unique_id := self._entry_unique_id_from_identifers(all_identifiers): return unique_id @@ -143,7 +151,9 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["identifier"] = self.unique_id return await self.async_step_reconfigure() - async def async_step_reconfigure(self, user_input=None): + async def async_step_reconfigure( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Inform user that reconfiguration is about to start.""" if user_input is not None: return await self.async_find_device_wrapper( @@ -152,7 +162,9 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="reconfigure") - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} if user_input is not None: @@ -170,6 +182,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( self.device_identifier, raise_on_progress=False ) + assert self.atv self.context["all_identifiers"] = self.atv.all_identifiers return await self.async_step_confirm() @@ -275,8 +288,11 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): context["all_identifiers"].append(unique_id) raise AbortFlow("already_in_progress") - async def async_found_zeroconf_device(self, user_input=None): + async def async_found_zeroconf_device( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle device found after Zeroconf discovery.""" + assert self.atv self.context["all_identifiers"] = self.atv.all_identifiers # Also abort if an integration with this identifier already exists await self.async_set_unique_id(self.device_identifier) @@ -288,7 +304,11 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["identifier"] = self.unique_id return await self.async_step_confirm() - async def async_find_device_wrapper(self, next_func, allow_exist=False): + async def async_find_device_wrapper( + self, + next_func: Callable[[], Awaitable[FlowResult]], + allow_exist: bool = False, + ) -> FlowResult: """Find a specific device and call another function when done. This function will do error handling and bail out when an error @@ -306,7 +326,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await next_func() - async def async_find_device(self, allow_exist=False): + async def async_find_device(self, allow_exist: bool = False) -> None: """Scan for the selected device to discover services.""" self.atv, self.atv_identifiers = await device_scan( self.hass, self.scan_filter, self.hass.loop @@ -357,8 +377,11 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not allow_exist: raise DeviceAlreadyConfigured() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle user-confirmation of discovered node.""" + assert self.atv if user_input is not None: expected_identifier_count = len(self.context["all_identifiers"]) # If number of services found during device scan mismatch number of @@ -384,7 +407,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): }, ) - async def async_pair_next_protocol(self): + async def async_pair_next_protocol(self) -> FlowResult: """Start pairing process for the next available protocol.""" await self._async_cleanup() @@ -393,8 +416,16 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self._async_get_entry() self.protocol = self.protocols_to_pair.popleft() + assert self.atv service = self.atv.get_service(self.protocol) + if service is None: + _LOGGER.debug( + "%s does not support pairing (cannot find a corresponding service)", + self.protocol, + ) + return await self.async_pair_next_protocol() + # Service requires a password if service.requires_password: return await self.async_step_password() @@ -413,7 +444,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.debug("%s requires pairing", self.protocol) # Protocol specific arguments - pair_args = {} + pair_args: dict[str, Any] = {} if self.protocol in {Protocol.AirPlay, Protocol.Companion, Protocol.DMAP}: pair_args["name"] = "Home Assistant" if self.protocol == Protocol.DMAP: @@ -448,8 +479,11 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_pair_no_pin() - async def async_step_protocol_disabled(self, user_input=None): + async def async_step_protocol_disabled( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Inform user that a protocol is disabled and cannot be paired.""" + assert self.protocol if user_input is not None: return await self.async_pair_next_protocol() return self.async_show_form( @@ -457,9 +491,13 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={"protocol": protocol_str(self.protocol)}, ) - async def async_step_pair_with_pin(self, user_input=None): + async def async_step_pair_with_pin( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle pairing step where a PIN is required from the user.""" errors = {} + assert self.pairing + assert self.protocol if user_input is not None: try: self.pairing.pin(user_input[CONF_PIN]) @@ -480,8 +518,12 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={"protocol": protocol_str(self.protocol)}, ) - async def async_step_pair_no_pin(self, user_input=None): + async def async_step_pair_no_pin( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle step where user has to enter a PIN on the device.""" + assert self.pairing + assert self.protocol if user_input is not None: await self.pairing.finish() if self.pairing.has_paired: @@ -497,12 +539,15 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="pair_no_pin", description_placeholders={ "protocol": protocol_str(self.protocol), - "pin": pin, + "pin": str(pin), }, ) - async def async_step_service_problem(self, user_input=None): + async def async_step_service_problem( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Inform user that a service will not be added.""" + assert self.protocol if user_input is not None: return await self.async_pair_next_protocol() @@ -511,8 +556,11 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={"protocol": protocol_str(self.protocol)}, ) - async def async_step_password(self, user_input=None): + async def async_step_password( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Inform user that password is not supported.""" + assert self.protocol if user_input is not None: return await self.async_pair_next_protocol() @@ -521,18 +569,20 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={"protocol": protocol_str(self.protocol)}, ) - async def _async_cleanup(self): + async def _async_cleanup(self) -> None: """Clean up allocated resources.""" if self.pairing is not None: await self.pairing.close() self.pairing = None - async def _async_get_entry(self): + async def _async_get_entry(self) -> FlowResult: """Return config entry or update existing config entry.""" # Abort if no protocols were paired if not self.credentials: return self.async_abort(reason="setup_failed") + assert self.atv + data = { CONF_NAME: self.atv.name, CONF_CREDENTIALS: self.credentials, diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 789415a1717..a7b5957ecff 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -16,7 +16,15 @@ from pyatv.const import ( ShuffleState, ) from pyatv.helpers import is_streamable -from pyatv.interface import AppleTV, Playing +from pyatv.interface import ( + AppleTV, + AudioListener, + OutputDevice, + Playing, + PowerListener, + PushListener, + PushUpdater, +) from homeassistant.components import media_source from homeassistant.components.media_player import ( @@ -101,7 +109,9 @@ async def async_setup_entry( async_add_entities([AppleTvMediaPlayer(name, config_entry.unique_id, manager)]) -class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): +class AppleTvMediaPlayer( + AppleTVEntity, MediaPlayerEntity, PowerListener, AudioListener, PushListener +): """Representation of an Apple TV media player.""" _attr_supported_features = SUPPORT_APPLE_TV @@ -116,9 +126,9 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): def async_device_connected(self, atv: AppleTV) -> None: """Handle when connection is made to device.""" # NB: Do not use _is_feature_available here as it only works when playing - if self.atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates): - self.atv.push_updater.listener = self - self.atv.push_updater.start() + if atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates): + atv.push_updater.listener = self + atv.push_updater.start() self._attr_supported_features = SUPPORT_BASE @@ -126,7 +136,7 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): # "Unsupported" are considered here as the state of such a feature can never # change after a connection has been established, i.e. an unsupported feature # can never change to be supported. - all_features = self.atv.features.all_features() + all_features = atv.features.all_features() for feature_name, support_flag in SUPPORT_FEATURE_MAPPING.items(): feature_info = all_features.get(feature_name) if feature_info and feature_info.state != FeatureState.Unsupported: @@ -136,16 +146,18 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): # metadata update arrives (sometime very soon after this callback returns) # Listen to power updates - self.atv.power.listener = self + atv.power.listener = self # Listen to volume updates - self.atv.audio.listener = self + atv.audio.listener = self - if self.atv.features.in_state(FeatureState.Available, FeatureName.AppList): + if atv.features.in_state(FeatureState.Available, FeatureName.AppList): self.hass.create_task(self._update_app_list()) async def _update_app_list(self) -> None: _LOGGER.debug("Updating app list") + if not self.atv: + return try: apps = await self.atv.apps.app_list() except exceptions.NotSupportedError: @@ -189,33 +201,56 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): return None @callback - def playstatus_update(self, _, playing: Playing) -> None: - """Print what is currently playing when it changes.""" - self._playing = playing + def playstatus_update(self, updater: PushUpdater, playstatus: Playing) -> None: + """Print what is currently playing when it changes. + + This is a callback function from pyatv.interface.PushListener. + """ + self._playing = playstatus self.async_write_ha_state() @callback - def playstatus_error(self, _, exception: Exception) -> None: - """Inform about an error and restart push updates.""" + def playstatus_error(self, updater: PushUpdater, exception: Exception) -> None: + """Inform about an error and restart push updates. + + This is a callback function from pyatv.interface.PushListener. + """ _LOGGER.warning("A %s error occurred: %s", exception.__class__, exception) self._playing = None self.async_write_ha_state() @callback def powerstate_update(self, old_state: PowerState, new_state: PowerState) -> None: - """Update power state when it changes.""" + """Update power state when it changes. + + This is a callback function from pyatv.interface.PowerListener. + """ self.async_write_ha_state() @callback def volume_update(self, old_level: float, new_level: float) -> None: - """Update volume when it changes.""" + """Update volume when it changes. + + This is a callback function from pyatv.interface.AudioListener. + """ self.async_write_ha_state() + @callback + def outputdevices_update( + self, old_devices: list[OutputDevice], new_devices: list[OutputDevice] + ) -> None: + """Output devices were updated. + + This is a callback function from pyatv.interface.AudioListener. + """ + @property def app_id(self) -> str | None: """ID of the current running app.""" - if self._is_feature_available(FeatureName.App) and ( - app := self.atv.metadata.app + if ( + self.atv + and self._is_feature_available(FeatureName.App) + and (app := self.atv.metadata.app) is not None ): return app.identifier return None @@ -223,8 +258,10 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): @property def app_name(self) -> str | None: """Name of the current running app.""" - if self._is_feature_available(FeatureName.App) and ( - app := self.atv.metadata.app + if ( + self.atv + and self._is_feature_available(FeatureName.App) + and (app := self.atv.metadata.app) is not None ): return app.name return None @@ -255,7 +292,7 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): @property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" - if self._is_feature_available(FeatureName.Volume): + if self.atv and self._is_feature_available(FeatureName.Volume): return self.atv.audio.volume / 100.0 # from percent return None @@ -286,6 +323,8 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): """Send the play_media command to the media player.""" # If input (file) has a file format supported by pyatv, then stream it with # RAOP. Otherwise try to play it with regular AirPlay. + if not self.atv: + return if media_type in {MediaType.APP, MediaType.URL}: await self.atv.apps.launch_app(media_id) return @@ -313,7 +352,8 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): """Hash value for media image.""" state = self.state if ( - self._playing + self.atv + and self._playing and self._is_feature_available(FeatureName.Artwork) and state not in {None, MediaPlayerState.OFF, MediaPlayerState.IDLE} ): @@ -323,7 +363,11 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): async def async_get_media_image(self) -> tuple[bytes | None, str | None]: """Fetch media image of current playing image.""" state = self.state - if self._playing and state not in {MediaPlayerState.OFF, MediaPlayerState.IDLE}: + if ( + self.atv + and self._playing + and state not in {MediaPlayerState.OFF, MediaPlayerState.IDLE} + ): artwork = await self.atv.metadata.artwork() if artwork: return artwork.bytes, artwork.mimetype @@ -439,20 +483,24 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): async def async_turn_on(self) -> None: """Turn the media player on.""" - if self._is_feature_available(FeatureName.TurnOn): + if self.atv and self._is_feature_available(FeatureName.TurnOn): await self.atv.power.turn_on() async def async_turn_off(self) -> None: """Turn the media player off.""" - if (self._is_feature_available(FeatureName.TurnOff)) and ( - not self._is_feature_available(FeatureName.PowerState) - or self.atv.power.power_state == PowerState.On + if ( + self.atv + and (self._is_feature_available(FeatureName.TurnOff)) + and ( + not self._is_feature_available(FeatureName.PowerState) + or self.atv.power.power_state == PowerState.On + ) ): await self.atv.power.turn_off() async def async_media_play_pause(self) -> None: """Pause media on media player.""" - if self._playing: + if self.atv and self._playing: await self.atv.remote_control.play_pause() async def async_media_play(self) -> None: @@ -519,5 +567,6 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): async def async_select_source(self, source: str) -> None: """Select input source.""" - if app_id := self._app_list.get(source): - await self.atv.apps.launch_app(app_id) + if self.atv: + if app_id := self._app_list.get(source): + await self.atv.apps.launch_app(app_id) diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index 24d2ef68ed4..7baa6321f21 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AppleTVEntity +from . import AppleTVEntity, AppleTVManager from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -38,8 +38,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Load Apple TV remote based on a config entry.""" - name = config_entry.data[CONF_NAME] - manager = hass.data[DOMAIN][config_entry.unique_id] + name: str = config_entry.data[CONF_NAME] + # apple_tv config entries always have a unique id + assert config_entry.unique_id is not None + manager: AppleTVManager = hass.data[DOMAIN][config_entry.unique_id] async_add_entities([AppleTVRemote(name, config_entry.unique_id, manager)]) @@ -47,7 +49,7 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): """Device that sends commands to an Apple TV.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self.atv is not None @@ -64,13 +66,13 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): num_repeats = kwargs[ATTR_NUM_REPEATS] delay = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) - if not self.is_on: + if not self.atv: _LOGGER.error("Unable to send commands, not connected to %s", self.name) return for _ in range(num_repeats): for single_command in command: - attr_value = None + attr_value: Any = None if attributes := COMMAND_TO_ATTRIBUTE.get(single_command): attr_value = self.atv for attr_name in attributes: @@ -81,5 +83,5 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): raise ValueError("Command not found. Exiting sequence") _LOGGER.info("Sending command %s", single_command) - await attr_value() # type: ignore[operator] + await attr_value() await asyncio.sleep(delay) diff --git a/homeassistant/components/aprilaire/__init__.py b/homeassistant/components/aprilaire/__init__.py new file mode 100644 index 00000000000..b5aeea2a55c --- /dev/null +++ b/homeassistant/components/aprilaire/__init__.py @@ -0,0 +1,69 @@ +"""The Aprilaire integration.""" + +from __future__ import annotations + +import logging + +from pyaprilaire.const import Attribute + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN +from .coordinator import AprilaireCoordinator + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry for Aprilaire.""" + + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + + coordinator = AprilaireCoordinator(hass, entry.unique_id, host, port) + await coordinator.start_listen() + + hass.data.setdefault(DOMAIN, {})[entry.unique_id] = coordinator + + async def ready_callback(ready: bool): + if ready: + mac_address = format_mac(coordinator.data[Attribute.MAC_ADDRESS]) + + if mac_address != entry.unique_id: + raise ConfigEntryAuthFailed("Invalid MAC address") + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def _async_close(_: Event) -> None: + coordinator.stop_listen() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close) + ) + else: + _LOGGER.error("Failed to wait for ready") + + coordinator.stop_listen() + + raise ConfigEntryNotReady() + + await coordinator.wait_for_ready(ready_callback) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + coordinator: AprilaireCoordinator = hass.data[DOMAIN].pop(entry.unique_id) + coordinator.stop_listen() + + return unload_ok diff --git a/homeassistant/components/aprilaire/climate.py b/homeassistant/components/aprilaire/climate.py new file mode 100644 index 00000000000..96c1e1ac981 --- /dev/null +++ b/homeassistant/components/aprilaire/climate.py @@ -0,0 +1,302 @@ +"""The Aprilaire climate component.""" + +from __future__ import annotations + +from typing import Any + +from pyaprilaire.const import Attribute + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_ON, + PRESET_AWAY, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PRECISION_HALVES, PRECISION_WHOLE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + DOMAIN, + FAN_CIRCULATE, + PRESET_PERMANENT_HOLD, + PRESET_TEMPORARY_HOLD, + PRESET_VACATION, +) +from .coordinator import AprilaireCoordinator +from .entity import BaseAprilaireEntity + +HVAC_MODE_MAP = { + 1: HVACMode.OFF, + 2: HVACMode.HEAT, + 3: HVACMode.COOL, + 4: HVACMode.HEAT, + 5: HVACMode.AUTO, +} + +HVAC_MODES_MAP = { + 1: [HVACMode.OFF, HVACMode.HEAT], + 2: [HVACMode.OFF, HVACMode.COOL], + 3: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL], + 4: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL], + 5: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO], + 6: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO], +} + +PRESET_MODE_MAP = { + 1: PRESET_TEMPORARY_HOLD, + 2: PRESET_PERMANENT_HOLD, + 3: PRESET_AWAY, + 4: PRESET_VACATION, +} + +FAN_MODE_MAP = { + 1: FAN_ON, + 2: FAN_AUTO, + 3: FAN_CIRCULATE, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add climates for passed config_entry in HA.""" + + coordinator: AprilaireCoordinator = hass.data[DOMAIN][config_entry.unique_id] + + async_add_entities([AprilaireClimate(coordinator, config_entry.unique_id)]) + + +class AprilaireClimate(BaseAprilaireEntity, ClimateEntity): + """Climate entity for Aprilaire.""" + + _attr_fan_modes = [FAN_AUTO, FAN_ON, FAN_CIRCULATE] + _attr_min_humidity = 10 + _attr_max_humidity = 50 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = "thermostat" + + @property + def precision(self) -> float: + """Get the precision based on the unit.""" + return ( + PRECISION_HALVES + if self.hass.config.units.temperature_unit == UnitOfTemperature.CELSIUS + else PRECISION_WHOLE + ) + + @property + def supported_features(self) -> ClimateEntityFeature: + """Get supported features.""" + features = 0 + + if self.coordinator.data.get(Attribute.MODE) == 5: + features = features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + else: + features = features | ClimateEntityFeature.TARGET_TEMPERATURE + + if self.coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) == 2: + features = features | ClimateEntityFeature.TARGET_HUMIDITY + + features = features | ClimateEntityFeature.PRESET_MODE + + features = features | ClimateEntityFeature.FAN_MODE + + return features + + @property + def current_humidity(self) -> int | None: + """Get current humidity.""" + return self.coordinator.data.get( + Attribute.INDOOR_HUMIDITY_CONTROLLING_SENSOR_VALUE + ) + + @property + def target_humidity(self) -> int | None: + """Get current target humidity.""" + return self.coordinator.data.get(Attribute.HUMIDIFICATION_SETPOINT) + + @property + def hvac_mode(self) -> HVACMode | None: + """Get HVAC mode.""" + + if mode := self.coordinator.data.get(Attribute.MODE): + if hvac_mode := HVAC_MODE_MAP.get(mode): + return hvac_mode + + return None + + @property + def hvac_modes(self) -> list[HVACMode]: + """Get supported HVAC modes.""" + + if modes := self.coordinator.data.get(Attribute.THERMOSTAT_MODES): + if thermostat_modes := HVAC_MODES_MAP.get(modes): + return thermostat_modes + + return [] + + @property + def hvac_action(self) -> HVACAction | None: + """Get the current HVAC action.""" + + if self.coordinator.data.get(Attribute.HEATING_EQUIPMENT_STATUS, 0): + return HVACAction.HEATING + + if self.coordinator.data.get(Attribute.COOLING_EQUIPMENT_STATUS, 0): + return HVACAction.COOLING + + return HVACAction.IDLE + + @property + def current_temperature(self) -> float | None: + """Get current temperature.""" + return self.coordinator.data.get( + Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_VALUE + ) + + @property + def target_temperature(self) -> float | None: + """Get the target temperature.""" + + hvac_mode = self.hvac_mode + + if hvac_mode == HVACMode.COOL: + return self.target_temperature_high + if hvac_mode == HVACMode.HEAT: + return self.target_temperature_low + + return None + + @property + def target_temperature_step(self) -> float | None: + """Get the step for the target temperature based on the unit.""" + return ( + 0.5 + if self.hass.config.units.temperature_unit == UnitOfTemperature.CELSIUS + else 1 + ) + + @property + def target_temperature_high(self) -> float | None: + """Get cool setpoint.""" + return self.coordinator.data.get(Attribute.COOL_SETPOINT) + + @property + def target_temperature_low(self) -> float | None: + """Get heat setpoint.""" + return self.coordinator.data.get(Attribute.HEAT_SETPOINT) + + @property + def preset_mode(self) -> str | None: + """Get the current preset mode.""" + if hold := self.coordinator.data.get(Attribute.HOLD): + if preset_mode := PRESET_MODE_MAP.get(hold): + return preset_mode + + return PRESET_NONE + + @property + def preset_modes(self) -> list[str] | None: + """Get the supported preset modes.""" + presets = [PRESET_NONE, PRESET_VACATION] + + if self.coordinator.data.get(Attribute.AWAY_AVAILABLE) == 1: + presets.append(PRESET_AWAY) + + hold = self.coordinator.data.get(Attribute.HOLD, 0) + + if hold == 1: + presets.append(PRESET_TEMPORARY_HOLD) + elif hold == 2: + presets.append(PRESET_PERMANENT_HOLD) + + return presets + + @property + def fan_mode(self) -> str | None: + """Get fan mode.""" + + if mode := self.coordinator.data.get(Attribute.FAN_MODE): + if fan_mode := FAN_MODE_MAP.get(mode): + return fan_mode + + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + + cool_setpoint = 0 + heat_setpoint = 0 + + if temperature := kwargs.get("temperature"): + if self.coordinator.data.get(Attribute.MODE) == 3: + cool_setpoint = temperature + else: + heat_setpoint = temperature + else: + if target_temp_low := kwargs.get("target_temp_low"): + heat_setpoint = target_temp_low + if target_temp_high := kwargs.get("target_temp_high"): + cool_setpoint = target_temp_high + + if cool_setpoint == 0 and heat_setpoint == 0: + return + + await self.coordinator.client.update_setpoint(cool_setpoint, heat_setpoint) + + await self.coordinator.client.read_control() + + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidification setpoint.""" + + await self.coordinator.client.set_humidification_setpoint(humidity) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the fan mode.""" + + try: + fan_mode_value_index = list(FAN_MODE_MAP.values()).index(fan_mode) + except ValueError as exc: + raise ValueError(f"Unsupported fan mode {fan_mode}") from exc + + fan_mode_value = list(FAN_MODE_MAP.keys())[fan_mode_value_index] + + await self.coordinator.client.update_fan_mode(fan_mode_value) + + await self.coordinator.client.read_control() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode.""" + + try: + mode_value_index = list(HVAC_MODE_MAP.values()).index(hvac_mode) + except ValueError as exc: + raise ValueError(f"Unsupported HVAC mode {hvac_mode}") from exc + + mode_value = list(HVAC_MODE_MAP.keys())[mode_value_index] + + await self.coordinator.client.update_mode(mode_value) + + await self.coordinator.client.read_control() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode.""" + + if preset_mode == PRESET_AWAY: + await self.coordinator.client.set_hold(3) + elif preset_mode == PRESET_VACATION: + await self.coordinator.client.set_hold(4) + elif preset_mode == PRESET_NONE: + await self.coordinator.client.set_hold(0) + else: + raise ValueError(f"Unsupported preset mode {preset_mode}") + + await self.coordinator.client.read_scheduling() diff --git a/homeassistant/components/aprilaire/config_flow.py b/homeassistant/components/aprilaire/config_flow.py new file mode 100644 index 00000000000..0e38b385450 --- /dev/null +++ b/homeassistant/components/aprilaire/config_flow.py @@ -0,0 +1,72 @@ +"""Config flow for the Aprilaire integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pyaprilaire.const import Attribute +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN +from .coordinator import AprilaireCoordinator + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=7000): cv.port, + } +) + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Aprilaire.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + coordinator = AprilaireCoordinator( + self.hass, None, user_input[CONF_HOST], user_input[CONF_PORT] + ) + await coordinator.start_listen() + + async def ready_callback(ready: bool): + if not ready: + _LOGGER.error("Failed to wait for ready") + + try: + ready = await coordinator.wait_for_ready(ready_callback) + finally: + coordinator.stop_listen() + + mac_address = coordinator.data.get(Attribute.MAC_ADDRESS) + + if ready and mac_address is not None: + await self.async_set_unique_id(format_mac(mac_address)) + + self._abort_if_unique_id_configured() + + return self.async_create_entry(title="Aprilaire", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "connection_failed"}, + ) diff --git a/homeassistant/components/aprilaire/const.py b/homeassistant/components/aprilaire/const.py new file mode 100644 index 00000000000..baf92294266 --- /dev/null +++ b/homeassistant/components/aprilaire/const.py @@ -0,0 +1,11 @@ +"""Constants for the Aprilaire integration.""" + +from __future__ import annotations + +DOMAIN = "aprilaire" + +FAN_CIRCULATE = "Circulate" + +PRESET_TEMPORARY_HOLD = "Temporary" +PRESET_PERMANENT_HOLD = "Permanent" +PRESET_VACATION = "Vacation" diff --git a/homeassistant/components/aprilaire/coordinator.py b/homeassistant/components/aprilaire/coordinator.py new file mode 100644 index 00000000000..7a67dee46a8 --- /dev/null +++ b/homeassistant/components/aprilaire/coordinator.py @@ -0,0 +1,209 @@ +"""The Aprilaire coordinator.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +import logging +from typing import Any, Optional + +import pyaprilaire.client +from pyaprilaire.const import MODELS, Attribute, FunctionalDomain + +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol + +from .const import DOMAIN + +RECONNECT_INTERVAL = 60 * 60 +RETRY_CONNECTION_INTERVAL = 10 +WAIT_TIMEOUT = 30 + +_LOGGER = logging.getLogger(__name__) + + +class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol): + """Coordinator for interacting with the thermostat.""" + + def __init__( + self, + hass: HomeAssistant, + unique_id: str | None, + host: str, + port: int, + ) -> None: + """Initialize the coordinator.""" + + self.hass = hass + self.unique_id = unique_id + self.data: dict[str, Any] = {} + + self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} + + self.client = pyaprilaire.client.AprilaireClient( + host, + port, + self.async_set_updated_data, + _LOGGER, + RECONNECT_INTERVAL, + RETRY_CONNECTION_INTERVAL, + ) + + if hasattr(self.client, "data") and self.client.data: + self.data = self.client.data + + @callback + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: + """Listen for data updates.""" + + @callback + def remove_listener() -> None: + """Remove update listener.""" + self._listeners.pop(remove_listener) + + self._listeners[remove_listener] = (update_callback, context) + + return remove_listener + + @callback + def async_update_listeners(self) -> None: + """Update all registered listeners.""" + for update_callback, _ in list(self._listeners.values()): + update_callback() + + def async_set_updated_data(self, data: Any) -> None: + """Manually update data, notify listeners and reset refresh interval.""" + + old_device_info = self.create_device_info(self.data) + + self.data = self.data | data + + self.async_update_listeners() + + new_device_info = self.create_device_info(data) + + if ( + old_device_info is not None + and new_device_info is not None + and old_device_info != new_device_info + ): + device_registry = dr.async_get(self.hass) + + device = device_registry.async_get_device(old_device_info["identifiers"]) + + if device is not None: + new_device_info.pop("identifiers", None) + new_device_info.pop("connections", None) + + device_registry.async_update_device( + device_id=device.id, + **new_device_info, # type: ignore[misc] + ) + + async def start_listen(self): + """Start listening for data.""" + await self.client.start_listen() + + def stop_listen(self): + """Stop listening for data.""" + self.client.stop_listen() + + async def wait_for_ready( + self, ready_callback: Callable[[bool], Awaitable[bool]] + ) -> bool: + """Wait for the client to be ready.""" + + if not self.data or Attribute.MAC_ADDRESS not in self.data: + data = await self.client.wait_for_response( + FunctionalDomain.IDENTIFICATION, 2, WAIT_TIMEOUT + ) + + if not data or Attribute.MAC_ADDRESS not in data: + _LOGGER.error("Missing MAC address") + await ready_callback(False) + + return False + + if not self.data or Attribute.NAME not in self.data: + await self.client.wait_for_response( + FunctionalDomain.IDENTIFICATION, 4, WAIT_TIMEOUT + ) + + if not self.data or Attribute.THERMOSTAT_MODES not in self.data: + await self.client.wait_for_response( + FunctionalDomain.CONTROL, 7, WAIT_TIMEOUT + ) + + if ( + not self.data + or Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_STATUS not in self.data + ): + await self.client.wait_for_response( + FunctionalDomain.SENSORS, 2, WAIT_TIMEOUT + ) + + await ready_callback(True) + + return True + + @property + def device_name(self) -> str: + """Get the name of the thermostat.""" + + return self.create_device_name(self.data) + + def create_device_name(self, data: Optional[dict[str, Any]]) -> str: + """Create the name of the thermostat.""" + + name = data.get(Attribute.NAME) if data else None + + return name if name else "Aprilaire" + + def get_hw_version(self, data: dict[str, Any]) -> str: + """Get the hardware version.""" + + if hardware_revision := data.get(Attribute.HARDWARE_REVISION): + return ( + f"Rev. {chr(hardware_revision)}" + if hardware_revision > ord("A") + else str(hardware_revision) + ) + + return "Unknown" + + @property + def device_info(self) -> DeviceInfo | None: + """Get the device info for the thermostat.""" + return self.create_device_info(self.data) + + def create_device_info(self, data: dict[str, Any]) -> DeviceInfo | None: + """Create the device info for the thermostat.""" + + if data is None or Attribute.MAC_ADDRESS not in data or self.unique_id is None: + return None + + device_info = DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + name=self.create_device_name(data), + manufacturer="Aprilaire", + ) + + model_number = data.get(Attribute.MODEL_NUMBER) + if model_number is not None: + device_info["model"] = MODELS.get(model_number, f"Unknown ({model_number})") + + device_info["hw_version"] = self.get_hw_version(data) + + firmware_major_revision = data.get(Attribute.FIRMWARE_MAJOR_REVISION) + firmware_minor_revision = data.get(Attribute.FIRMWARE_MINOR_REVISION) + if firmware_major_revision is not None: + device_info["sw_version"] = ( + str(firmware_major_revision) + if firmware_minor_revision is None + else f"{firmware_major_revision}.{firmware_minor_revision:02}" + ) + + return device_info diff --git a/homeassistant/components/aprilaire/entity.py b/homeassistant/components/aprilaire/entity.py new file mode 100644 index 00000000000..e2f2bf109ef --- /dev/null +++ b/homeassistant/components/aprilaire/entity.py @@ -0,0 +1,46 @@ +"""Base functionality for Aprilaire entities.""" + +from __future__ import annotations + +import logging + +from pyaprilaire.const import Attribute + +from homeassistant.helpers.update_coordinator import BaseCoordinatorEntity + +from .coordinator import AprilaireCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class BaseAprilaireEntity(BaseCoordinatorEntity[AprilaireCoordinator]): + """Base for Aprilaire entities.""" + + _attr_available = False + _attr_has_entity_name = True + + def __init__( + self, coordinator: AprilaireCoordinator, unique_id: str | None + ) -> None: + """Initialize the entity.""" + + super().__init__(coordinator) + + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{unique_id}_{self.translation_key}" + + self._update_available() + + def _update_available(self): + """Update the entity availability.""" + + connected: bool = self.coordinator.data.get( + Attribute.CONNECTED, None + ) or self.coordinator.data.get(Attribute.RECONNECTING, None) + + stopped: bool = self.coordinator.data.get(Attribute.STOPPED, None) + + self._attr_available = connected and not stopped + + async def async_update(self) -> None: + """Implement abstract base method.""" diff --git a/homeassistant/components/aprilaire/manifest.json b/homeassistant/components/aprilaire/manifest.json new file mode 100644 index 00000000000..43ba4417638 --- /dev/null +++ b/homeassistant/components/aprilaire/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "aprilaire", + "name": "Aprilaire", + "codeowners": ["@chamberlain2007"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/aprilaire", + "integration_type": "device", + "iot_class": "local_push", + "loggers": ["pyaprilaire"], + "requirements": ["pyaprilaire==0.7.0"] +} diff --git a/homeassistant/components/aprilaire/strings.json b/homeassistant/components/aprilaire/strings.json new file mode 100644 index 00000000000..e996691f21f --- /dev/null +++ b/homeassistant/components/aprilaire/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "port": "Usually 7000 or 8000" + } + } + }, + "error": { + "connection_failed": "Connection failed. Please check that the host and port is correct." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "climate": { + "thermostat": { + "name": "Thermostat" + } + } + } +} diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py index d9ab17dba86..a45dd89e180 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -83,7 +83,7 @@ async def _run_client(hass: HomeAssistant, client: Client, interval: float) -> N except ConnectionFailed: await asyncio.sleep(interval) - except asyncio.TimeoutError: + except TimeoutError: continue except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception, aborting arcam client") diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index bfba8563875..6d60426e730 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -241,7 +241,7 @@ async def websocket_run( # Task contains a timeout async with asyncio.timeout(timeout): await run_task - except asyncio.TimeoutError: + except TimeoutError: pipeline_input.run.process_event( PipelineEvent( PipelineEventType.ERROR, @@ -487,7 +487,7 @@ async def websocket_device_capture( ) try: - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): async with asyncio.timeout(timeout_seconds): while True: # Send audio chunks encoded as base64 diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index 53a0b5d06b5..cc06c225d22 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -11,6 +11,7 @@ from typing import Any, TypeVar, cast from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy from aiohttp import ClientSession from pyasuswrt import AsusWrtError, AsusWrtHttp +from pyasuswrt.exceptions import AsusWrtNotAvailableInfoError from homeassistant.const import ( CONF_HOST, @@ -354,13 +355,14 @@ class AsusWrtHttpBridge(AsusWrtBridge): async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]: """Return a dictionary of available sensors for this bridge.""" sensors_temperatures = await self._get_available_temperature_sensors() + sensors_loadavg = await self._get_loadavg_sensors_availability() sensors_types = { SENSORS_TYPE_BYTES: { KEY_SENSORS: SENSORS_BYTES, KEY_METHOD: self._get_bytes, }, SENSORS_TYPE_LOAD_AVG: { - KEY_SENSORS: SENSORS_LOAD_AVG, + KEY_SENSORS: sensors_loadavg, KEY_METHOD: self._get_load_avg, }, SENSORS_TYPE_RATES: { @@ -393,6 +395,16 @@ class AsusWrtHttpBridge(AsusWrtBridge): return [] return available_sensors + async def _get_loadavg_sensors_availability(self) -> list[str]: + """Check if load avg is available on the router.""" + try: + await self._api.async_get_loadavg() + except AsusWrtNotAvailableInfoError: + return [] + except AsusWrtError: + pass + return SENSORS_LOAD_AVG + @handle_errors_and_zip(AsusWrtError, SENSORS_BYTES) async def _get_bytes(self) -> Any: """Fetch byte information from the router.""" diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 624121b8828..fe16819bf9c 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -59,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await async_setup_august(hass, entry, august_gateway) except (RequireValidation, InvalidAuth) as err: raise ConfigEntryAuthFailed from err - except asyncio.TimeoutError as err: + except TimeoutError as err: raise ConfigEntryNotReady("Timed out connecting to august api") from err except (AugustApiAIOHTTPError, ClientResponseError, CannotConnect) as err: raise ConfigEntryNotReady from err @@ -233,7 +233,7 @@ class AugustData(AugustSubscriberMixin): return_exceptions=True, ): if isinstance(result, Exception) and not isinstance( - result, (asyncio.TimeoutError, ClientResponseError, CannotConnect) + result, (TimeoutError, ClientResponseError, CannotConnect) ): _LOGGER.warning( "Unexpected exception during initial sync: %s", @@ -249,10 +249,11 @@ class AugustData(AugustSubscriberMixin): device = self.get_device_detail(device_id) activities = activities_from_pubnub_message(device, date_time, message) activity_stream = self.activity_stream - if activities: - activity_stream.async_process_newer_device_activities(activities) + if activities and activity_stream.async_process_newer_device_activities( + activities + ): self.async_signal_device_id_update(device.device_id) - activity_stream.async_schedule_house_id_refresh(device.house_id) + activity_stream.async_schedule_house_id_refresh(device.house_id) @callback def async_stop(self) -> None: @@ -292,7 +293,7 @@ class AugustData(AugustSubscriberMixin): for device_id in device_ids_list: try: await self._async_refresh_device_detail_by_id(device_id) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning( "Timed out calling august api during refresh of device: %s", device_id, diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index d0f2a27522d..a1a7adb4ede 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.10.0", "yalexs-ble==2.4.0"] + "requirements": ["yalexs==1.11.2", "yalexs-ble==2.4.1"] } diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index e2614af6a3e..cf7f38fa32a 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -1,7 +1,6 @@ """Helpers to resolve client ID/secret.""" from __future__ import annotations -import asyncio from html.parser import HTMLParser from ipaddress import ip_address import logging @@ -102,7 +101,7 @@ async def fetch_redirect_uris(hass: HomeAssistant, url: str) -> list[str]: if chunks == 10: break - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Timeout while looking up redirect_uri %s", url) except aiohttp.client_exceptions.ClientSSLError: _LOGGER.error("SSL error while looking up redirect_uri %s", url) diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index 65a425fa5c4..ae5ffcbdb7a 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -50,7 +50,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if config_entry.version != 3: # Home Assistant 2023.2 - config_entry.version = 3 + hass.config_entries.async_update_entry(config_entry, version=3) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 67ef61af8ac..4a54843edfc 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -1,6 +1,5 @@ """Axis network device abstraction.""" -import asyncio from asyncio import timeout from types import MappingProxyType from typing import Any @@ -270,7 +269,7 @@ async def get_axis_device( ) raise AuthenticationRequired from err - except (asyncio.TimeoutError, axis.RequestError) as err: + except (TimeoutError, axis.RequestError) as err: LOGGER.error("Error connecting to the Axis device at %s", config[CONF_HOST]) raise CannotConnect from err diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index fe0d494a650..60739c39c2b 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -4,11 +4,12 @@ from __future__ import annotations import asyncio from dataclasses import asdict, dataclass import hashlib +import io import json from pathlib import Path import tarfile from tarfile import TarError -from tempfile import TemporaryDirectory +import time from typing import Any, Protocol, cast from securetar import SecureTarFile, atomic_contents_add @@ -17,7 +18,7 @@ from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import integration_platform -from homeassistant.helpers.json import save_json +from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads_object @@ -81,6 +82,38 @@ class BackupManager: return self.platforms[integration_domain] = platform + async def pre_backup_actions(self) -> None: + """Perform pre backup actions.""" + if not self.loaded_platforms: + await self.load_platforms() + + pre_backup_results = await asyncio.gather( + *( + platform.async_pre_backup(self.hass) + for platform in self.platforms.values() + ), + return_exceptions=True, + ) + for result in pre_backup_results: + if isinstance(result, Exception): + raise result + + async def post_backup_actions(self) -> None: + """Perform post backup actions.""" + if not self.loaded_platforms: + await self.load_platforms() + + post_backup_results = await asyncio.gather( + *( + platform.async_post_backup(self.hass) + for platform in self.platforms.values() + ), + return_exceptions=True, + ) + for result in post_backup_results: + if isinstance(result, Exception): + raise result + async def load_backups(self) -> None: """Load data of stored backup files.""" backups = await self.hass.async_add_executor_job(self._read_backups) @@ -159,22 +192,9 @@ class BackupManager: if self.backing_up: raise HomeAssistantError("Backup already in progress") - if not self.loaded_platforms: - await self.load_platforms() - try: self.backing_up = True - pre_backup_results = await asyncio.gather( - *( - platform.async_pre_backup(self.hass) - for platform in self.platforms.values() - ), - return_exceptions=True, - ) - for result in pre_backup_results: - if isinstance(result, Exception): - raise result - + await self.pre_backup_actions() backup_name = f"Core {HAVERSION}" date_str = dt_util.now().isoformat() slug = _generate_slug(date_str, backup_name) @@ -207,16 +227,7 @@ class BackupManager: return backup finally: self.backing_up = False - post_backup_results = await asyncio.gather( - *( - platform.async_post_backup(self.hass) - for platform in self.platforms.values() - ), - return_exceptions=True, - ) - for result in post_backup_results: - if isinstance(result, Exception): - raise result + await self.post_backup_actions() def _mkdir_and_generate_backup_contents( self, @@ -228,18 +239,18 @@ class BackupManager: LOGGER.debug("Creating backup directory") self.backup_dir.mkdir() - with TemporaryDirectory() as tmp_dir, SecureTarFile( + outer_secure_tarfile = SecureTarFile( tar_file_path, "w", gzip=False, bufsize=BUF_SIZE - ) as tar_file: - tmp_dir_path = Path(tmp_dir) - save_json( - tmp_dir_path.joinpath("./backup.json").as_posix(), - backup_data, - ) - with SecureTarFile( - tmp_dir_path.joinpath("./homeassistant.tar.gz").as_posix(), - "w", - bufsize=BUF_SIZE, + ) + with outer_secure_tarfile as outer_secure_tarfile_tarfile: + raw_bytes = json_bytes(backup_data) + fileobj = io.BytesIO(raw_bytes) + tar_info = tarfile.TarInfo(name="./backup.json") + tar_info.size = len(raw_bytes) + tar_info.mtime = int(time.time()) + outer_secure_tarfile_tarfile.addfile(tar_info, fileobj=fileobj) + with outer_secure_tarfile.create_inner_tar( + "./homeassistant.tar.gz", gzip=True ) as core_tar: atomic_contents_add( tar_file=core_tar, @@ -247,7 +258,7 @@ class BackupManager: excludes=EXCLUDE_FROM_BACKUP, arcname="data", ) - tar_file.add(tmp_dir_path, arcname=".") + return tar_file_path.stat().st_size diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index fb7e9eff780..afa4483e95a 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["securetar==2023.3.0"] + "requirements": ["securetar==2024.2.0"] } diff --git a/homeassistant/components/baf/__init__.py b/homeassistant/components/baf/__init__.py index fcc648f4001..e685ec6dc8c 100644 --- a/homeassistant/components/baf/__init__.py +++ b/homeassistant/components/baf/__init__.py @@ -1,7 +1,6 @@ """The Big Ass Fans integration.""" from __future__ import annotations -import asyncio from asyncio import timeout from aiobafi6 import Device, Service @@ -42,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady( f"Unexpected device found at {ip_address}; expected {entry.unique_id}, found {device.dns_sd_uuid}" ) from ex - except asyncio.TimeoutError as ex: + except TimeoutError as ex: run_future.cancel() raise ConfigEntryNotReady(f"Timed out connecting to {ip_address}") from ex diff --git a/homeassistant/components/baf/config_flow.py b/homeassistant/components/baf/config_flow.py index 9edb23abcf8..0aaf2189c28 100644 --- a/homeassistant/components/baf/config_flow.py +++ b/homeassistant/components/baf/config_flow.py @@ -1,7 +1,6 @@ """Config flow for baf.""" from __future__ import annotations -import asyncio from asyncio import timeout import logging from typing import Any @@ -28,7 +27,7 @@ async def async_try_connect(ip_address: str) -> Device: try: async with timeout(RUN_TIMEOUT): await device.async_wait_available() - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise CannotConnect from ex finally: run_future.cancel() diff --git a/homeassistant/components/blackbird/media_player.py b/homeassistant/components/blackbird/media_player.py index daa23553c96..61dca6550c0 100644 --- a/homeassistant/components/blackbird/media_player.py +++ b/homeassistant/components/blackbird/media_player.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -import socket from pyblackbird import get_blackbird from serial import SerialException @@ -93,7 +92,7 @@ def setup_platform( try: blackbird = get_blackbird(host, False) connection = host - except socket.timeout: + except TimeoutError: _LOGGER.error("Error connecting to the Blackbird controller") return diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index c4f13503abf..6446949cb89 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -62,7 +62,6 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): def __init__(self, feature: blebox_uniapi.light.Light) -> None: """Initialize a BleBox light.""" super().__init__(feature) - self._attr_supported_color_modes = {self.color_mode} if feature.effect_list: self._attr_supported_features = LightEntityFeature.EFFECT @@ -94,6 +93,11 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): return color_mode_tmp + @property + def supported_color_modes(self): + """Return supported color modes.""" + return {self.color_mode} + @property def effect_list(self) -> list[str]: """Return the list of supported effects.""" diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 50c7fad516a..e86d07c8780 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -1,5 +1,4 @@ """Support for Blink Home Camera System.""" -import asyncio from copy import deepcopy import logging @@ -93,7 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await blink.start() - except (ClientError, asyncio.TimeoutError) as ex: + except (ClientError, TimeoutError) as ex: raise ConfigEntryNotReady("Can not connect to host") from ex if blink.auth.check_key_required(): diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index 80a6ceb50e0..f3e3d97fc5d 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -1,7 +1,6 @@ """Support for Blink Alarm Control Panel.""" from __future__ import annotations -import asyncio import logging from blinkpy.blinkpy import Blink, BlinkSyncModule @@ -91,7 +90,7 @@ class BlinkSyncModuleHA( try: await self.sync.async_arm(False) - except asyncio.TimeoutError as er: + except TimeoutError as er: raise HomeAssistantError("Blink failed to disarm camera") from er await self.coordinator.async_refresh() @@ -101,7 +100,7 @@ class BlinkSyncModuleHA( try: await self.sync.async_arm(True) - except asyncio.TimeoutError as er: + except TimeoutError as er: raise HomeAssistantError("Blink failed to arm camera away") from er await self.coordinator.async_refresh() diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 838020c98c6..ff4fa6380a7 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -1,7 +1,6 @@ """Support for Blink system camera.""" from __future__ import annotations -import asyncio from collections.abc import Mapping import contextlib import logging @@ -96,7 +95,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): try: await self._camera.async_arm(True) - except asyncio.TimeoutError as er: + except TimeoutError as er: raise HomeAssistantError("Blink failed to arm camera") from er self._camera.motion_enabled = True @@ -106,7 +105,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): """Disable motion detection for the camera.""" try: await self._camera.async_arm(False) - except asyncio.TimeoutError as er: + except TimeoutError as er: raise HomeAssistantError("Blink failed to disarm camera") from er self._camera.motion_enabled = False @@ -124,7 +123,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): async def trigger_camera(self) -> None: """Trigger camera to take a snapshot.""" - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): await self._camera.snap_picture() self.async_write_ha_state() diff --git a/homeassistant/components/blink/switch.py b/homeassistant/components/blink/switch.py index 197c8e08685..0a066850d5f 100644 --- a/homeassistant/components/blink/switch.py +++ b/homeassistant/components/blink/switch.py @@ -1,7 +1,6 @@ """Support for Blink Motion detection switches.""" from __future__ import annotations -import asyncio from typing import Any from homeassistant.components.switch import ( @@ -74,7 +73,7 @@ class BlinkSwitch(CoordinatorEntity[BlinkUpdateCoordinator], SwitchEntity): try: await self._camera.async_arm(True) - except asyncio.TimeoutError as er: + except TimeoutError as er: raise HomeAssistantError( "Blink failed to arm camera motion detection" ) from er @@ -86,7 +85,7 @@ class BlinkSwitch(CoordinatorEntity[BlinkUpdateCoordinator], SwitchEntity): try: await self._camera.async_arm(False) - except asyncio.TimeoutError as er: + except TimeoutError as er: raise HomeAssistantError( "Blink failed to dis-arm camera motion detection" ) from er diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py index 604f251bfeb..16b81c3c1e7 100644 --- a/homeassistant/components/blue_current/__init__.py +++ b/homeassistant/components/blue_current/__init__.py @@ -1,6 +1,7 @@ """The Blue Current integration.""" from __future__ import annotations +import asyncio from contextlib import suppress from datetime import datetime from typing import Any @@ -14,8 +15,13 @@ from bluecurrent_api.exceptions import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME, CONF_API_TOKEN, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + ATTR_NAME, + CONF_API_TOKEN, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later @@ -47,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except BlueCurrentException as err: raise ConfigEntryNotReady from err - hass.async_create_task(connector.start_loop()) + hass.async_create_background_task(connector.start_loop(), "blue_current-websocket") await client.get_charge_points() await client.wait_for_response() @@ -56,6 +62,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.async_on_unload(connector.disconnect) + async def _async_disconnect_websocket(_: Event) -> None: + await connector.disconnect() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_disconnect_websocket) + return True @@ -78,9 +89,9 @@ class Connector: self, hass: HomeAssistant, config: ConfigEntry, client: Client ) -> None: """Initialize.""" - self.config: ConfigEntry = config - self.hass: HomeAssistant = hass - self.client: Client = client + self.config = config + self.hass = hass + self.client = client self.charge_points: dict[str, dict] = {} self.grid: dict[str, Any] = {} self.available = False @@ -93,22 +104,12 @@ class Connector: async def on_data(self, message: dict) -> None: """Handle received data.""" - async def handle_charge_points(data: list) -> None: - """Loop over the charge points and get their data.""" - for entry in data: - evse_id = entry[EVSE_ID] - model = entry[MODEL_TYPE] - name = entry[ATTR_NAME] - self.add_charge_point(evse_id, model, name) - await self.get_charge_point_data(evse_id) - await self.client.get_grid_status(data[0][EVSE_ID]) - object_name: str = message[OBJECT] # gets charge point ids if object_name == CHARGE_POINTS: charge_points_data: list = message[DATA] - await handle_charge_points(charge_points_data) + await self.handle_charge_point_data(charge_points_data) # gets charge point key / values elif object_name in VALUE_TYPES: @@ -122,8 +123,21 @@ class Connector: self.grid = data self.dispatch_grid_update_signal() - async def get_charge_point_data(self, evse_id: str) -> None: - """Get all the data of a charge point.""" + async def handle_charge_point_data(self, charge_points_data: list) -> None: + """Handle incoming chargepoint data.""" + await asyncio.gather( + *( + self.handle_charge_point( + entry[EVSE_ID], entry[MODEL_TYPE], entry[ATTR_NAME] + ) + for entry in charge_points_data + ) + ) + await self.client.get_grid_status(charge_points_data[0][EVSE_ID]) + + async def handle_charge_point(self, evse_id: str, model: str, name: str) -> None: + """Add the chargepoint and request their data.""" + self.add_charge_point(evse_id, model, name) await self.client.get_status(evse_id) def add_charge_point(self, evse_id: str, model: str, name: str) -> None: @@ -159,9 +173,8 @@ class Connector: """Keep trying to reconnect to the websocket.""" try: await self.connect(self.config.data[CONF_API_TOKEN]) - LOGGER.info("Reconnected to the Blue Current websocket") + LOGGER.debug("Reconnected to the Blue Current websocket") self.hass.async_create_task(self.start_loop()) - await self.client.get_charge_points() except RequestLimitReached: self.available = False async_call_later( diff --git a/homeassistant/components/blue_current/entity.py b/homeassistant/components/blue_current/entity.py index 300f2191cdc..c797fec08b0 100644 --- a/homeassistant/components/blue_current/entity.py +++ b/homeassistant/components/blue_current/entity.py @@ -1,4 +1,6 @@ """Entity representing a Blue Current charge point.""" +from abc import abstractmethod + from homeassistant.const import ATTR_NAME from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo @@ -17,9 +19,9 @@ class BlueCurrentEntity(Entity): def __init__(self, connector: Connector, signal: str) -> None: """Initialize the entity.""" - self.connector: Connector = connector - self.signal: str = signal - self.has_value: bool = False + self.connector = connector + self.signal = signal + self.has_value = False async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -40,9 +42,9 @@ class BlueCurrentEntity(Entity): return self.connector.available and self.has_value @callback + @abstractmethod def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" - raise NotImplementedError class ChargepointEntity(BlueCurrentEntity): @@ -50,6 +52,8 @@ class ChargepointEntity(BlueCurrentEntity): def __init__(self, connector: Connector, evse_id: str) -> None: """Initialize the entity.""" + super().__init__(connector, f"{DOMAIN}_value_update_{evse_id}") + chargepoint_name = connector.charge_points[evse_id][ATTR_NAME] self.evse_id = evse_id @@ -59,5 +63,3 @@ class ChargepointEntity(BlueCurrentEntity): manufacturer="Blue Current", model=connector.charge_points[evse_id][MODEL_TYPE], ) - - super().__init__(connector, f"{DOMAIN}_value_update_{self.evse_id}") diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 293d0cd6ab7..3ba6349b714 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -13,7 +13,6 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "limit_reached": "Request limit reached", "invalid_token": "Invalid token", - "no_cards_found": "No charge cards found", "already_connected": "Already connected", "unknown": "[%key:common::config_flow::error::unknown%]" }, diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index eba03963ebc..70c19b5fa6f 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -290,7 +290,7 @@ class BluesoundPlayer(MediaPlayerEntity): while True: await self.async_update_status() - except (asyncio.TimeoutError, ClientError, BluesoundPlayer._TimeoutException): + except (TimeoutError, ClientError, BluesoundPlayer._TimeoutException): _LOGGER.info("Node %s:%s is offline, retrying later", self.name, self.port) await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) self.start_polling() @@ -317,7 +317,7 @@ class BluesoundPlayer(MediaPlayerEntity): self._retry_remove = None await self.force_update_sync_status(self._init_callback, True) - except (asyncio.TimeoutError, ClientError): + except (TimeoutError, ClientError): _LOGGER.info("Node %s:%s is offline, retrying later", self.host, self.port) self._retry_remove = async_track_time_interval( self._hass, self.async_init, NODE_RETRY_INITIATION @@ -370,7 +370,7 @@ class BluesoundPlayer(MediaPlayerEntity): _LOGGER.error("Error %s on %s", response.status, url) return None - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): if raise_timeout: _LOGGER.info("Timeout: %s:%s", self.host, self.port) raise @@ -437,7 +437,7 @@ class BluesoundPlayer(MediaPlayerEntity): "Error %s on %s. Trying one more time", response.status, url ) - except (asyncio.TimeoutError, ClientError): + except (TimeoutError, ClientError): self._is_online = False self._last_status_update = None self._status = None diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index 453ab996abc..2fd650d9580 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -90,6 +90,8 @@ def seen_all_fields( class IntegrationMatcher: """Integration matcher for the bluetooth integration.""" + __slots__ = ("_integration_matchers", "_matched", "_matched_connectable", "_index") + def __init__(self, integration_matchers: list[BluetoothMatcher]) -> None: """Initialize the matcher.""" self._integration_matchers = integration_matchers @@ -159,6 +161,16 @@ class BluetoothMatcherIndexBase(Generic[_T]): any bucket and we can quickly reject the service info as not matching. """ + __slots__ = ( + "local_name", + "service_uuid", + "service_data_uuid", + "manufacturer_id", + "service_uuid_set", + "service_data_uuid_set", + "manufacturer_id_set", + ) + def __init__(self) -> None: """Initialize the matcher index.""" self.local_name: dict[str, list[_T]] = {} @@ -285,6 +297,8 @@ class BluetoothCallbackMatcherIndex( Supports matching on addresses. """ + __slots__ = ("address", "connectable") + def __init__(self) -> None: """Initialize the matcher index.""" super().__init__() diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 43991672e81..a92a5317ba4 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -649,7 +649,8 @@ class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProce self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name if device_id is None: self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_BLUETOOTH, address)} - self._attr_name = processor.entity_names.get(entity_key) + if (name := processor.entity_names.get(entity_key)) is not None: + self._attr_name = name @property def available(self) -> bool: diff --git a/homeassistant/components/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py index 3739734223e..f85a9506d72 100644 --- a/homeassistant/components/bluetooth_le_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py @@ -1,7 +1,6 @@ """Tracking for bluetooth low energy devices.""" from __future__ import annotations -import asyncio from datetime import datetime, timedelta import logging from uuid import UUID @@ -155,7 +154,7 @@ async def async_setup_scanner( # noqa: C901 async with BleakClient(device) as client: bat_char = await client.read_gatt_char(BATTERY_CHARACTERISTIC_UUID) battery = ord(bat_char) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.debug( "Timeout when trying to get battery status for %s", service_info.name ) diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index d5a213256c3..079563b1ad3 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -10,7 +10,11 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import discovery, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + discovery, + entity_registry as er, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType @@ -146,6 +150,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) + # Clean up vehicles which are not assigned to the account anymore + account_vehicles = {(DOMAIN, v.vin) for v in coordinator.account.vehicles} + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=entry.entry_id + ) + for device in device_entries: + if not device.identifiers.intersection(account_vehicles): + device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + return True diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index b6f402004f6..2e60512156f 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -1,5 +1,4 @@ """The Bond integration.""" -from asyncio import TimeoutError as AsyncIOTimeoutError from http import HTTPStatus import logging from typing import Any @@ -56,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Bond token no longer valid: %s", ex) return False raise ConfigEntryNotReady from ex - except (ClientError, AsyncIOTimeoutError, OSError) as error: + except (ClientError, TimeoutError, OSError) as error: raise ConfigEntryNotReady from error bpup_subs = BPUPSubscriptions() diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 26b485127f2..33b5d2bf2c4 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Bond integration.""" from __future__ import annotations -import asyncio import contextlib from http import HTTPStatus import logging @@ -87,7 +86,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: if not (token := await async_get_token(self.hass, host)): return - except asyncio.TimeoutError: + except TimeoutError: return self._discovered[CONF_ACCESS_TOKEN] = token diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 2c54ad8f3dd..dd307547b81 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations from abc import abstractmethod -from asyncio import Lock, TimeoutError as AsyncIOTimeoutError +from asyncio import Lock from datetime import datetime import logging @@ -139,7 +139,7 @@ class BondEntity(Entity): """Fetch via the API.""" try: state: dict = await self._hub.bond.device_state(self._device_id) - except (ClientError, AsyncIOTimeoutError, OSError) as error: + except (ClientError, TimeoutError, OSError) as error: if self.available: _LOGGER.warning( "Entity %s has become unavailable", self.entity_id, exc_info=error diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index e9501fc64b3..dc9f053876e 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -3,8 +3,8 @@ from __future__ import annotations import logging -from python_bring_api.bring import Bring -from python_bring_api.exceptions import ( +from bring_api.bring import Bring +from bring_api.exceptions import ( BringAuthException, BringParseException, BringRequestException, @@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN from .coordinator import BringDataUpdateCoordinator @@ -29,14 +30,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: email = entry.data[CONF_EMAIL] password = entry.data[CONF_PASSWORD] - bring = Bring(email, password) - - def login_and_load_lists() -> None: - bring.login() - bring.loadLists() + session = async_get_clientsession(hass) + bring = Bring(session, email, password) try: - await hass.async_add_executor_job(login_and_load_lists) + await bring.login() + await bring.loadLists() except BringRequestException as e: raise ConfigEntryNotReady( f"Timeout while connecting for email '{email}'" diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py index 21774117ff6..0a2198712c9 100644 --- a/homeassistant/components/bring/config_flow.py +++ b/homeassistant/components/bring/config_flow.py @@ -4,13 +4,14 @@ from __future__ import annotations import logging from typing import Any -from python_bring_api.bring import Bring -from python_bring_api.exceptions import BringAuthException, BringRequestException +from bring_api.bring import Bring +from bring_api.exceptions import BringAuthException, BringRequestException import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, @@ -48,14 +49,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - bring = Bring(user_input[CONF_EMAIL], user_input[CONF_PASSWORD]) - - def login_and_load_lists() -> None: - bring.login() - bring.loadLists() + session = async_get_clientsession(self.hass) + bring = Bring(session, user_input[CONF_EMAIL], user_input[CONF_PASSWORD]) try: - await self.hass.async_add_executor_job(login_and_load_lists) + await bring.login() + await bring.loadLists() except BringRequestException: errors["base"] = "cannot_connect" except BringAuthException: diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index a7bd4a35f43..57cc3d71085 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -4,9 +4,9 @@ from __future__ import annotations from datetime import timedelta import logging -from python_bring_api.bring import Bring -from python_bring_api.exceptions import BringParseException, BringRequestException -from python_bring_api.types import BringItemsResponse, BringList +from bring_api.bring import Bring +from bring_api.exceptions import BringParseException, BringRequestException +from bring_api.types import BringItemsResponse, BringList from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -40,9 +40,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): async def _async_update_data(self) -> dict[str, BringData]: try: - lists_response = await self.hass.async_add_executor_job( - self.bring.loadLists - ) + lists_response = await self.bring.loadLists() except BringRequestException as e: raise UpdateFailed("Unable to connect and retrieve data from bring") from e except BringParseException as e: @@ -51,9 +49,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): list_dict = {} for lst in lists_response["lists"]: try: - items = await self.hass.async_add_executor_job( - self.bring.getItems, lst["listUuid"] - ) + items = await self.bring.getItems(lst["listUuid"]) except BringRequestException as e: raise UpdateFailed( "Unable to connect and retrieve data from bring" diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index bc249ecea98..195fbf05f1f 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bring", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["python-bring-api==2.0.0"] + "requirements": ["bring-api==0.1.1"] } diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index bd87a2d18de..608cc58bfba 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from python_bring_api.exceptions import BringRequestException +from bring_api.exceptions import BringRequestException from homeassistant.components.todo import ( TodoItem, @@ -75,8 +75,8 @@ class BringTodoListEntity( """Return the todo items.""" return [ TodoItem( - uid=item["name"], - summary=item["name"], + uid=item["itemId"], + summary=item["itemId"], description=item["specification"] or "", status=TodoItemStatus.NEEDS_ACTION, ) @@ -91,11 +91,8 @@ class BringTodoListEntity( async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" try: - await self.hass.async_add_executor_job( - self.coordinator.bring.saveItem, - self.bring_list["listUuid"], - item.summary, - item.description or "", + await self.coordinator.bring.saveItem( + self.bring_list["listUuid"], item.summary, item.description or "" ) except BringRequestException as e: raise HomeAssistantError("Unable to save todo item for bring") from e @@ -126,16 +123,14 @@ class BringTodoListEntity( assert item.uid if item.status == TodoItemStatus.COMPLETED: - await self.hass.async_add_executor_job( - self.coordinator.bring.removeItem, + await self.coordinator.bring.removeItem( bring_list["listUuid"], item.uid, ) elif item.summary == item.uid: try: - await self.hass.async_add_executor_job( - self.coordinator.bring.updateItem, + await self.coordinator.bring.updateItem( bring_list["listUuid"], item.uid, item.description or "", @@ -144,13 +139,11 @@ class BringTodoListEntity( raise HomeAssistantError("Unable to update todo item for bring") from e else: try: - await self.hass.async_add_executor_job( - self.coordinator.bring.removeItem, + await self.coordinator.bring.removeItem( bring_list["listUuid"], item.uid, ) - await self.hass.async_add_executor_job( - self.coordinator.bring.saveItem, + await self.coordinator.bring.saveItem( bring_list["listUuid"], item.summary, item.description or "", @@ -164,8 +157,8 @@ class BringTodoListEntity( """Delete an item from the To-do list.""" for uid in uids: try: - await self.hass.async_add_executor_job( - self.coordinator.bring.removeItem, self.bring_list["listUuid"], uid + await self.coordinator.bring.removeItem( + self.bring_list["listUuid"], uid ) except BringRequestException as e: raise HomeAssistantError("Unable to delete todo item for bring") from e diff --git a/homeassistant/components/brother/icons.json b/homeassistant/components/brother/icons.json new file mode 100644 index 00000000000..0e609f4190a --- /dev/null +++ b/homeassistant/components/brother/icons.json @@ -0,0 +1,105 @@ +{ + "entity": { + "sensor": { + "belt_unit_remaining_life": { + "default": "mdi:current-ac" + }, + "black_drum_page_counter": { + "default": "mdi:chart-donut" + }, + "black_drum_remaining_life": { + "default": "mdi:chart-donut" + }, + "black_drum_remaining_pages": { + "default": "mdi:chart-donut" + }, + "black_toner_remaining": { + "default": "mdi:printer-3d-nozzle" + }, + "black_ink_remaining": { + "default": "mdi:printer-3d-nozzle" + }, + "bw_pages": { + "default": "mdi:file-document-outline" + }, + "color_pages": { + "default": "mdi:file-document-outline" + }, + "cyan_drum_page_counter": { + "default": "mdi:chart-donut" + }, + "cyan_drum_remaining_life": { + "default": "mdi:chart-donut" + }, + "cyan_drum_remaining_pages": { + "default": "mdi:chart-donut" + }, + "cyan_ink_remaining": { + "default": "mdi:printer-3d-nozzle" + }, + "cyan_toner_remaining": { + "default": "mdi:printer-3d-nozzle" + }, + "drum_page_counter": { + "default": "mdi:chart-donut" + }, + "drum_remaining_life": { + "default": "mdi:chart-donut" + }, + "drum_remaining_pages": { + "default": "mdi:chart-donut" + }, + "duplex_unit_page_counter": { + "default": "mdi:file-document-outline" + }, + "fuser_remaining_life": { + "default": "mdi:water-outline" + }, + "laser_remaining_life": { + "default": "mdi:spotlight-beam" + }, + "magenta_drum_page_counter": { + "default": "mdi:chart-donut" + }, + "magenta_drum_remaining_life": { + "default": "mdi:chart-donut" + }, + "magenta_drum_remaining_pages": { + "default": "mdi:chart-donut" + }, + "magenta_ink_remaining": { + "default": "mdi:printer-3d-nozzle" + }, + "magenta_toner_remaining": { + "default": "mdi:printer-3d-nozzle" + }, + "status": { + "default": "mdi:printer" + }, + "page_counter": { + "default": "mdi:file-document-outline" + }, + "pf_kit_1_remaining_life": { + "default": "mdi:printer-3d" + }, + "pf_kit_mp_remaining_life": { + "default": "mdi:printer-3d" + }, + "yellow_drum_page_counter": { + "default": "mdi:chart-donut" + }, + "yellow_drum_remaining_life": { + "default": "mdi:chart-donut" + }, + "yellow_drum_remaining_pages": { + "default": "mdi:chart-donut" + }, + "yellow_ink_remaining": { + "default": "mdi:printer-3d-nozzle" + }, + "yellow_toner_remaining": { + "default": "mdi:printer-3d-nozzle" + } + } + } +} diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 27e4b7fd715..d91eb606bae 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -52,14 +52,12 @@ class BrotherSensorEntityDescription( SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="status", - icon="mdi:printer", translation_key="status", entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.status, ), BrotherSensorEntityDescription( key="page_counter", - icon="mdi:file-document-outline", translation_key="page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -68,7 +66,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="bw_counter", - icon="mdi:file-document-outline", translation_key="bw_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -77,7 +74,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="color_counter", - icon="mdi:file-document-outline", translation_key="color_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -86,7 +82,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="duplex_unit_pages_counter", - icon="mdi:file-document-outline", translation_key="duplex_unit_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -95,7 +90,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="drum_remaining_life", - icon="mdi:chart-donut", translation_key="drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -104,7 +98,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="drum_remaining_pages", - icon="mdi:chart-donut", translation_key="drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -113,7 +106,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="drum_counter", - icon="mdi:chart-donut", translation_key="drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -122,7 +114,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="black_drum_remaining_life", - icon="mdi:chart-donut", translation_key="black_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -131,7 +122,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="black_drum_remaining_pages", - icon="mdi:chart-donut", translation_key="black_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -140,7 +130,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="black_drum_counter", - icon="mdi:chart-donut", translation_key="black_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -149,7 +138,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="cyan_drum_remaining_life", - icon="mdi:chart-donut", translation_key="cyan_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -158,7 +146,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="cyan_drum_remaining_pages", - icon="mdi:chart-donut", translation_key="cyan_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -167,7 +154,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="cyan_drum_counter", - icon="mdi:chart-donut", translation_key="cyan_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -176,7 +162,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="magenta_drum_remaining_life", - icon="mdi:chart-donut", translation_key="magenta_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -185,7 +170,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="magenta_drum_remaining_pages", - icon="mdi:chart-donut", translation_key="magenta_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -194,7 +178,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="magenta_drum_counter", - icon="mdi:chart-donut", translation_key="magenta_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -203,7 +186,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="yellow_drum_remaining_life", - icon="mdi:chart-donut", translation_key="yellow_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -212,7 +194,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="yellow_drum_remaining_pages", - icon="mdi:chart-donut", translation_key="yellow_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -221,7 +202,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="yellow_drum_counter", - icon="mdi:chart-donut", translation_key="yellow_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -230,7 +210,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="belt_unit_remaining_life", - icon="mdi:current-ac", translation_key="belt_unit_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -239,7 +218,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="fuser_remaining_life", - icon="mdi:water-outline", translation_key="fuser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -248,7 +226,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="laser_remaining_life", - icon="mdi:spotlight-beam", translation_key="laser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -257,7 +234,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="pf_kit_1_remaining_life", - icon="mdi:printer-3d", translation_key="pf_kit_1_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -266,7 +242,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="pf_kit_mp_remaining_life", - icon="mdi:printer-3d", translation_key="pf_kit_mp_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -275,7 +250,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="black_toner_remaining", - icon="mdi:printer-3d-nozzle", translation_key="black_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -284,7 +258,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="cyan_toner_remaining", - icon="mdi:printer-3d-nozzle", translation_key="cyan_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -293,7 +266,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="magenta_toner_remaining", - icon="mdi:printer-3d-nozzle", translation_key="magenta_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -302,7 +274,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="yellow_toner_remaining", - icon="mdi:printer-3d-nozzle", translation_key="yellow_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -311,7 +282,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="black_ink_remaining", - icon="mdi:printer-3d-nozzle", translation_key="black_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -320,7 +290,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="cyan_ink_remaining", - icon="mdi:printer-3d-nozzle", translation_key="cyan_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -329,7 +298,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="magenta_ink_remaining", - icon="mdi:printer-3d-nozzle", translation_key="magenta_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -338,7 +306,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="yellow_ink_remaining", - icon="mdi:printer-3d-nozzle", translation_key="yellow_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 1963041bcca..ba62cbfbb19 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -128,7 +128,7 @@ class BuienradarCam(Camera): _LOGGER.debug("HTTP 200 - Last-Modified: %s", last_modified) return True - except (asyncio.TimeoutError, aiohttp.ClientError) as err: + except (TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Failed to fetch image, %s", type(err)) return False diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index 63e0004dc43..426f982bafc 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -1,5 +1,4 @@ """Shared utilities for different supported platforms.""" -import asyncio from asyncio import timeout from datetime import datetime, timedelta from http import HTTPStatus @@ -104,7 +103,7 @@ class BrData: result[MESSAGE] = "Got http statuscode: %d" % (resp.status) return result - except (asyncio.TimeoutError, aiohttp.ClientError) as err: + except (TimeoutError, aiohttp.ClientError) as err: result[MESSAGE] = str(err) return result finally: diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 5a78728697b..1abf1768fa3 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -181,7 +181,7 @@ async def _async_get_image( that we can scale, however the majority of cases are handled. """ - with suppress(asyncio.CancelledError, asyncio.TimeoutError): + with suppress(asyncio.CancelledError, TimeoutError): async with asyncio.timeout(timeout): image_bytes = ( await _async_get_stream_image( @@ -891,7 +891,7 @@ async def ws_camera_stream( except HomeAssistantError as ex: _LOGGER.error("Error requesting stream: %s", ex) connection.send_error(msg["id"], "start_stream_failed", str(ex)) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Timeout getting stream source") connection.send_error( msg["id"], "start_stream_failed", "Timeout getting stream source" @@ -936,7 +936,7 @@ async def ws_camera_web_rtc_offer( except (HomeAssistantError, ValueError) as ex: _LOGGER.error("Error handling WebRTC offer: %s", ex) connection.send_error(msg["id"], "web_rtc_offer_failed", str(ex)) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Timeout handling WebRTC offer") connection.send_error( msg["id"], "web_rtc_offer_failed", "Timeout handling WebRTC offer" diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py index 730757de8b4..f05c2c4c143 100644 --- a/homeassistant/components/cast/const.py +++ b/homeassistant/components/cast/const.py @@ -1,9 +1,7 @@ """Consts for Cast integration.""" from __future__ import annotations -from typing import TYPE_CHECKING - -from pychromecast.controllers.homeassistant import HomeAssistantController +from typing import TYPE_CHECKING, TypedDict from homeassistant.helpers.dispatcher import SignalType @@ -33,8 +31,17 @@ SIGNAL_CAST_REMOVED: SignalType[ChromecastInfo] = SignalType("cast_removed") # Dispatcher signal fired when a Chromecast should show a Home Assistant Cast view. SIGNAL_HASS_CAST_SHOW_VIEW: SignalType[ - HomeAssistantController, str, str, str | None + HomeAssistantControllerData, str, str, str | None ] = SignalType("cast_show_view") CONF_IGNORE_CEC = "ignore_cec" CONF_KNOWN_HOSTS = "known_hosts" + + +class HomeAssistantControllerData(TypedDict): + """Data for creating a HomeAssistantController.""" + + hass_url: str + hass_uuid: str + client_id: str | None + refresh_token: str diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index 8b8862ab318..3d880b40407 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -1,7 +1,6 @@ """Helpers to deal with Cast devices.""" from __future__ import annotations -import asyncio import configparser from dataclasses import dataclass import logging @@ -183,10 +182,10 @@ class CastStatusListener( if self._valid: self._cast_device.new_media_status(status) - def load_media_failed(self, item, error_code): + def load_media_failed(self, queue_item_id, error_code): """Handle reception of a new MediaStatus.""" if self._valid: - self._cast_device.load_media_failed(item, error_code) + self._cast_device.load_media_failed(queue_item_id, error_code) def new_connection_status(self, status): """Handle reception of a new ConnectionStatus.""" @@ -257,7 +256,7 @@ async def _fetch_playlist(hass, url, supported_content_types): playlist_data = (await resp.content.read(64 * 1024)).decode(charset) except ValueError as err: raise PlaylistError(f"Could not decode playlist {url}") from err - except asyncio.TimeoutError as err: + except TimeoutError as err: raise PlaylistError(f"Timeout while fetching playlist {url}") from err except aiohttp.client_exceptions.ClientError as err: raise PlaylistError(f"Error while fetching playlist {url}") from err diff --git a/homeassistant/components/cast/home_assistant_cast.py b/homeassistant/components/cast/home_assistant_cast.py index 5eec2a28908..f7518b9519a 100644 --- a/homeassistant/components/cast/home_assistant_cast.py +++ b/homeassistant/components/cast/home_assistant_cast.py @@ -1,7 +1,6 @@ """Home Assistant Cast integration for Cast.""" from __future__ import annotations -from pychromecast.controllers.homeassistant import HomeAssistantController import voluptuous as vol from homeassistant import auth, config_entries, core @@ -11,7 +10,7 @@ from homeassistant.helpers import config_validation as cv, dispatcher, instance_ from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.service import async_register_admin_service -from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW +from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW, HomeAssistantControllerData SERVICE_SHOW_VIEW = "show_lovelace_view" ATTR_VIEW_PATH = "view_path" @@ -55,7 +54,7 @@ async def async_setup_ha_cast( hass_uuid = await instance_id.async_get(hass) - controller = HomeAssistantController( + controller_data = HomeAssistantControllerData( # If you are developing Home Assistant Cast, uncomment and set to # your dev app id. # app_id="5FE44367", @@ -68,7 +67,7 @@ async def async_setup_ha_cast( dispatcher.async_dispatcher_send( hass, SIGNAL_HASS_CAST_SHOW_VIEW, - controller, + controller_data, call.data[ATTR_ENTITY_ID], call.data[ATTR_VIEW_PATH], call.data.get(ATTR_URL_PATH), diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index ae049fefef6..d02bcd3558a 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["PyChromecast==13.1.0"], + "requirements": ["PyChromecast==14.0.0"], "zeroconf": ["_googlecast._tcp.local."] } diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index b472b18bed0..b2893a54310 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -61,6 +61,7 @@ from .const import ( SIGNAL_CAST_DISCOVERED, SIGNAL_CAST_REMOVED, SIGNAL_HASS_CAST_SHOW_VIEW, + HomeAssistantControllerData, ) from .discovery import setup_internal_discovery from .helpers import ( @@ -389,15 +390,15 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): self.media_status_received = dt_util.utcnow() self.schedule_update_ha_state() - def load_media_failed(self, item, error_code): + def load_media_failed(self, queue_item_id, error_code): """Handle load media failed.""" _LOGGER.debug( - "[%s %s] Load media failed with code %s(%s) for item %s", + "[%s %s] Load media failed with code %s(%s) for queue_item_id %s", self.entity_id, self._cast_info.friendly_name, error_code, MEDIA_PLAYER_ERROR_CODES.get(error_code, "unknown code"), - item, + queue_item_id, ) def new_connection_status(self, connection_status): @@ -951,7 +952,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): def _handle_signal_show_view( self, - controller: HomeAssistantController, + controller_data: HomeAssistantControllerData, entity_id: str, view_path: str, url_path: str | None, @@ -961,6 +962,23 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): return if self._hass_cast_controller is None: + + def unregister() -> None: + """Handle request to unregister the handler.""" + if not self._hass_cast_controller or not self._chromecast: + return + _LOGGER.debug( + "[%s %s] Unregistering HomeAssistantController", + self.entity_id, + self._cast_info.friendly_name, + ) + + self._chromecast.unregister_handler(self._hass_cast_controller) + self._hass_cast_controller = None + + controller = HomeAssistantController( + **controller_data, unregister=unregister + ) self._hass_cast_controller = controller self._chromecast.register_handler(controller) diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py index 30896d12299..1f90f317fe0 100644 --- a/homeassistant/components/ccm15/climate.py +++ b/homeassistant/components/ccm15/climate.py @@ -64,8 +64,11 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, ac_host: str, ac_index: int, coordinator: CCM15Coordinator diff --git a/homeassistant/components/cert_expiry/helper.py b/homeassistant/components/cert_expiry/helper.py index cde9364214e..6d10d750705 100644 --- a/homeassistant/components/cert_expiry/helper.py +++ b/homeassistant/components/cert_expiry/helper.py @@ -55,7 +55,7 @@ async def get_cert_expiry_timestamp( cert = await async_get_cert(hass, hostname, port) except socket.gaierror as err: raise ResolveFailed(f"Cannot resolve hostname: {hostname}") from err - except asyncio.TimeoutError as err: + except TimeoutError as err: raise ConnectionTimeout( f"Connection timeout with server: {hostname}:{port}" ) from err diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index fcd780dba7d..fc49331c1b7 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -144,7 +144,7 @@ async def async_citybikes_request(hass, uri, schema): json_response = await req.json() return schema(json_response) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.error("Could not connect to CityBikes API endpoint") except ValueError: _LOGGER.error("Received non-JSON data from CityBikes API endpoint") diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index bf663fac365..43d98ad6bbd 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -339,9 +339,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def _report_turn_on_off(feature: str, method: str) -> None: """Log warning not implemented turn on/off feature.""" - module = type(self).__module__ - if module and "custom_components" not in module: - return report_issue = self._suggest_report_issue() if feature.startswith("TURN"): message = ( diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 4152fb5ee2d..db263451f0b 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -1,4 +1,5 @@ """Intents for the client integration.""" + from __future__ import annotations import voluptuous as vol @@ -36,24 +37,34 @@ class GetTemperatureIntent(intent.IntentHandler): if not entities: raise intent.IntentHandleError("No climate entities") - if "area" in slots: - # Filter by area - area_name = slots["area"]["value"] + name_slot = slots.get("name", {}) + entity_name: str | None = name_slot.get("value") + entity_text: str | None = name_slot.get("text") + + area_slot = slots.get("area", {}) + area_id = area_slot.get("value") + + if area_id: + # Filter by area and optionally name + area_name = area_slot.get("text") for maybe_climate in intent.async_match_states( - hass, area_name=area_name, domains=[DOMAIN] + hass, name=entity_name, area_name=area_id, domains=[DOMAIN] ): climate_state = maybe_climate break if climate_state is None: - raise intent.IntentHandleError(f"No climate entity in area {area_name}") + raise intent.NoStatesMatchedError( + name=entity_text or entity_name, + area=area_name or area_id, + domains={DOMAIN}, + device_classes=None, + ) climate_entity = component.get_entity(climate_state.entity_id) - elif "name" in slots: + elif entity_name: # Filter by name - entity_name = slots["name"]["value"] - for maybe_climate in intent.async_match_states( hass, name=entity_name, domains=[DOMAIN] ): @@ -61,7 +72,12 @@ class GetTemperatureIntent(intent.IntentHandler): break if climate_state is None: - raise intent.IntentHandleError(f"No climate entity named {entity_name}") + raise intent.NoStatesMatchedError( + name=entity_name, + area=None, + domains={DOMAIN}, + device_classes=None, + ) climate_entity = component.get_entity(climate_state.entity_id) else: diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index 1423330cb44..f1e5d1a6903 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -1,7 +1,6 @@ """Account linking via the cloud.""" from __future__ import annotations -import asyncio from datetime import datetime import logging from typing import Any @@ -69,7 +68,7 @@ async def _get_services(hass: HomeAssistant) -> list[dict[str, Any]]: try: services = await account_link.async_fetch_available_services(hass.data[DOMAIN]) - except (aiohttp.ClientError, asyncio.TimeoutError): + except (aiohttp.ClientError, TimeoutError): return [] hass.data[DATA_SERVICES] = services @@ -114,7 +113,7 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement try: tokens = await helper.async_get_tokens() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.info("Timeout fetching tokens for flow %s", flow_id) except account_link.AccountLinkException as err: _LOGGER.info( diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index e85c6dd277a..415f2415095 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -246,21 +246,27 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): await self._prefs.async_update( alexa_settings_version=ALEXA_SETTINGS_VERSION ) - async_listen_entity_updates( - self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated + self._on_deinitialize.append( + async_listen_entity_updates( + self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated + ) ) async def on_hass_start(hass: HomeAssistant) -> None: if self.enabled and ALEXA_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, ALEXA_DOMAIN, {}) - start.async_at_start(self.hass, on_hass_start) - start.async_at_started(self.hass, on_hass_started) + self._on_deinitialize.append(start.async_at_start(self.hass, on_hass_start)) + self._on_deinitialize.append(start.async_at_started(self.hass, on_hass_started)) - self._prefs.async_listen_updates(self._async_prefs_updated) - self.hass.bus.async_listen( - er.EVENT_ENTITY_REGISTRY_UPDATED, - self._handle_entity_registry_updated, + self._on_deinitialize.append( + self._prefs.async_listen_updates(self._async_prefs_updated) + ) + self._on_deinitialize.append( + self.hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, + self._handle_entity_registry_updated, + ) ) def _should_expose_legacy(self, entity_id: str) -> bool: @@ -505,7 +511,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): return True - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Timeout trying to sync entities to Alexa") return False diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 8cf79d20c5d..463d290d49c 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Any, Literal import aiohttp -from hass_nabucasa.client import CloudClient as Interface +from hass_nabucasa.client import CloudClient as Interface, RemoteActivationNotAllowed from homeassistant.components import google_assistant, persistent_notification, webhook from homeassistant.components.alexa import ( @@ -213,6 +213,10 @@ class CloudClient(Interface): """Cleanup some stuff after logout.""" await self.prefs.async_set_username(None) + if self._alexa_config: + self._alexa_config.async_deinitialize() + self._alexa_config = None + if self._google_config: self._google_config.async_deinitialize() self._google_config = None @@ -230,6 +234,8 @@ class CloudClient(Interface): async def async_cloud_connect_update(self, connect: bool) -> None: """Process cloud remote message to client.""" + if not self._prefs.remote_allow_remote_enable: + raise RemoteActivationNotAllowed await self._prefs.async_update(remote_enabled=connect) async def async_cloud_connection_info( @@ -238,6 +244,7 @@ class CloudClient(Interface): """Process cloud connection info message to client.""" return { "remote": { + "can_enable": self._prefs.remote_allow_remote_enable, "connected": self.cloud.remote.is_connected, "enabled": self._prefs.remote_enabled, "instance_domain": self.cloud.remote.instance_domain, diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 97d2345f16b..f704fb61f69 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -30,6 +30,8 @@ PREF_GOOGLE_DEFAULT_EXPOSE = "google_default_expose" PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version" PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version" PREF_TTS_DEFAULT_VOICE = "tts_default_voice" +PREF_GOOGLE_CONNECTED = "google_connected" +PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable" DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female") DEFAULT_DISABLE_2FA = False DEFAULT_ALEXA_REPORT_STATE = True diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 42f25f43ae1..12d3453a53c 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -23,7 +23,6 @@ from homeassistant.components.homeassistant.exposed_entities import ( from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import ( - CALLBACK_TYPE, CoreState, Event, HomeAssistant, @@ -145,7 +144,6 @@ class CloudGoogleConfig(AbstractConfig): self._prefs = prefs self._cloud = cloud self._sync_entities_lock = asyncio.Lock() - self._on_deinitialize: list[CALLBACK_TYPE] = [] @property def enabled(self) -> bool: @@ -175,8 +173,12 @@ class CloudGoogleConfig(AbstractConfig): """Return the webhook ID to be used for actions for a given agent user id via the local SDK.""" return self._prefs.google_local_webhook_id - def get_local_agent_user_id(self, webhook_id: Any) -> str: - """Return the user ID to be used for actions received via the local SDK.""" + def get_local_user_id(self, webhook_id: Any) -> str: + """Map webhook ID to a Home Assistant user ID. + + Any action inititated by Google Assistant via the local SDK will be attributed + to the returned user ID. + """ return self._user @property @@ -256,17 +258,6 @@ class CloudGoogleConfig(AbstractConfig): self._on_deinitialize.append(start.async_at_start(self.hass, on_hass_start)) self._on_deinitialize.append(start.async_at_started(self.hass, on_hass_started)) - # Remove any stored user agent id that is not ours - remove_agent_user_ids = [] - for agent_user_id in self._store.agent_user_ids: - if agent_user_id != self.agent_user_id: - remove_agent_user_ids.append(agent_user_id) - - if remove_agent_user_ids: - _LOGGER.debug("remove non cloud agent_user_ids: %s", remove_agent_user_ids) - for agent_user_id in remove_agent_user_ids: - await self.async_disconnect_agent_user(agent_user_id) - self._on_deinitialize.append( self._prefs.async_listen_updates(self._async_prefs_updated) ) @@ -283,13 +274,6 @@ class CloudGoogleConfig(AbstractConfig): ) ) - @callback - def async_deinitialize(self) -> None: - """Remove listeners.""" - _LOGGER.debug("async_deinitialize") - while self._on_deinitialize: - self._on_deinitialize.pop()() - def should_expose(self, state: State) -> bool: """If a state object should be exposed.""" return self._should_expose_entity_id(state.entity_id) @@ -344,7 +328,7 @@ class CloudGoogleConfig(AbstractConfig): @property def has_registered_user_agent(self) -> bool: """Return if we have a Agent User Id registered.""" - return len(self._store.agent_user_ids) > 0 + return len(self.async_get_agent_users()) > 0 def get_agent_user_id(self, context: Any) -> str: """Get agent user ID making request.""" @@ -385,6 +369,30 @@ class CloudGoogleConfig(AbstractConfig): resp = await cloud_api.async_google_actions_request_sync(self._cloud) return resp.status + async def async_connect_agent_user(self, agent_user_id: str) -> None: + """Add a synced and known agent_user_id. + + Called before sending a sync response to Google. + """ + await self._prefs.async_update(google_connected=True) + + async def async_disconnect_agent_user(self, agent_user_id: str) -> None: + """Turn off report state and disable further state reporting. + + Called when: + - The user disconnects their account from Google. + - When the cloud configuration is initialized + - When sync entities fails with 404 + """ + await self._prefs.async_update(google_connected=False) + + @callback + def async_get_agent_users(self) -> tuple: + """Return known agent users.""" + if not self._prefs.google_connected or not self._cloud.username: + return () + return (self._cloud.username,) + async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: """Handle updated preferences.""" _LOGGER.debug("_async_prefs_updated") diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 849a1c99db9..4fd9d5c0301 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -44,6 +44,7 @@ from .const import ( PREF_ENABLE_GOOGLE, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, + PREF_REMOTE_ALLOW_REMOTE_ENABLE, PREF_TTS_DEFAULT_VOICE, REQUEST_TIMEOUT, ) @@ -55,7 +56,7 @@ _LOGGER = logging.getLogger(__name__) _CLOUD_ERRORS: dict[type[Exception], tuple[HTTPStatus, str]] = { - asyncio.TimeoutError: ( + TimeoutError: ( HTTPStatus.BAD_GATEWAY, "Unable to reach the Home Assistant cloud.", ), @@ -235,7 +236,7 @@ class CloudLogoutView(HomeAssistantView): async def post(self, request: web.Request) -> web.Response: """Handle logout request.""" hass = request.app["hass"] - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] async with asyncio.timeout(REQUEST_TIMEOUT): await cloud.logout() @@ -262,7 +263,7 @@ class CloudRegisterView(HomeAssistantView): async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle registration request.""" hass = request.app["hass"] - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] client_metadata = None @@ -299,7 +300,7 @@ class CloudResendConfirmView(HomeAssistantView): async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle resending confirm email code request.""" hass = request.app["hass"] - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] async with asyncio.timeout(REQUEST_TIMEOUT): await cloud.auth.async_resend_email_confirm(data["email"]) @@ -319,7 +320,7 @@ class CloudForgotPasswordView(HomeAssistantView): async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle forgot password request.""" hass = request.app["hass"] - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] async with asyncio.timeout(REQUEST_TIMEOUT): await cloud.auth.async_forgot_password(data["email"]) @@ -338,7 +339,7 @@ async def websocket_cloud_status( Async friendly. """ - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] connection.send_message( websocket_api.result_message(msg["id"], await _account_data(hass, cloud)) ) @@ -362,7 +363,7 @@ def _require_cloud_login( msg: dict[str, Any], ) -> None: """Require to be logged into the cloud.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] if not cloud.is_logged_in: connection.send_message( websocket_api.error_message( @@ -385,7 +386,7 @@ async def websocket_subscription( msg: dict[str, Any], ) -> None: """Handle request for account info.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] if (data := await async_subscription_info(cloud)) is None: connection.send_error( msg["id"], "request_failed", "Failed to request subscription" @@ -408,6 +409,7 @@ async def websocket_subscription( vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All( vol.Coerce(tuple), vol.In(MAP_VOICE) ), + vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool, } ) @websocket_api.async_response @@ -417,7 +419,7 @@ async def websocket_update_prefs( msg: dict[str, Any], ) -> None: """Handle request for account info.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] changes = dict(msg) changes.pop("id") @@ -429,7 +431,7 @@ async def websocket_update_prefs( try: async with asyncio.timeout(10): await alexa_config.async_get_access_token() - except asyncio.TimeoutError: + except TimeoutError: connection.send_error( msg["id"], "alexa_timeout", "Timeout validating Alexa access token." ) @@ -468,7 +470,7 @@ async def websocket_hook_create( msg: dict[str, Any], ) -> None: """Handle request for account info.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] hook = await cloud.cloudhooks.async_create(msg["webhook_id"], False) connection.send_message(websocket_api.result_message(msg["id"], hook)) @@ -488,7 +490,7 @@ async def websocket_hook_delete( msg: dict[str, Any], ) -> None: """Handle request for account info.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] await cloud.cloudhooks.async_delete(msg["webhook_id"]) connection.send_message(websocket_api.result_message(msg["id"])) @@ -557,7 +559,7 @@ async def websocket_remote_connect( msg: dict[str, Any], ) -> None: """Handle request for connect remote.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] await cloud.client.prefs.async_update(remote_enabled=True) connection.send_result(msg["id"], await _account_data(hass, cloud)) @@ -573,7 +575,7 @@ async def websocket_remote_disconnect( msg: dict[str, Any], ) -> None: """Handle request for disconnect remote.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] await cloud.client.prefs.async_update(remote_enabled=False) connection.send_result(msg["id"], await _account_data(hass, cloud)) @@ -594,7 +596,7 @@ async def google_assistant_get( msg: dict[str, Any], ) -> None: """Get data for a single google assistant entity.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] gconf = await cloud.client.get_google_config() entity_id: str = msg["entity_id"] state = hass.states.get(entity_id) @@ -642,7 +644,7 @@ async def google_assistant_list( msg: dict[str, Any], ) -> None: """List all google assistant entities.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] gconf = await cloud.client.get_google_config() entities = google_helpers.async_get_entities(hass, gconf) @@ -736,7 +738,7 @@ async def alexa_list( msg: dict[str, Any], ) -> None: """List all alexa entities.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] alexa_config = await cloud.client.get_alexa_config() entities = alexa_entities.async_get_entities(hass, alexa_config) @@ -764,7 +766,7 @@ async def alexa_sync( msg: dict[str, Any], ) -> None: """Sync with Alexa.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] alexa_config = await cloud.client.get_alexa_config() async with asyncio.timeout(10): @@ -794,7 +796,7 @@ async def thingtalk_convert( msg: dict[str, Any], ) -> None: """Convert a query.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] async with asyncio.timeout(10): try: diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index d314aac2092..e816516fd7a 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.76.0"] + "requirements": ["hass-nabucasa==0.78.0"] } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index af5f9213e4d..010a9697f26 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -8,6 +8,9 @@ import uuid from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User from homeassistant.components import webhook +from homeassistant.components.google_assistant.http import ( + async_get_users as async_get_google_assistant_users, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import UNDEFINED, UndefinedType @@ -28,6 +31,7 @@ from .const import ( PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, + PREF_GOOGLE_CONNECTED, PREF_GOOGLE_DEFAULT_EXPOSE, PREF_GOOGLE_ENTITY_CONFIGS, PREF_GOOGLE_LOCAL_WEBHOOK_ID, @@ -35,6 +39,7 @@ from .const import ( PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_GOOGLE_SETTINGS_VERSION, PREF_INSTANCE_ID, + PREF_REMOTE_ALLOW_REMOTE_ENABLE, PREF_REMOTE_DOMAIN, PREF_TTS_DEFAULT_VOICE, PREF_USERNAME, @@ -42,7 +47,7 @@ from .const import ( STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 2 +STORAGE_VERSION_MINOR = 3 ALEXA_SETTINGS_VERSION = 3 GOOGLE_SETTINGS_VERSION = 3 @@ -55,10 +60,27 @@ class CloudPreferencesStore(Store): self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] ) -> dict[str, Any]: """Migrate to the new version.""" + + async def google_connected() -> bool: + """Return True if our user is preset in the google_assistant store.""" + # If we don't have a user, we can't be connected to Google + if not (cur_username := old_data.get(PREF_USERNAME)): + return False + + # If our user is in the Google store, we're connected + return cur_username in await async_get_google_assistant_users(self.hass) + if old_major_version == 1: if old_minor_version < 2: old_data.setdefault(PREF_ALEXA_SETTINGS_VERSION, 1) old_data.setdefault(PREF_GOOGLE_SETTINGS_VERSION, 1) + if old_minor_version < 3: + # Import settings from the google_assistant store which was previously + # shared between the cloud integration and manually configured Google + # assistant. + # In HA Core 2024.9, remove the import and also remove the Google + # assistant store if it's not been migrated by manual Google assistant + old_data.setdefault(PREF_GOOGLE_CONNECTED, await google_connected()) return old_data @@ -131,6 +153,8 @@ class CloudPreferences: remote_domain: str | None | UndefinedType = UNDEFINED, alexa_settings_version: int | UndefinedType = UNDEFINED, google_settings_version: int | UndefinedType = UNDEFINED, + google_connected: bool | UndefinedType = UNDEFINED, + remote_allow_remote_enable: bool | UndefinedType = UNDEFINED, ) -> None: """Update user preferences.""" prefs = {**self._prefs} @@ -148,6 +172,8 @@ class CloudPreferences: (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version), (PREF_TTS_DEFAULT_VOICE, tts_default_voice), (PREF_REMOTE_DOMAIN, remote_domain), + (PREF_GOOGLE_CONNECTED, google_connected), + (PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable), ): if value is not UNDEFINED: prefs[key] = value @@ -189,9 +215,16 @@ class CloudPreferences: PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose, PREF_GOOGLE_REPORT_STATE: self.google_report_state, PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, + PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, } + @property + def remote_allow_remote_enable(self) -> bool: + """Return if it's allowed to remotely activate remote.""" + allowed: bool = self._prefs.get(PREF_REMOTE_ALLOW_REMOTE_ENABLE, True) + return allowed + @property def remote_enabled(self) -> bool: """Return if remote is enabled on start.""" @@ -241,6 +274,12 @@ class CloudPreferences: google_enabled: bool = self._prefs[PREF_ENABLE_GOOGLE] return google_enabled + @property + def google_connected(self) -> bool: + """Return if Google is connected.""" + google_connected: bool = self._prefs[PREF_GOOGLE_CONNECTED] + return google_connected + @property def google_report_state(self) -> bool: """Return if Google report state is enabled.""" @@ -338,6 +377,7 @@ class CloudPreferences: PREF_ENABLE_ALEXA: True, PREF_ENABLE_GOOGLE: True, PREF_ENABLE_REMOTE: False, + PREF_GOOGLE_CONNECTED: False, PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_GOOGLE_ENTITY_CONFIGS: {}, PREF_GOOGLE_SETTINGS_VERSION: GOOGLE_SETTINGS_VERSION, @@ -345,5 +385,6 @@ class CloudPreferences: PREF_INSTANCE_ID: uuid.uuid4().hex, PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_REMOTE_DOMAIN: None, + PREF_REMOTE_ALLOW_REMOTE_ENABLE: True, PREF_USERNAME: username, } diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 6f1e3c80bf7..4bef2ac9ba3 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -24,6 +24,10 @@ } }, "issues": { + "deprecated_tts_platform_config": { + "title": "The Cloud text-to-speech platform configuration is deprecated", + "description": "The whole `platform: cloud` entry under the `tts:` section in configuration.yaml is deprecated and should be removed. You can use the UI to change settings for the Cloud text-to-speech platform. Please adjust your configuration.yaml and restart Home Assistant to fix this issue." + }, "deprecated_voice": { "title": "A deprecated voice was used", "fix_flow": { diff --git a/homeassistant/components/cloud/subscription.py b/homeassistant/components/cloud/subscription.py index 9a62f2d115c..63b57d2fa3d 100644 --- a/homeassistant/components/cloud/subscription.py +++ b/homeassistant/components/cloud/subscription.py @@ -19,7 +19,7 @@ async def async_subscription_info(cloud: Cloud[CloudClient]) -> dict[str, Any] | try: async with asyncio.timeout(REQUEST_TIMEOUT): return await cloud_api.async_subscription_info(cloud) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error( ( "A timeout of %s was reached while trying to fetch subscription" @@ -40,7 +40,7 @@ async def async_migrate_paypal_agreement( try: async with asyncio.timeout(REQUEST_TIMEOUT): return await cloud_api.async_migrate_paypal_agreement(cloud) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error( "A timeout of %s was reached while trying to start agreement migration", REQUEST_TIMEOUT, diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index ba34ac7a9b0..59ae5b22214 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -20,8 +20,9 @@ from homeassistant.components.tts import ( Voice, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_PLATFORM, Platform +from homeassistant.core import HomeAssistant, async_get_hass, 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.typing import ConfigType, DiscoveryInfoType @@ -39,6 +40,27 @@ SUPPORT_LANGUAGES = list(TTS_VOICES) _LOGGER = logging.getLogger(__name__) +def _deprecated_platform(value: str) -> str: + """Validate if platform is deprecated.""" + if value == DOMAIN: + _LOGGER.warning( + "The cloud tts platform configuration is deprecated, " + "please remove it from your configuration " + "and use the UI to change settings instead" + ) + hass = async_get_hass() + async_create_issue( + hass, + DOMAIN, + "deprecated_tts_platform_config", + breaks_in_ha_version="2024.9.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_tts_platform_config", + ) + return value + + def validate_lang(value: dict[str, Any]) -> dict[str, Any]: """Validate chosen gender or language.""" if (lang := value.get(CONF_LANG)) is None: @@ -58,6 +80,7 @@ def validate_lang(value: dict[str, Any]) -> dict[str, Any]: PLATFORM_SCHEMA = vol.All( TTS_PLATFORM_SCHEMA.extend( { + vol.Required(CONF_PLATFORM): vol.All(cv.string, _deprecated_platform), vol.Optional(CONF_LANG): str, vol.Optional(ATTR_GENDER): str, } diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index a678868ee18..a952f016671 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -8,6 +8,7 @@ from aioelectricitymaps import ( ElectricityMaps, ElectricityMapsError, ElectricityMapsInvalidTokenError, + ElectricityMapsNoDataError, ) import voluptuous as vol @@ -151,6 +152,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await fetch_latest_carbon_intensity(self.hass, em, data) except ElectricityMapsInvalidTokenError: errors["base"] = "invalid_auth" + except ElectricityMapsNoDataError: + errors["base"] = "no_data" except ElectricityMapsError: errors["base"] = "unknown" else: diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index a4cbed00684..ff6d5bdb18b 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aioelectricitymaps"], - "requirements": ["aioelectricitymaps==0.3.0"] + "requirements": ["aioelectricitymaps==0.4.0"] } diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json index 89289dd816d..7444cde73d7 100644 --- a/homeassistant/components/co2signal/strings.json +++ b/homeassistant/components/co2signal/strings.json @@ -28,12 +28,9 @@ "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "api_ratelimit": "API Ratelimit exceeded" + "no_data": "No data is available for the location you have selected." }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "unknown": "[%key:common::config_flow::error::unknown%]", - "api_ratelimit": "[%key:component::co2signal::config::error::api_ratelimit%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index 2cc3e206958..e6095c9f925 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -139,7 +139,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async with asyncio.timeout(10): response = await session.get(url) - except (asyncio.TimeoutError, aiohttp.ClientError) as err: + except (TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Failed to get ColorThief image due to HTTPError: %s", err) return None diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py index ef974b8f3ed..195bfa97b7d 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -123,7 +123,7 @@ class ComedHourlyPricingSensor(SensorEntity): else: self._attr_native_value = None - except (asyncio.TimeoutError, aiohttp.ClientError) as err: + except (TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Could not get data from ComEd API: %s", err) except (ValueError, KeyError): _LOGGER.warning("Could not update status for %s", self.name) diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py index 2c2d31514a5..5a879bc2d24 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -1,12 +1,11 @@ """Support for climates.""" from __future__ import annotations -import asyncio from enum import StrEnum from typing import Any from aiocomelit import ComelitSerialBridgeObject -from aiocomelit.const import CLIMATE, SLEEP_BETWEEN_CALLS +from aiocomelit.const import CLIMATE from homeassistant.components.climate import ( ClimateEntity, @@ -91,11 +90,16 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity _attr_hvac_modes = [HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF] _attr_max_temp = 30 _attr_min_temp = 5 - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_has_entity_name = True _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -186,7 +190,6 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity await self.coordinator.api.set_clima_status( self._device.index, ClimaAction.MANUAL ) - await asyncio.sleep(SLEEP_BETWEEN_CALLS) await self.coordinator.api.set_clima_status( self._device.index, ClimaAction.SET, target_temp ) @@ -198,7 +201,6 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity await self.coordinator.api.set_clima_status( self._device.index, ClimaAction.ON ) - await asyncio.sleep(SLEEP_BETWEEN_CALLS) await self.coordinator.api.set_clima_status( self._device.index, MODE_TO_ACTION[hvac_mode] ) diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 4ff75ba5307..fe23cb1f5d3 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -83,8 +83,8 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): return await self._async_update_system_data() except (exceptions.CannotConnect, exceptions.CannotRetrieveData) as err: raise UpdateFailed(repr(err)) from err - except exceptions.CannotAuthenticate: - raise ConfigEntryAuthFailed + except exceptions.CannotAuthenticate as err: + raise ConfigEntryAuthFailed from err @abstractmethod async def _async_update_system_data(self) -> dict[str, Any]: diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 7deb3d49624..a1743bff12d 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -6,7 +6,7 @@ from typing import Any from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import LIGHT, STATE_OFF, STATE_ON -from homeassistant.components.light import LightEntity +from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -34,8 +34,10 @@ async def async_setup_entry( class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): """Light device.""" + _attr_color_mode = ColorMode.ONOFF _attr_has_entity_name = True _attr_name = None + _attr_supported_color_modes = {ColorMode.ONOFF} def __init__( self, diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index f1b2cea9e73..bbbb4efe7d6 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.8.2"] + "requirements": ["aiocomelit==0.8.3"] } diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index dfa55b02c30..aa861a515fd 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -47,15 +47,14 @@ def websocket_list_devices( f'"success":true,"result": [' ).encode() # Concatenate cached entity registry item JSON serializations - msg_json = ( - msg_json_prefix - + b",".join( + inner = b",".join( + [ entry.json_repr for entry in registry.devices.values() if entry.json_repr is not None - ) - + b"]}" + ] ) + msg_json = b"".join((msg_json_prefix, inner, b"]}")) connection.send_message(msg_json) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index f1c1fadc144..11a4617adfa 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -45,15 +45,14 @@ def websocket_list_entities( '"success":true,"result": [' ).encode() # Concatenate cached entity registry item JSON serializations - msg_json = ( - msg_json_prefix - + b",".join( + inner = b",".join( + [ entry.partial_json_repr for entry in registry.entities.values() if entry.partial_json_repr is not None - ) - + b"]}" + ] ) + msg_json = b"".join((msg_json_prefix, inner, b"]}")) connection.send_message(msg_json) @@ -77,15 +76,14 @@ def websocket_list_entities_for_display( f'"result":{{"entity_categories":{_ENTITY_CATEGORIES_JSON},"entities":[' ).encode() # Concatenate cached entity registry item JSON serializations - msg_json = ( - msg_json_prefix - + b",".join( + inner = b",".join( + [ entry.display_json_repr for entry in registry.entities.values() if entry.disabled_by is None and entry.display_json_repr is not None - ) - + b"]}}" + ] ) + msg_json = b"".join((msg_json_prefix, inner, b"]}}")) connection.send_message(msg_json) diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 06dc62d114b..b93e586b7ca 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Control4 integration.""" from __future__ import annotations -from asyncio import TimeoutError as asyncioTimeoutError import logging from aiohttp.client_exceptions import ClientError @@ -82,7 +81,7 @@ class Control4Validator: ) await director.getAllItemInfo() return True - except (Unauthorized, ClientError, asyncioTimeoutError): + except (Unauthorized, ClientError, TimeoutError): _LOGGER.error("Failed to connect to the Control4 controller") return False diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index fb33d87e107..cd371ff0630 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -223,22 +223,22 @@ class DefaultAgent(AbstractConversationAgent): # Check if a trigger matched if isinstance(result, SentenceTriggerResult): # Gather callback responses in parallel - trigger_responses = await asyncio.gather( - *( - self._trigger_sentences[trigger_id].callback( - result.sentence, trigger_result - ) - for trigger_id, trigger_result in result.matched_triggers.items() + trigger_callbacks = [ + self._trigger_sentences[trigger_id].callback( + result.sentence, trigger_result ) - ) + for trigger_id, trigger_result in result.matched_triggers.items() + ] # Use last non-empty result as response. # # There may be multiple copies of a trigger running when editing in # the UI, so it's critical that we filter out empty responses here. response_text: str | None = None - for trigger_response in trigger_responses: - response_text = response_text or trigger_response + for trigger_future in asyncio.as_completed(trigger_callbacks): + if trigger_response := await trigger_future: + response_text = trigger_response + break # Convert to conversation result response = intent.IntentResponse(language=language) @@ -316,6 +316,20 @@ class DefaultAgent(AbstractConversationAgent): ), conversation_id, ) + except intent.DuplicateNamesMatchedError as duplicate_names_error: + # Intent was valid, but two or more entities with the same name matched. + ( + error_response_type, + error_response_args, + ) = _get_duplicate_names_matched_response(duplicate_names_error) + return _make_error_result( + language, + intent.IntentResponseErrorCode.NO_VALID_TARGETS, + self._get_error_text( + error_response_type, lang_intents, **error_response_args + ), + conversation_id, + ) except intent.IntentHandleError: # Intent was valid and entities matched constraints, but an error # occurred during handling. @@ -724,7 +738,12 @@ class DefaultAgent(AbstractConversationAgent): if async_should_expose(self.hass, DOMAIN, state.entity_id) ] - # Gather exposed entity names + # Gather exposed entity names. + # + # NOTE: We do not pass entity ids in here because multiple entities may + # have the same name. The intent matcher doesn't gather all matching + # values for a list, just the first. So we will need to match by name no + # matter what. entity_names = [] for state in states: # Checked against "requires_context" and "excludes_context" in hassil @@ -740,7 +759,7 @@ class DefaultAgent(AbstractConversationAgent): if not entity: # Default name - entity_names.append((state.name, state.entity_id, context)) + entity_names.append((state.name, state.name, context)) continue if entity.aliases: @@ -748,12 +767,15 @@ class DefaultAgent(AbstractConversationAgent): if not alias.strip(): continue - entity_names.append((alias, state.entity_id, context)) + entity_names.append((alias, alias, context)) # Default name - entity_names.append((state.name, state.entity_id, context)) + entity_names.append((state.name, state.name, context)) - # Expose all areas + # Expose all areas. + # + # We pass in area id here with the expectation that no two areas will + # share the same name or alias. areas = ar.async_get(self.hass) area_names = [] for area in areas.async_list_areas(): @@ -984,6 +1006,20 @@ def _get_no_states_matched_response( return ErrorKey.NO_INTENT, {} +def _get_duplicate_names_matched_response( + duplicate_names_error: intent.DuplicateNamesMatchedError, +) -> tuple[ErrorKey, dict[str, Any]]: + """Return key and template arguments for error when intent returns duplicate matches.""" + + if duplicate_names_error.area: + return ErrorKey.DUPLICATE_ENTITIES_IN_AREA, { + "entity": duplicate_names_error.name, + "area": duplicate_names_error.area, + } + + return ErrorKey.DUPLICATE_ENTITIES, {"entity": duplicate_names_error.name} + + def _collect_list_references(expression: Expression, list_names: set[str]) -> None: """Collect list reference names recursively.""" if isinstance(expression, Sequence): diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index de0a7029ac6..ecb604a14cc 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -54,6 +54,7 @@ class CoolmasterClimate(CoolmasterEntity, ClimateEntity): """Representation of a coolmaster climate device.""" _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator, unit_id, info, supported_modes): """Initialize the climate device.""" diff --git a/homeassistant/components/counter/icons.json b/homeassistant/components/counter/icons.json new file mode 100644 index 00000000000..1e0ef54bbb7 --- /dev/null +++ b/homeassistant/components/counter/icons.json @@ -0,0 +1,8 @@ +{ + "services": { + "decrement": "mdi:numeric-negative-1", + "increment": "mdi:numeric-positive-1", + "reset": "mdi:refresh", + "set_value": "mdi:counter" + } +} diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index e39fe97bc6c..b8e87d2b200 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -86,7 +86,7 @@ async def daikin_api_setup( device = await Appliance.factory( host, session, key=key, uuid=uuid, password=password ) - except asyncio.TimeoutError as err: + except TimeoutError as err: _LOGGER.debug("Connection to %s timed out", host) raise ConfigEntryNotReady from err except ClientConnectionError as err: diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index 047acd3cccf..c6bab19aa8a 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -128,6 +128,7 @@ class DaikinClimate(ClimateEntity): _attr_target_temperature_step = 1 _attr_fan_modes: list[str] _attr_swing_modes: list[str] + _enable_turn_on_off_backwards_compatibility = False def __init__(self, api: DaikinApi) -> None: """Initialize the climate device.""" diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index b79cc960fce..abd2d78c7fb 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -89,7 +89,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): uuid=uuid, password=password, ) - except (asyncio.TimeoutError, ClientError): + except (TimeoutError, ClientError): self.host = None return self.async_show_form( step_id="user", diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index d3ed3564344..fc52557fa5a 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["debugpy==1.8.0"] + "requirements": ["debugpy==1.8.1"] } diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index c0361aa2bca..99fa6412364 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -103,7 +103,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): async with asyncio.timeout(10): self.bridges = await deconz_discovery(session) - except (asyncio.TimeoutError, ResponseError): + except (TimeoutError, ResponseError): self.bridges = [] if LOGGER.isEnabledFor(logging.DEBUG): @@ -164,7 +164,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): except LinkButtonNotPressed: errors["base"] = "linking_not_possible" - except (ResponseError, RequestError, asyncio.TimeoutError): + except (ResponseError, RequestError, TimeoutError): errors["base"] = "no_key" else: @@ -193,7 +193,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): } ) - except asyncio.TimeoutError: + except TimeoutError: return self.async_abort(reason="no_bridges") return self.async_create_entry( diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 156309c0903..a982d110f1f 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -360,6 +360,6 @@ async def get_deconz_session( LOGGER.warning("Invalid key for deCONZ at %s", config[CONF_HOST]) raise AuthenticationRequired from err - except (asyncio.TimeoutError, errors.RequestError, errors.ResponseError) as err: + except (TimeoutError, errors.RequestError, errors.ResponseError) as err: LOGGER.error("Error connecting to deCONZ gateway at %s", config[CONF_HOST]) raise CannotConnect from err diff --git a/homeassistant/components/deluge/__init__.py b/homeassistant/components/deluge/__init__.py index 63412242dd0..40f4d772670 100644 --- a/homeassistant/components/deluge/__init__.py +++ b/homeassistant/components/deluge/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -import socket from ssl import SSLError from deluge_client.client import DelugeRPCClient @@ -40,11 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api.web_port = entry.data[CONF_WEB_PORT] try: await hass.async_add_executor_job(api.connect) - except ( - ConnectionRefusedError, - socket.timeout, - SSLError, - ) as ex: + except (ConnectionRefusedError, TimeoutError, SSLError) as ex: raise ConfigEntryNotReady("Connection to Deluge Daemon failed") from ex except Exception as ex: # pylint:disable=broad-except if type(ex).__name__ == "BadLoginError": diff --git a/homeassistant/components/deluge/config_flow.py b/homeassistant/components/deluge/config_flow.py index 5de61350039..db2598e1f67 100644 --- a/homeassistant/components/deluge/config_flow.py +++ b/homeassistant/components/deluge/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Mapping -import socket from ssl import SSLError from typing import Any @@ -91,11 +90,7 @@ class DelugeFlowHandler(ConfigFlow, domain=DOMAIN): ) try: await self.hass.async_add_executor_job(api.connect) - except ( - ConnectionRefusedError, - socket.timeout, - SSLError, - ): + except (ConnectionRefusedError, TimeoutError, SSLError): return "cannot_connect" except Exception as ex: # pylint:disable=broad-except if type(ex).__name__ == "BadLoginError": diff --git a/homeassistant/components/deluge/coordinator.py b/homeassistant/components/deluge/coordinator.py index 9b0d5907b1a..7a3e840ff95 100644 --- a/homeassistant/components/deluge/coordinator.py +++ b/homeassistant/components/deluge/coordinator.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import timedelta -import socket from ssl import SSLError from typing import Any @@ -52,7 +51,7 @@ class DelugeDataUpdateCoordinator( ) except ( ConnectionRefusedError, - socket.timeout, + TimeoutError, SSLError, FailedToReconnectException, ) as ex: diff --git a/homeassistant/components/deluge/manifest.json b/homeassistant/components/deluge/manifest.json index 4b59f72219c..ffcf0e0dd4b 100644 --- a/homeassistant/components/deluge/manifest.json +++ b/homeassistant/components/deluge/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["deluge_client"], - "requirements": ["deluge-client==1.7.1"] + "requirements": ["deluge-client==1.10.0"] } diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index 73cae4a64b1..644c4cb7860 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -90,6 +90,7 @@ class BaseDemoFan(FanEntity): """A demonstration fan component that uses legacy fan speeds.""" _attr_should_poll = False + _attr_translation_key = "demo" def __init__( self, diff --git a/homeassistant/components/demo/icons.json b/homeassistant/components/demo/icons.json index 79c18bc0a2e..9c746c633d4 100644 --- a/homeassistant/components/demo/icons.json +++ b/homeassistant/components/demo/icons.json @@ -23,6 +23,21 @@ } } }, + "fan": { + "demo": { + "state_attributes": { + "preset_mode": { + "default": "mdi:circle-medium", + "state": { + "auto": "mdi:fan-auto", + "sleep": "mdi:bed", + "smart": "mdi:brain", + "on": "mdi:power" + } + } + } + } + }, "number": { "volume": { "default": "mdi:volume-high" diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index 555760a5af9..aa5554e9fcc 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -46,6 +46,20 @@ } } }, + "fan": { + "demo": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", + "sleep": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]", + "smart": "Smart", + "on": "[%key:common::state::on%]" + } + } + } + } + }, "event": { "push": { "state_attributes": { diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index 0ba8caed6c5..9188009bde5 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/denonavr", "iot_class": "local_push", "loggers": ["denonavr"], - "requirements": ["denonavr==0.11.4"], + "requirements": ["denonavr==0.11.6"], "ssdp": [ { "manufacturer": "Denon", diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 125fec7caaa..f5050af357d 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -21,6 +21,7 @@ from denonavr.exceptions import ( AvrCommandError, AvrForbiddenError, AvrNetworkError, + AvrProcessingError, AvrTimoutError, DenonAvrError, ) @@ -201,6 +202,16 @@ def async_log_errors( self._receiver.host, ) self._attr_available = False + except AvrProcessingError: + available = True + if self.available: + _LOGGER.warning( + ( + "Update of Denon AVR receiver at host %s not complete. " + "Device is still available" + ), + self._receiver.host, + ) except AvrForbiddenError: available = False if self.available: @@ -274,8 +285,6 @@ class DenonDevice(MediaPlayerEntity): and MediaPlayerEntityFeature.SELECT_SOUND_MODE ) - self._telnet_was_healthy: bool | None = None - async def _telnet_callback(self, zone: str, event: str, parameter: str) -> None: """Process a telnet command callback.""" # There are multiple checks implemented which reduce unnecessary updates of the ha state machine @@ -306,24 +315,13 @@ class DenonDevice(MediaPlayerEntity): """Get the latest status information from device.""" receiver = self._receiver - # We can only skip the update if telnet was healthy after - # the last update and is still healthy now to ensure that - # we don't miss any state changes while telnet is down - # or reconnecting. - if ( - telnet_is_healthy := receiver.telnet_connected and receiver.telnet_healthy - ) and self._telnet_was_healthy: + # We skip the update if telnet is healthy. + # When telnet recovers it automatically updates all properties. + if receiver.telnet_connected and receiver.telnet_healthy: return - # if async_update raises an exception, we don't want to skip the next update - # so we set _telnet_was_healthy to None here and only set it to the value - # before the update if the update was successful - self._telnet_was_healthy = None - await receiver.async_update() - self._telnet_was_healthy = telnet_is_healthy - if self._update_audyssey: await receiver.async_update_audyssey() diff --git a/homeassistant/components/derivative/icons.json b/homeassistant/components/derivative/icons.json new file mode 100644 index 00000000000..d8f2a961c3a --- /dev/null +++ b/homeassistant/components/derivative/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "derivative": { + "default": "mdi:chart-line" + } + } + } +} diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 73d297d7541..cd912ceb24e 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -64,8 +64,6 @@ UNIT_TIME = { UnitOfTime.DAYS: 24 * 60 * 60, } -ICON = "mdi:chart-line" - DEFAULT_ROUND = 3 DEFAULT_TIME_WINDOW = 0 @@ -157,9 +155,9 @@ async def async_setup_platform( class DerivativeSensor(RestoreSensor, SensorEntity): - """Representation of an derivative sensor.""" + """Representation of a derivative sensor.""" - _attr_icon = ICON + _attr_translation_key = "derivative" _attr_should_poll = False def __init__( diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 68d05c19f67..2bf87343c72 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -247,9 +247,9 @@ async def async_get_device_automations( match_device_ids = set(device_ids or device_registry.devices) combined_results: dict[str, list[dict[str, Any]]] = {} - for entry in entity_registry.entities.values(): - if not entry.disabled_by and entry.device_id in match_device_ids: - device_entities_domains.setdefault(entry.device_id, set()).add(entry.domain) + for device_id in match_device_ids: + for entry in entity_registry.entities.get_entries_for_device_id(device_id): + device_entities_domains.setdefault(device_id, set()).add(entry.domain) for device_id in match_device_ids: combined_results[device_id] = [] diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index e27d5a315a5..9f17a653673 100644 --- a/homeassistant/components/devolo_home_control/climate.py +++ b/homeassistant/components/devolo_home_control/climate.py @@ -56,6 +56,7 @@ class DevoloClimateDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, ClimateEntit _attr_precision = PRECISION_TENTHS _attr_hvac_mode = HVACMode.HEAT _attr_hvac_modes = [HVACMode.HEAT] + _enable_turn_on_off_backwards_compatibility = False def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index b8a12a937e3..ebd0629950e 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -3,18 +3,17 @@ from __future__ import annotations from abc import ABC, abstractmethod import asyncio -from collections.abc import Callable, Iterable -import contextlib +from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta from fnmatch import translate from functools import lru_cache +import itertools import logging -import os import re -import threading -from typing import TYPE_CHECKING, Any, Final, cast +from typing import Any, Final +import aiodhcpwatcher from aiodiscover import DiscoverHosts from aiodiscover.discovery import ( HOSTNAME as DISCOVERY_HOSTNAME, @@ -22,8 +21,6 @@ from aiodiscover.discovery import ( MAC_ADDRESS as DISCOVERY_MAC_ADDRESS, ) from cached_ipaddress import cached_ip_addresses -from scapy.config import conf -from scapy.error import Scapy_Exception from homeassistant import config_entries from homeassistant.components.device_tracker import ( @@ -60,20 +57,13 @@ from homeassistant.loader import DHCPMatcher, async_get_dhcp from .const import DOMAIN -if TYPE_CHECKING: - from scapy.packet import Packet - from scapy.sendrecv import AsyncSniffer - CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) FILTER = "udp and (port 67 or 68)" -REQUESTED_ADDR = "requested_addr" -MESSAGE_TYPE = "message-type" HOSTNAME: Final = "hostname" MAC_ADDRESS: Final = "macaddress" IP_ADDRESS: Final = "ip" REGISTERED_DEVICES: Final = "registered_devices" -DHCP_REQUEST = 3 SCAN_INTERVAL = timedelta(minutes=60) @@ -89,32 +79,79 @@ class DhcpServiceInfo(BaseServiceInfo): macaddress: str +@dataclass(slots=True) +class DhcpMatchers: + """Prepared info from dhcp entries.""" + + registered_devices_domains: set[str] + no_oui_matchers: dict[str, list[DHCPMatcher]] + oui_matchers: dict[str, list[DHCPMatcher]] + + +def async_index_integration_matchers( + integration_matchers: list[DHCPMatcher], +) -> DhcpMatchers: + """Index the integration matchers. + + We have three types of matchers: + + 1. Registered devices + 2. Devices with no OUI - index by first char of lower() hostname + 3. Devices with OUI - index by OUI + """ + registered_devices_domains: set[str] = set() + no_oui_matchers: dict[str, list[DHCPMatcher]] = {} + oui_matchers: dict[str, list[DHCPMatcher]] = {} + for matcher in integration_matchers: + domain = matcher["domain"] + if REGISTERED_DEVICES in matcher: + registered_devices_domains.add(domain) + continue + + if mac_address := matcher.get(MAC_ADDRESS): + oui_matchers.setdefault(mac_address[:6], []).append(matcher) + continue + + if hostname := matcher.get(HOSTNAME): + first_char = hostname[0].lower() + no_oui_matchers.setdefault(first_char, []).append(matcher) + + return DhcpMatchers( + registered_devices_domains=registered_devices_domains, + no_oui_matchers=no_oui_matchers, + oui_matchers=oui_matchers, + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the dhcp component.""" watchers: list[WatcherBase] = [] address_data: dict[str, dict[str, str]] = {} - integration_matchers = await async_get_dhcp(hass) + integration_matchers = async_index_integration_matchers(await async_get_dhcp(hass)) # For the passive classes we need to start listening # for state changes and connect the dispatchers before # everything else starts up or we will miss events for passive_cls in (DeviceTrackerRegisteredWatcher, DeviceTrackerWatcher): passive_watcher = passive_cls(hass, address_data, integration_matchers) - await passive_watcher.async_start() + passive_watcher.async_start() watchers.append(passive_watcher) - async def _initialize(event: Event) -> None: + async def _async_initialize(event: Event) -> None: + await aiodhcpwatcher.async_init() + for active_cls in (DHCPWatcher, NetworkWatcher): active_watcher = active_cls(hass, address_data, integration_matchers) - await active_watcher.async_start() + active_watcher.async_start() watchers.append(active_watcher) - async def _async_stop(event: Event) -> None: + @callback + def _async_stop(event: Event) -> None: for watcher in watchers: - await watcher.async_stop() + watcher.async_stop() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _initialize) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_initialize) return True @@ -125,7 +162,7 @@ class WatcherBase(ABC): self, hass: HomeAssistant, address_data: dict[str, dict[str, str]], - integration_matchers: list[DHCPMatcher], + integration_matchers: DhcpMatchers, ) -> None: """Initialize class.""" super().__init__() @@ -133,24 +170,23 @@ class WatcherBase(ABC): self.hass = hass self._integration_matchers = integration_matchers self._address_data = address_data + self._unsub: Callable[[], None] | None = None + + @callback + def async_stop(self) -> None: + """Stop scanning for new devices on the network.""" + if self._unsub: + self._unsub() + self._unsub = None @abstractmethod - async def async_stop(self) -> None: - """Stop the watcher.""" - - @abstractmethod - async def async_start(self) -> None: + @callback + def async_start(self) -> None: """Start the watcher.""" - def process_client(self, ip_address: str, hostname: str, mac_address: str) -> None: - """Process a client.""" - self.hass.loop.call_soon_threadsafe( - self.async_process_client, ip_address, hostname, mac_address - ) - @callback def async_process_client( - self, ip_address: str, hostname: str, mac_address: str + self, ip_address: str, hostname: str, unformatted_mac_address: str ) -> None: """Process a client.""" if (made_ip_address := cached_ip_addresses(ip_address)) is None: @@ -166,6 +202,12 @@ class WatcherBase(ABC): # Ignore self assigned addresses, loopback, invalid return + formatted_mac = format_mac(unformatted_mac_address) + # Historically, the MAC address was formatted without colons + # and since all consumers of this data are expecting it to be + # formatted without colons we will continue to do so + mac_address = formatted_mac.replace(":", "") + data = self._address_data.get(ip_address) if ( data @@ -189,28 +231,29 @@ class WatcherBase(ABC): lowercase_hostname, ) - matched_domains = set() - device_domains = set() + matched_domains: set[str] = set() + matchers = self._integration_matchers + registered_devices_domains = matchers.registered_devices_domains dev_reg: DeviceRegistry = async_get(self.hass) if device := dev_reg.async_get_device( - connections={(CONNECTION_NETWORK_MAC, uppercase_mac)} + connections={(CONNECTION_NETWORK_MAC, formatted_mac)} ): for entry_id in device.config_entries: - if entry := self.hass.config_entries.async_get_entry(entry_id): - device_domains.add(entry.domain) + if ( + entry := self.hass.config_entries.async_get_entry(entry_id) + ) and entry.domain in registered_devices_domains: + matched_domains.add(entry.domain) - for matcher in self._integration_matchers: + oui = uppercase_mac[:6] + lowercase_hostname_first_char = ( + lowercase_hostname[0] if len(lowercase_hostname) else "" + ) + for matcher in itertools.chain( + matchers.no_oui_matchers.get(lowercase_hostname_first_char, ()), + matchers.oui_matchers.get(oui, ()), + ): domain = matcher["domain"] - - if matcher.get(REGISTERED_DEVICES) and domain not in device_domains: - continue - - if ( - matcher_mac := matcher.get(MAC_ADDRESS) - ) is not None and not _memorized_fnmatch(uppercase_mac, matcher_mac): - continue - if ( matcher_hostname := matcher.get(HOSTNAME) ) is not None and not _memorized_fnmatch( @@ -241,24 +284,23 @@ class NetworkWatcher(WatcherBase): self, hass: HomeAssistant, address_data: dict[str, dict[str, str]], - integration_matchers: list[DHCPMatcher], + integration_matchers: DhcpMatchers, ) -> None: """Initialize class.""" super().__init__(hass, address_data, integration_matchers) - self._unsub: Callable[[], None] | None = None self._discover_hosts: DiscoverHosts | None = None self._discover_task: asyncio.Task | None = None - async def async_stop(self) -> None: + @callback + def async_stop(self) -> None: """Stop scanning for new devices on the network.""" - if self._unsub: - self._unsub() - self._unsub = None + super().async_stop() if self._discover_task: self._discover_task.cancel() self._discover_task = None - async def async_start(self) -> None: + @callback + def async_start(self) -> None: """Start scanning for new devices on the network.""" self._discover_hosts = DiscoverHosts() self._unsub = async_track_time_interval( @@ -283,30 +325,15 @@ class NetworkWatcher(WatcherBase): self.async_process_client( host[DISCOVERY_IP_ADDRESS], host[DISCOVERY_HOSTNAME], - _format_mac(host[DISCOVERY_MAC_ADDRESS]), + host[DISCOVERY_MAC_ADDRESS], ) class DeviceTrackerWatcher(WatcherBase): """Class to watch dhcp data from routers.""" - def __init__( - self, - hass: HomeAssistant, - address_data: dict[str, dict[str, str]], - integration_matchers: list[DHCPMatcher], - ) -> None: - """Initialize class.""" - super().__init__(hass, address_data, integration_matchers) - self._unsub: Callable[[], None] | None = None - - async def async_stop(self) -> None: - """Stop watching for new device trackers.""" - if self._unsub: - self._unsub() - self._unsub = None - - async def async_start(self) -> None: + @callback + def async_start(self) -> None: """Stop watching for new device trackers.""" self._unsub = async_track_state_added_domain( self.hass, [DEVICE_TRACKER_DOMAIN], self._async_process_device_event @@ -339,29 +366,14 @@ class DeviceTrackerWatcher(WatcherBase): if ip_address is None or mac_address is None: return - self.async_process_client(ip_address, hostname, _format_mac(mac_address)) + self.async_process_client(ip_address, hostname, mac_address) class DeviceTrackerRegisteredWatcher(WatcherBase): """Class to watch data from device tracker registrations.""" - def __init__( - self, - hass: HomeAssistant, - address_data: dict[str, dict[str, str]], - integration_matchers: list[DHCPMatcher], - ) -> None: - """Initialize class.""" - super().__init__(hass, address_data, integration_matchers) - self._unsub: Callable[[], None] | None = None - - async def async_stop(self) -> None: - """Stop watching for device tracker registrations.""" - if self._unsub: - self._unsub() - self._unsub = None - - async def async_start(self) -> None: + @callback + def async_start(self) -> None: """Stop watching for device tracker registrations.""" self._unsub = async_dispatcher_connect( self.hass, CONNECTED_DEVICE_REGISTERED, self._async_process_device_data @@ -377,152 +389,23 @@ class DeviceTrackerRegisteredWatcher(WatcherBase): if ip_address is None or mac_address is None: return - self.async_process_client(ip_address, hostname, _format_mac(mac_address)) + self.async_process_client(ip_address, hostname, mac_address) class DHCPWatcher(WatcherBase): """Class to watch dhcp requests.""" - def __init__( - self, - hass: HomeAssistant, - address_data: dict[str, dict[str, str]], - integration_matchers: list[DHCPMatcher], - ) -> None: - """Initialize class.""" - super().__init__(hass, address_data, integration_matchers) - self._sniffer: AsyncSniffer | None = None - self._started = threading.Event() - - async def async_stop(self) -> None: - """Stop watching for new device trackers.""" - await self.hass.async_add_executor_job(self._stop) - - def _stop(self) -> None: - """Stop the thread.""" - if self._started.is_set(): - assert self._sniffer is not None - self._sniffer.stop() - - async def async_start(self) -> None: - """Start watching for dhcp packets.""" - await self.hass.async_add_executor_job(self._start) - - def _start(self) -> None: - """Start watching for dhcp packets.""" - # Local import because importing from scapy has side effects such as opening - # sockets - from scapy import arch # pylint: disable=import-outside-toplevel # noqa: F401 - from scapy.layers.dhcp import DHCP # pylint: disable=import-outside-toplevel - from scapy.layers.inet import IP # pylint: disable=import-outside-toplevel - from scapy.layers.l2 import Ether # pylint: disable=import-outside-toplevel - - # - # Importing scapy.sendrecv will cause a scapy resync which will - # import scapy.arch.read_routes which will import scapy.sendrecv - # - # We avoid this circular import by importing arch above to ensure - # the module is loaded and avoid the problem - # - from scapy.sendrecv import ( # pylint: disable=import-outside-toplevel - AsyncSniffer, + @callback + def _async_process_dhcp_request(self, response: aiodhcpwatcher.DHCPRequest) -> None: + """Process a dhcp request.""" + self.async_process_client( + response.ip_address, response.hostname, response.mac_address ) - def _handle_dhcp_packet(packet: Packet) -> None: - """Process a dhcp packet.""" - if DHCP not in packet: - return - - options_dict = _dhcp_options_as_dict(packet[DHCP].options) - if options_dict.get(MESSAGE_TYPE) != DHCP_REQUEST: - # Not a DHCP request - return - - ip_address = options_dict.get(REQUESTED_ADDR) or cast(str, packet[IP].src) - assert isinstance(ip_address, str) - hostname = "" - if (hostname_bytes := options_dict.get(HOSTNAME)) and isinstance( - hostname_bytes, bytes - ): - with contextlib.suppress(AttributeError, UnicodeDecodeError): - hostname = hostname_bytes.decode() - mac_address = _format_mac(cast(str, packet[Ether].src)) - - if ip_address is not None and mac_address is not None: - self.process_client(ip_address, hostname, mac_address) - - # disable scapy promiscuous mode as we do not need it - conf.sniff_promisc = 0 - - try: - _verify_l2socket_setup(FILTER) - except (Scapy_Exception, OSError) as ex: - if os.geteuid() == 0: - _LOGGER.error("Cannot watch for dhcp packets: %s", ex) - else: - _LOGGER.debug( - "Cannot watch for dhcp packets without root or CAP_NET_RAW: %s", ex - ) - return - - try: - _verify_working_pcap(FILTER) - except (Scapy_Exception, ImportError) as ex: - _LOGGER.error( - "Cannot watch for dhcp packets without a functional packet filter: %s", - ex, - ) - return - - self._sniffer = AsyncSniffer( - filter=FILTER, - started_callback=self._started.set, - prn=_handle_dhcp_packet, - store=0, - ) - - self._sniffer.start() - if self._sniffer.thread: - self._sniffer.thread.name = self.__class__.__name__ - - -def _dhcp_options_as_dict( - dhcp_options: Iterable[tuple[str, int | bytes | None]], -) -> dict[str, str | int | bytes | None]: - """Extract data from packet options as a dict.""" - return {option[0]: option[1] for option in dhcp_options if len(option) >= 2} - - -def _format_mac(mac_address: str) -> str: - """Format a mac address for matching.""" - return format_mac(mac_address).replace(":", "") - - -def _verify_l2socket_setup(cap_filter: str) -> None: - """Create a socket using the scapy configured l2socket. - - Try to create the socket - to see if we have permissions - since AsyncSniffer will do it another - thread so we will not be able to capture - any permission or bind errors. - """ - conf.L2socket(filter=cap_filter) - - -def _verify_working_pcap(cap_filter: str) -> None: - """Verify we can create a packet filter. - - If we cannot create a filter we will be listening for - all traffic which is too intensive. - """ - # Local import because importing from scapy has side effects such as opening - # sockets - from scapy.arch.common import ( # pylint: disable=import-outside-toplevel - compile_filter, - ) - - compile_filter(cap_filter) + @callback + def async_start(self) -> None: + """Start watching for dhcp packets.""" + self._unsub = aiodhcpwatcher.start(self._async_process_dhcp_request) @lru_cache(maxsize=4096, typed=True) diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index f190f0ab10e..142aab52cc8 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -5,11 +5,17 @@ "documentation": "https://www.home-assistant.io/integrations/dhcp", "integration_type": "system", "iot_class": "local_push", - "loggers": ["aiodiscover", "dnspython", "pyroute2", "scapy"], + "loggers": [ + "aiodiscover", + "aiodhcpwatcher", + "dnspython", + "pyroute2", + "scapy" + ], "quality_scale": "internal", "requirements": [ - "scapy==2.5.0", - "aiodiscover==1.6.0", + "aiodhcpwatcher==0.8.0", + "aiodiscover==1.6.1", "cached_ipaddress==0.3.0" ] } diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index 1b53ba83cee..5f1ba2a13ef 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_push", "loggers": ["discord"], - "requirements": ["nextcord==2.0.0a8"] + "requirements": ["nextcord==2.6.0"] } diff --git a/homeassistant/components/discord/notify.py b/homeassistant/components/discord/notify.py index 43301290490..ff83d97f8c2 100644 --- a/homeassistant/components/discord/notify.py +++ b/homeassistant/components/discord/notify.py @@ -129,10 +129,10 @@ class DiscordNotificationService(BaseNotificationService): embeds: list[nextcord.Embed] = [] if ATTR_EMBED in data: embedding = data[ATTR_EMBED] - title = embedding.get(ATTR_EMBED_TITLE) or nextcord.Embed.Empty - description = embedding.get(ATTR_EMBED_DESCRIPTION) or nextcord.Embed.Empty - color = embedding.get(ATTR_EMBED_COLOR) or nextcord.Embed.Empty - url = embedding.get(ATTR_EMBED_URL) or nextcord.Embed.Empty + title = embedding.get(ATTR_EMBED_TITLE) + description = embedding.get(ATTR_EMBED_DESCRIPTION) + color = embedding.get(ATTR_EMBED_COLOR) + url = embedding.get(ATTR_EMBED_URL) fields = embedding.get(ATTR_EMBED_FIELDS) or [] if embedding: diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index ab5d035dd54..128822cf289 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.38.1", "getmac==0.9.4"], + "requirements": ["async-upnp-client==0.38.2", "getmac==0.9.4"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 749f2c887eb..c8c70486854 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -19,6 +19,7 @@ from homeassistant import config_entries from homeassistant.components import media_source, ssdp from homeassistant.components.media_player import ( ATTR_MEDIA_EXTRA, + DOMAIN as MEDIA_PLAYER_DOMAIN, BrowseMedia, MediaPlayerEntity, MediaPlayerEntityFeature, @@ -28,7 +29,7 @@ from homeassistant.components.media_player import ( async_process_play_media_url, ) from homeassistant.const import CONF_DEVICE_ID, CONF_MAC, CONF_TYPE, CONF_URL -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -37,6 +38,7 @@ from .const import ( CONF_CALLBACK_URL_OVERRIDE, CONF_LISTEN_PORT, CONF_POLL_AVAILABILITY, + DOMAIN, LOGGER as _LOGGER, MEDIA_METADATA_DIDL, MEDIA_TYPE_MAP, @@ -87,9 +89,32 @@ async def async_setup_entry( """Set up the DlnaDmrEntity from a config entry.""" _LOGGER.debug("media_player.async_setup_entry %s (%s)", entry.entry_id, entry.title) + udn = entry.data[CONF_DEVICE_ID] + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + + if ( + ( + existing_entity_id := ent_reg.async_get_entity_id( + domain=MEDIA_PLAYER_DOMAIN, platform=DOMAIN, unique_id=udn + ) + ) + and (existing_entry := ent_reg.async_get(existing_entity_id)) + and (device_id := existing_entry.device_id) + and (device_entry := dev_reg.async_get(device_id)) + and (dr.CONNECTION_UPNP, udn) not in device_entry.connections + ): + # If the existing device is missing the udn connection, add it + # now to ensure that when the entity gets added it is linked to + # the correct device. + dev_reg.async_update_device( + device_id, + merge_connections={(dr.CONNECTION_UPNP, udn)}, + ) + # Create our own device-wrapping entity entity = DlnaDmrEntity( - udn=entry.data[CONF_DEVICE_ID], + udn=udn, device_type=entry.data[CONF_TYPE], name=entry.title, event_port=entry.options.get(CONF_LISTEN_PORT) or 0, @@ -98,6 +123,7 @@ async def async_setup_entry( location=entry.data[CONF_URL], mac_address=entry.data.get(CONF_MAC), browse_unfiltered=entry.options.get(CONF_BROWSE_UNFILTERED, False), + config_entry=entry, ) async_add_entities([entity]) @@ -143,6 +169,7 @@ class DlnaDmrEntity(MediaPlayerEntity): location: str, mac_address: str | None, browse_unfiltered: bool, + config_entry: config_entries.ConfigEntry, ) -> None: """Initialize DLNA DMR entity.""" self.udn = udn @@ -154,25 +181,17 @@ class DlnaDmrEntity(MediaPlayerEntity): self.mac_address = mac_address self.browse_unfiltered = browse_unfiltered self._device_lock = asyncio.Lock() + self._background_setup_task: asyncio.Task[None] | None = None + self._updated_registry: bool = False + self._config_entry = config_entry + self._attr_device_info = dr.DeviceInfo(connections={(dr.CONNECTION_UPNP, udn)}) async def async_added_to_hass(self) -> None: """Handle addition.""" # Update this entity when the associated config entry is modified - if self.registry_entry and self.registry_entry.config_entry_id: - config_entry = self.hass.config_entries.async_get_entry( - self.registry_entry.config_entry_id - ) - assert config_entry is not None - self.async_on_remove( - config_entry.add_update_listener(self.async_config_update_listener) - ) - - # Try to connect to the last known location, but don't worry if not available - if not self._device: - try: - await self._device_connect(self.location) - except UpnpError as err: - _LOGGER.debug("Couldn't connect immediately: %r", err) + self.async_on_remove( + self._config_entry.add_update_listener(self.async_config_update_listener) + ) # Get SSDP notifications for only this device self.async_on_remove( @@ -193,8 +212,29 @@ class DlnaDmrEntity(MediaPlayerEntity): ) ) + if not self._device: + if self.hass.state is CoreState.running: + await self._async_setup() + else: + self._background_setup_task = self.hass.async_create_background_task( + self._async_setup(), f"dlna_dmr {self.name} setup" + ) + + async def _async_setup(self) -> None: + # Try to connect to the last known location, but don't worry if not available + try: + await self._device_connect(self.location) + except UpnpError as err: + _LOGGER.debug("Couldn't connect immediately: %r", err) + async def async_will_remove_from_hass(self) -> None: """Handle removal.""" + if self._background_setup_task: + self._background_setup_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._background_setup_task + self._background_setup_task = None + await self._device_disconnect() async def async_ssdp_callback( @@ -351,25 +391,28 @@ class DlnaDmrEntity(MediaPlayerEntity): def _update_device_registry(self, set_mac: bool = False) -> None: """Update the device registry with new information about the DMR.""" - if not self._device: - return # Can't get all the required information without a connection + if ( + # Can't get all the required information without a connection + not self._device + or + # No new information + (not set_mac and self._updated_registry) + ): + return - if not self.registry_entry or not self.registry_entry.config_entry_id: - return # No config registry entry to link to - - if self.registry_entry.device_id and not set_mac: - return # No new information - - connections = set() # Connections based on the root device's UDN, and the DMR embedded # device's UDN. They may be the same, if the DMR is the root device. - connections.add( + connections = { ( dr.CONNECTION_UPNP, self._device.profile_device.root_device.udn, - ) - ) - connections.add((dr.CONNECTION_UPNP, self._device.udn)) + ), + (dr.CONNECTION_UPNP, self._device.udn), + ( + dr.CONNECTION_UPNP, + self.udn, + ), + } if self.mac_address: # Connection based on MAC address, if known @@ -378,23 +421,27 @@ class DlnaDmrEntity(MediaPlayerEntity): (dr.CONNECTION_NETWORK_MAC, self.mac_address) ) - # Create linked HA DeviceEntry now the information is known. - dev_reg = dr.async_get(self.hass) - device_entry = dev_reg.async_get_or_create( - config_entry_id=self.registry_entry.config_entry_id, + device_info = dr.DeviceInfo( connections=connections, default_manufacturer=self._device.manufacturer, default_model=self._device.model_name, default_name=self._device.name, ) + self._attr_device_info = device_info + + self._updated_registry = True + # Create linked HA DeviceEntry now the information is known. + device_entry = dr.async_get(self.hass).async_get_or_create( + config_entry_id=self._config_entry.entry_id, **device_info + ) # Update entity registry to link to the device - ent_reg = er.async_get(self.hass) - ent_reg.async_get_or_create( - self.registry_entry.domain, - self.registry_entry.platform, + er.async_get(self.hass).async_get_or_create( + MEDIA_PLAYER_DOMAIN, + DOMAIN, self.unique_id, device_id=device_entry.id, + config_entry=self._config_entry, ) async def _device_disconnect(self) -> None: @@ -419,6 +466,10 @@ class DlnaDmrEntity(MediaPlayerEntity): async def async_update(self) -> None: """Retrieve the latest data.""" + if self._background_setup_task: + await self._background_setup_task + self._background_setup_task = None + if not self._device: if not self.poll_availability: return diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index d4a74725467..aaa6e1ee7de 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["async-upnp-client==0.38.1"], + "requirements": ["async-upnp-client==0.38.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index a4133f2da2c..3da47eb572a 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -108,7 +108,7 @@ class DoorBirdCamera(DoorBirdEntity, Camera): self._last_image = await response.read() self._last_update = now return self._last_image - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("DoorBird %s: Camera image timed out", self.name) return self._last_image except aiohttp.ClientError as error: diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 376b4d100fc..a38326c1346 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -123,7 +123,7 @@ class DSMRConnection: try: async with asyncio.timeout(30): await protocol.wait_closed() - except asyncio.TimeoutError: + except TimeoutError: # Timeout (no data received), close transport and return True (if telegram is empty, will result in CannotCommunicate error) transport.close() await protocol.wait_closed() diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py index 22be40a812e..3df80721af4 100644 --- a/homeassistant/components/duotecno/climate.py +++ b/homeassistant/components/duotecno/climate.py @@ -47,12 +47,16 @@ class DuotecnoClimate(DuotecnoEntity, ClimateEntity): _unit: SensUnit _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = list(HVACMODE_REVERSE) _attr_preset_modes = list(PRESETMODES) _attr_translation_key = "duotecno" + _enable_turn_on_off_backwards_compatibility = False @property def current_temperature(self) -> float | None: diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 6f57ea6ed5f..4dcce0fd705 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==2.1.0"] + "requirements": ["easyenergy==2.1.1"] } diff --git a/homeassistant/components/ebusd/__init__.py b/homeassistant/components/ebusd/__init__.py index ab83759bb2d..b1eb03989ea 100644 --- a/homeassistant/components/ebusd/__init__.py +++ b/homeassistant/components/ebusd/__init__.py @@ -1,6 +1,5 @@ """Support for Ebusd daemon for communication with eBUS heating systems.""" import logging -import socket import ebusdpy import voluptuous as vol @@ -80,7 +79,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.debug("Ebusd integration setup completed") return True - except (socket.timeout, OSError): + except (TimeoutError, OSError): return False diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index e15a8e1d3d8..58a3cb09997 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -323,6 +323,7 @@ class Thermostat(ClimateEntity): _attr_fan_modes = [FAN_AUTO, FAN_ON] _attr_name = None _attr_has_entity_name = True + _enable_turn_on_off_backwards_compatibility = False def __init__( self, data: EcobeeData, thermostat_index: int, thermostat: dict @@ -375,6 +376,10 @@ class Thermostat(ClimateEntity): supported = supported | ClimateEntityFeature.TARGET_HUMIDITY if self.has_aux_heat: supported = supported | ClimateEntityFeature.AUX_HEAT + if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes: + supported = ( + supported | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) return supported @property diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index f5328da4776..ac812a07566 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -66,6 +66,7 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): _attr_should_poll = True _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _enable_turn_on_off_backwards_compatibility = False def __init__(self, thermostat): """Initialize.""" @@ -79,12 +80,13 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): ha_mode = ECONET_STATE_TO_HA[mode] self._attr_hvac_modes.append(ha_mode) - @property - def supported_features(self) -> ClimateEntityFeature: - """Return the list of supported features.""" - if self._econet.supports_humidifier: - return SUPPORT_FLAGS_THERMOSTAT | ClimateEntityFeature.TARGET_HUMIDITY - return SUPPORT_FLAGS_THERMOSTAT + self._attr_supported_features |= SUPPORT_FLAGS_THERMOSTAT + if thermostat.supports_humidifier: + self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY + if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) @property def current_temperature(self): diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 34760ea6aca..819f1db2f69 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.8", "deebot-client==5.1.0"] + "requirements": ["py-sucks==0.9.9", "deebot-client==5.1.1"] } diff --git a/homeassistant/components/ecowitt/entity.py b/homeassistant/components/ecowitt/entity.py index a5d769e6749..cf62cfb2d94 100644 --- a/homeassistant/components/ecowitt/entity.py +++ b/homeassistant/components/ecowitt/entity.py @@ -38,8 +38,8 @@ class EcowittEntity(Entity): """Update the state on callback.""" self.async_write_ha_state() - self.ecowitt.update_cb.append(_update_state) # type: ignore[arg-type] # upstream bug - self.async_on_remove(lambda: self.ecowitt.update_cb.remove(_update_state)) # type: ignore[arg-type] # upstream bug + self.ecowitt.update_cb.append(_update_state) + self.async_on_remove(lambda: self.ecowitt.update_cb.remove(_update_state)) @property def available(self) -> bool: diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json index 9f0f668ee81..175960ab57d 100644 --- a/homeassistant/components/ecowitt/manifest.json +++ b/homeassistant/components/ecowitt/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/ecowitt", "iot_class": "local_push", - "requirements": ["aioecowitt==2023.5.0"] + "requirements": ["aioecowitt==2024.2.1"] } diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 6d048cc423d..4bcdd2461cd 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -176,6 +176,12 @@ ECOWITT_SENSORS_MAPPING: Final = { native_unit_of_measurement=UnitOfLength.MILES, state_class=SensorStateClass.MEASUREMENT, ), + EcoWittSensorTypes.SOIL_RAWADC: SensorEntityDescription( + key="SOIL_RAWADC", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), EcoWittSensorTypes.SPEED_KPH: SensorEntityDescription( key="SPEED_KPH", device_class=SensorDeviceClass.WIND_SPEED, diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py index 086a5288f77..9f6e7cbddf5 100644 --- a/homeassistant/components/electrasmart/climate.py +++ b/homeassistant/components/electrasmart/climate.py @@ -111,6 +111,7 @@ class ElectraClimateEntity(ClimateEntity): _attr_hvac_modes = ELECTRA_MODES _attr_has_entity_name = True _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device: ElectraAirConditioner, api: ElectraAPI) -> None: """Initialize Electra climate entity.""" @@ -121,6 +122,8 @@ class ElectraClimateEntity(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) swing_modes: list = [] diff --git a/homeassistant/components/eliqonline/sensor.py b/homeassistant/components/eliqonline/sensor.py index bea60b94a1c..2a929db4b0a 100644 --- a/homeassistant/components/eliqonline/sensor.py +++ b/homeassistant/components/eliqonline/sensor.py @@ -1,7 +1,6 @@ """Monitors home energy use for the ELIQ Online service.""" from __future__ import annotations -import asyncio from datetime import timedelta import logging @@ -83,5 +82,5 @@ class EliqSensor(SensorEntity): _LOGGER.debug("Updated power from server %d W", self.native_value) except KeyError: _LOGGER.warning("Invalid response from ELIQ Online API") - except (OSError, asyncio.TimeoutError) as error: + except (OSError, TimeoutError) as error: _LOGGER.warning("Could not connect to the ELIQ Online API: %s", error) diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index b633e1ae620..c51cb30776a 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -296,7 +296,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: if not await async_wait_for_elk_to_sync(elk, LOGIN_TIMEOUT, SYNC_TIMEOUT): return False - except asyncio.TimeoutError as exc: + except TimeoutError as exc: raise ConfigEntryNotReady(f"Timed out connecting to {conf[CONF_HOST]}") from exc elk_temp_unit = elk.panel.temperature_units @@ -389,7 +389,7 @@ async def async_wait_for_elk_to_sync( try: async with asyncio.timeout(timeout): await event.wait() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.debug("Timed out waiting for %s event", name) elk.disconnect() raise diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index c1e6dc7b034..97b16b14954 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -79,6 +79,8 @@ class ElkThermostat(ElkEntity, ClimateEntity): ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.AUX_HEAT | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_min_temp = 1 _attr_max_temp = 99 @@ -87,6 +89,7 @@ class ElkThermostat(ElkEntity, ClimateEntity): _attr_target_temperature_step = 1 _attr_fan_modes = [FAN_AUTO, FAN_ON] _element: Thermostat + _enable_turn_on_off_backwards_compatibility = False @property def temperature_unit(self) -> str: diff --git a/homeassistant/components/elkm1/config_flow.py b/homeassistant/components/elkm1/config_flow.py index ac7fc903330..e8d3f8cb0e4 100644 --- a/homeassistant/components/elkm1/config_flow.py +++ b/homeassistant/components/elkm1/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Elk-M1 Control integration.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -244,7 +243,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: info = await validate_input(user_input, self.unique_id) - except asyncio.TimeoutError: + except TimeoutError: return {"base": "cannot_connect"}, None except InvalidAuth: return {CONF_PASSWORD: "invalid_auth"}, None diff --git a/homeassistant/components/emulated_hue/config.py b/homeassistant/components/emulated_hue/config.py index 069fc3177d6..2be89e7214c 100644 --- a/homeassistant/components/emulated_hue/config.py +++ b/homeassistant/components/emulated_hue/config.py @@ -207,25 +207,25 @@ class Config: return state.attributes.get(ATTR_EMULATED_HUE_NAME, state.name) # type: ignore[no-any-return] @cache # pylint: disable=method-cache-max-size-none - def get_exposed_states(self) -> list[State]: + def get_exposed_entity_ids(self) -> list[str]: """Return a list of exposed states.""" state_machine = self.hass.states if self.expose_by_default: return [ - state + state.entity_id for state in state_machine.async_all() if self.is_state_exposed(state) ] - states: list[State] = [] - for entity_id in self.entities: - if (state := state_machine.get(entity_id)) and self.is_state_exposed(state): - states.append(state) - return states + return [ + entity_id + for entity_id in self.entities + if (state := state_machine.get(entity_id)) and self.is_state_exposed(state) + ] @callback def _clear_exposed_cache(self, event: EventType[EventStateChangedData]) -> None: - """Clear the cache of exposed states.""" - self.get_exposed_states.cache_clear() + """Clear the cache of exposed entity ids.""" + self.get_exposed_entity_ids.cache_clear() def is_state_exposed(self, state: State) -> bool: """Cache determine if an entity should be exposed on the emulated bridge.""" diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 94ac97b6b36..5e2937cae40 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -890,18 +890,11 @@ def create_config_model(config: Config, request: web.Request) -> dict[str, Any]: def create_list_of_entities(config: Config, request: web.Request) -> dict[str, Any]: """Create a list of all entities.""" hass: core.HomeAssistant = request.app["hass"] - - json_response: dict[str, Any] = {} - for cached_state in config.get_exposed_states(): - entity_id = cached_state.entity_id - state = hass.states.get(entity_id) - assert state is not None - - json_response[config.entity_id_to_number(entity_id)] = state_to_json( - config, state - ) - - return json_response + return { + config.entity_id_to_number(entity_id): state_to_json(config, state) + for entity_id in config.get_exposed_entity_ids() + if (state := hass.states.get(entity_id)) + } def hue_brightness_to_hass(value: int) -> int: @@ -934,7 +927,7 @@ async def wait_for_state_change_or_timeout( try: async with asyncio.timeout(STATE_CHANGE_WAIT_TIMEOUT): await ev.wait() - except asyncio.TimeoutError: + except TimeoutError: pass finally: unsub() diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py index 3735b4d16c2..047b9234b82 100644 --- a/homeassistant/components/ephember/climate.py +++ b/homeassistant/components/ephember/climate.py @@ -83,6 +83,7 @@ class EphEmberThermostat(ClimateEntity): _attr_hvac_modes = OPERATION_LIST _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, ember, zone): """Initialize the thermostat.""" @@ -100,6 +101,9 @@ class EphEmberThermostat(ClimateEntity): if self._hot_water: self._attr_supported_features = ClimateEntityFeature.AUX_HEAT self._attr_target_temperature_step = None + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) @property def current_temperature(self): diff --git a/homeassistant/components/epion/sensor.py b/homeassistant/components/epion/sensor.py index 826d565c2cd..c722e73ac6c 100644 --- a/homeassistant/components/epion/sensor.py +++ b/homeassistant/components/epion/sensor.py @@ -88,9 +88,9 @@ class EpionSensor(CoordinatorEntity[EpionCoordinator], SensorEntity): super().__init__(coordinator) self._epion_device_id = epion_device_id self.entity_description = description - self.unique_id = f"{epion_device_id}_{description.key}" + self._attr_unique_id = f"{epion_device_id}_{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._epion_device_id)}, + identifiers={(DOMAIN, epion_device_id)}, manufacturer="Epion", name=self.device.get("deviceName"), sw_version=self.device.get("fwVersion"), diff --git a/homeassistant/components/escea/climate.py b/homeassistant/components/escea/climate.py index 71c8a403f8f..021cfd26764 100644 --- a/homeassistant/components/escea/climate.py +++ b/homeassistant/components/escea/climate.py @@ -82,10 +82,14 @@ class ControllerEntity(ClimateEntity): _attr_precision = PRECISION_WHOLE _attr_should_poll = False _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, controller: Controller) -> None: """Initialise ControllerDevice.""" diff --git a/homeassistant/components/escea/config_flow.py b/homeassistant/components/escea/config_flow.py index 8766c30c04a..eb50e7d0fdc 100644 --- a/homeassistant/components/escea/config_flow.py +++ b/homeassistant/components/escea/config_flow.py @@ -31,7 +31,7 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: discovery_service = await async_start_discovery_service(hass) - with suppress(asyncio.TimeoutError): + with suppress(TimeoutError): async with asyncio.timeout(TIMEOUT_DISCOVERY): await controller_ready.wait() diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 5c265068216..9c2177800f3 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -137,6 +137,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = "climate" + _enable_turn_on_off_backwards_compatibility = False @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: @@ -179,6 +180,8 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti features |= ClimateEntityFeature.FAN_MODE if self.swing_modes: features |= ClimateEntityFeature.SWING_MODE + if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes: + features |= ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON self._attr_supported_features = features def _get_precision(self) -> float: diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 59f37d3a078..9d52c8eddea 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -21,7 +21,6 @@ from aioesphomeapi import ( UserService, UserServiceArgType, VoiceAssistantAudioSettings, - VoiceAssistantEventType, ) from awesomeversion import AwesomeVersion import voluptuous as vol @@ -330,11 +329,6 @@ class ESPHomeManager: ) ) - def _handle_pipeline_event( - self, event_type: VoiceAssistantEventType, data: dict[str, str] | None - ) -> None: - self.cli.send_voice_assistant_event(event_type, data) - def _handle_pipeline_finished(self) -> None: self.entry_data.async_set_assist_pipeline_state(False) @@ -358,7 +352,7 @@ class ESPHomeManager: self.voice_assistant_udp_server = VoiceAssistantUDPServer( hass, self.entry_data, - self._handle_pipeline_event, + self.cli.send_voice_assistant_event, self._handle_pipeline_finished, ) port = await self.voice_assistant_udp_server.start_server() diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index e3437e5aa73..35b8e91f12b 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "requirements": [ - "aioesphomeapi==21.0.1", + "aioesphomeapi==21.0.2", "esphome-dashboard-api==1.2.3", "bleak-esphome==0.4.1" ], diff --git a/homeassistant/components/evil_genius_labs/config_flow.py b/homeassistant/components/evil_genius_labs/config_flow.py index beb16115bd7..ab2e116b2a6 100644 --- a/homeassistant/components/evil_genius_labs/config_flow.py +++ b/homeassistant/components/evil_genius_labs/config_flow.py @@ -63,7 +63,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: info = await validate_input(self.hass, user_input) - except asyncio.TimeoutError: + except TimeoutError: errors["base"] = "timeout" except CannotConnect: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 9d32ba98e92..6b893dc8f48 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], - "requirements": ["evohome-async==0.4.17"] + "requirements": ["evohome-async==0.4.19"] } diff --git a/homeassistant/components/fan/icons.json b/homeassistant/components/fan/icons.json index ebc4988e87f..f962d1e7c1a 100644 --- a/homeassistant/components/fan/icons.json +++ b/homeassistant/components/fan/icons.json @@ -11,6 +11,10 @@ "state": { "reverse": "mdi:rotate-left" } + }, + "preset_mode": { + "default": "mdi:circle-medium", + "state": {} } } } diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py index ba7134d7e50..5732fb3822c 100644 --- a/homeassistant/components/flexit_bacnet/__init__.py +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -11,6 +11,7 @@ from .coordinator import FlexitCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index 0d8a381a014..e641a9d7e4f 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -37,12 +37,12 @@ from .entity import FlexitEntity async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Flexit Nordic unit.""" coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_devices([FlexitClimateEntity(coordinator)]) + async_add_entities([FlexitClimateEntity(coordinator)]) class FlexitClimateEntity(FlexitEntity, ClimateEntity): diff --git a/homeassistant/components/flexit_bacnet/icons.json b/homeassistant/components/flexit_bacnet/icons.json new file mode 100644 index 00000000000..7ce8b116a27 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/icons.json @@ -0,0 +1,44 @@ +{ + "entity": { + "number": { + "away_extract_fan_setpoint": { + "default": "mdi:fan-minus" + }, + "away_supply_fan_setpoint": { + "default": "mdi:fan-plus" + }, + "cooker_hood_extract_fan_setpoint": { + "default": "mdi:fan-minus" + }, + "cooker_hood_supply_fan_setpoint": { + "default": "mdi:fan-plus" + }, + "fireplace_extract_fan_setpoint": { + "default": "mdi:fan-minus" + }, + "fireplace_supply_fan_setpoint": { + "default": "mdi:fan-plus" + }, + "high_extract_fan_setpoint": { + "default": "mdi:fan-minus" + }, + "high_supply_fan_setpoint": { + "default": "mdi:fan-plus" + }, + "home_extract_fan_setpoint": { + "default": "mdi:fan-minus" + }, + "home_supply_fan_setpoint": { + "default": "mdi:fan-plus" + } + }, + "switch": { + "electric_heater": { + "default": "mdi:radiator", + "state": { + "off": "mdi:radiator-off" + } + } + } + } +} diff --git a/homeassistant/components/flexit_bacnet/number.py b/homeassistant/components/flexit_bacnet/number.py new file mode 100644 index 00000000000..2731d5e8b09 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/number.py @@ -0,0 +1,204 @@ +"""The Flexit Nordic (BACnet) integration.""" +import asyncio.exceptions +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FlexitCoordinator +from .const import DOMAIN +from .entity import FlexitEntity + + +@dataclass(kw_only=True, frozen=True) +class FlexitNumberEntityDescription(NumberEntityDescription): + """Describes a Flexit number entity.""" + + native_value_fn: Callable[[FlexitBACnet], float] + set_native_value_fn: Callable[[FlexitBACnet], Callable[[int], Awaitable[None]]] + + +NUMBERS: tuple[FlexitNumberEntityDescription, ...] = ( + FlexitNumberEntityDescription( + key="away_extract_fan_setpoint", + translation_key="away_extract_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_extract_air_away, + set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_away, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="away_supply_fan_setpoint", + translation_key="away_supply_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_supply_air_away, + set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_away, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="cooker_hood_extract_fan_setpoint", + translation_key="cooker_hood_extract_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_extract_air_cooker, + set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_cooker, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="cooker_hood_supply_fan_setpoint", + translation_key="cooker_hood_supply_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_supply_air_cooker, + set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_cooker, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="fireplace_extract_fan_setpoint", + translation_key="fireplace_extract_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_extract_air_fire, + set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_fire, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="fireplace_supply_fan_setpoint", + translation_key="fireplace_supply_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_supply_air_fire, + set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_fire, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="high_extract_fan_setpoint", + translation_key="high_extract_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_extract_air_high, + set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_high, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="high_supply_fan_setpoint", + translation_key="high_supply_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_supply_air_high, + set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_high, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="home_extract_fan_setpoint", + translation_key="home_extract_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_extract_air_home, + set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_home, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="home_supply_fan_setpoint", + translation_key="home_supply_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_supply_air_home, + set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_home, + native_unit_of_measurement=PERCENTAGE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Flexit (bacnet) number from a config entry.""" + coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + FlexitNumber(coordinator, description) for description in NUMBERS + ) + + +class FlexitNumber(FlexitEntity, NumberEntity): + """Representation of a Flexit Number.""" + + entity_description: FlexitNumberEntityDescription + + def __init__( + self, + coordinator: FlexitCoordinator, + entity_description: FlexitNumberEntityDescription, + ) -> None: + """Initialize Flexit (bacnet) number.""" + super().__init__(coordinator) + + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.device.serial_number}-{entity_description.key}" + ) + + @property + def native_value(self) -> float: + """Return the state of the number.""" + return self.entity_description.native_value_fn(self.coordinator.device) + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + set_native_value_fn = self.entity_description.set_native_value_fn( + self.coordinator.device + ) + try: + await set_native_value_fn(int(value)) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + finally: + await self.coordinator.async_refresh() diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json index d9efd1fc411..7f763674d00 100644 --- a/homeassistant/components/flexit_bacnet/strings.json +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -22,6 +22,38 @@ "name": "Air filter polluted" } }, + "number": { + "away_extract_fan_setpoint": { + "name": "Away extract fan setpoint" + }, + "away_supply_fan_setpoint": { + "name": "Away supply fan setpoint" + }, + "cooker_hood_extract_fan_setpoint": { + "name": "Cooker hood extract fan setpoint" + }, + "cooker_hood_supply_fan_setpoint": { + "name": "Cooker hood supply fan setpoint" + }, + "fireplace_extract_fan_setpoint": { + "name": "Fireplace extract fan setpoint" + }, + "fireplace_supply_fan_setpoint": { + "name": "Fireplace supply fan setpoint" + }, + "high_extract_fan_setpoint": { + "name": "High extract fan setpoint" + }, + "high_supply_fan_setpoint": { + "name": "High supply fan setpoint" + }, + "home_extract_fan_setpoint": { + "name": "Home extract fan setpoint" + }, + "home_supply_fan_setpoint": { + "name": "Home supply fan setpoint" + } + }, "sensor": { "outside_air_temperature": { "name": "Outside air temperature" diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py index b3751c90f7d..0a7785eaa38 100644 --- a/homeassistant/components/flexit_bacnet/switch.py +++ b/homeassistant/components/flexit_bacnet/switch.py @@ -35,7 +35,6 @@ SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = ( FlexitSwitchEntityDescription( key="electric_heater", translation_key="electric_heater", - icon="mdi:radiator", is_on_fn=lambda data: data.electric_heater, turn_on_fn=lambda data: data.enable_electric_heater(), turn_off_fn=lambda data: data.disable_electric_heater(), diff --git a/homeassistant/components/flick_electric/config_flow.py b/homeassistant/components/flick_electric/config_flow.py index 557d0492320..842706172f1 100644 --- a/homeassistant/components/flick_electric/config_flow.py +++ b/homeassistant/components/flick_electric/config_flow.py @@ -46,7 +46,7 @@ class FlickConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: async with asyncio.timeout(60): token = await auth.async_get_access_token() - except asyncio.TimeoutError as err: + except TimeoutError as err: raise CannotConnect() from err except AuthException as err: raise InvalidAuth() from err diff --git a/homeassistant/components/flo/device.py b/homeassistant/components/flo/device.py index 7aacb1b262a..27feb15a97e 100644 --- a/homeassistant/components/flo/device.py +++ b/homeassistant/components/flo/device.py @@ -7,6 +7,7 @@ from typing import Any from aioflo.api import API from aioflo.errors import RequestError +from orjson import JSONDecodeError from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,6 +19,8 @@ from .const import DOMAIN as FLO_DOMAIN, LOGGER class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module """Flo device object.""" + _failure_count: int = 0 + def __init__( self, hass: HomeAssistant, api_client: API, location_id: str, device_id: str ) -> None: @@ -43,8 +46,11 @@ class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable= await self.send_presence_ping() await self._update_device() await self._update_consumption_data() - except RequestError as error: - raise UpdateFailed(error) from error + self._failure_count = 0 + except (RequestError, TimeoutError, JSONDecodeError) as error: + self._failure_count += 1 + if self._failure_count > 3: + raise UpdateFailed(error) from error @property def location_id(self) -> str: diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py index 3fdd54dd40d..c5926e3158e 100644 --- a/homeassistant/components/flock/notify.py +++ b/homeassistant/components/flock/notify.py @@ -58,5 +58,5 @@ class FlockNotificationService(BaseNotificationService): response.status, result, ) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Timeout accessing Flock at %s", self._url) diff --git a/homeassistant/components/flux_led/const.py b/homeassistant/components/flux_led/const.py index 8b42f5f2e0d..08e1d274ea7 100644 --- a/homeassistant/components/flux_led/const.py +++ b/homeassistant/components/flux_led/const.py @@ -1,6 +1,5 @@ """Constants of the FluxLed/MagicHome Integration.""" -import asyncio import socket from typing import Final @@ -38,7 +37,7 @@ DEFAULT_EFFECT_SPEED: Final = 50 FLUX_LED_DISCOVERY: Final = "flux_led_discovery" FLUX_LED_EXCEPTIONS: Final = ( - asyncio.TimeoutError, + TimeoutError, socket.error, RuntimeError, BrokenPipeError, diff --git a/homeassistant/components/flux_led/util.py b/homeassistant/components/flux_led/util.py index 7aa2d91de4e..8db12cb6e32 100644 --- a/homeassistant/components/flux_led/util.py +++ b/homeassistant/components/flux_led/util.py @@ -12,6 +12,8 @@ from .const import FLUX_COLOR_MODE_TO_HASS, MIN_RGB_BRIGHTNESS def _hass_color_modes(device: AIOWifiLedBulb) -> set[str]: color_modes = device.color_modes + if not color_modes: + return {ColorMode.ONOFF} return {_flux_color_mode_to_hass(mode, color_modes) for mode in color_modes} diff --git a/homeassistant/components/foobot/sensor.py b/homeassistant/components/foobot/sensor.py index a865dd33053..0af1206dbd3 100644 --- a/homeassistant/components/foobot/sensor.py +++ b/homeassistant/components/foobot/sensor.py @@ -1,7 +1,6 @@ """Support for the Foobot indoor air quality monitor.""" from __future__ import annotations -import asyncio from datetime import timedelta import logging from typing import Any @@ -118,7 +117,7 @@ async def async_setup_platform( ) except ( aiohttp.client_exceptions.ClientConnectorError, - asyncio.TimeoutError, + TimeoutError, FoobotClient.TooManyRequests, FoobotClient.InternalError, ) as err: @@ -175,7 +174,7 @@ class FoobotData: ) except ( aiohttp.client_exceptions.ClientConnectorError, - asyncio.TimeoutError, + TimeoutError, self._client.TooManyRequests, self._client.InternalError, ): diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index c6d4236c219..1d28aad6a92 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -28,10 +28,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 + entry, data=entry.data, options=new_options, version=2 ) return True diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 48c2be07c76..df12de944ae 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -668,7 +668,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): try: async with asyncio.timeout(CALLBACK_TIMEOUT): await self._paused_event.wait() # wait for paused - except asyncio.TimeoutError: + except TimeoutError: self._pause_requested = False self._paused_event.clear() @@ -764,7 +764,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): async with asyncio.timeout(TTS_TIMEOUT): await self._tts_playing_event.wait() # we have started TTS, now wait for completion - except asyncio.TimeoutError: + except TimeoutError: self._tts_requested = False _LOGGER.warning("TTS request timed out") await asyncio.sleep( diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index 057ef4dbe8c..aed3ed637ae 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -66,8 +66,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await async_migrate_entries(hass, entry.entry_id, update_unique_id) - entry.unique_id = None - # Get RTSP port from the camera or use the fallback one and store it in data camera = FoscamCamera( entry.data[CONF_HOST], @@ -85,12 +83,12 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: rtsp_port = response.get("rtspPort") or response.get("mediaPort") hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_RTSP_PORT: rtsp_port} + entry, + data={**entry.data, CONF_RTSP_PORT: rtsp_port}, + version=2, + unique_id=None, ) - # Change entry version - entry.version = 2 - LOGGER.info("Migration to version %s successful", entry.version) return True diff --git a/homeassistant/components/foscam/manifest.json b/homeassistant/components/foscam/manifest.json index da4e9f53af4..6f256f99854 100644 --- a/homeassistant/components/foscam/manifest.json +++ b/homeassistant/components/foscam/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/foscam", "iot_class": "local_polling", "loggers": ["libpyfoscam"], - "requirements": ["libpyfoscam==1.0"] + "requirements": ["libpyfoscam==1.2.2"] } diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index 59b5d65710a..7441def7d4d 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN -from .router import get_api +from .router import get_api, get_hosts_list_if_supported _LOGGER = logging.getLogger(__name__) @@ -69,7 +69,7 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Check permissions await fbx.system.get_config() - await fbx.lan.get_hosts_list() + await get_hosts_list_if_supported(fbx) # Close connection await fbx.close() diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 15e3b34bd77..3b13fad0572 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -64,6 +64,33 @@ async def get_api(hass: HomeAssistant, host: str) -> Freepybox: return Freepybox(APP_DESC, token_file, API_VERSION) +async def get_hosts_list_if_supported( + fbx_api: Freepybox, +) -> tuple[bool, list[dict[str, Any]]]: + """Hosts list is not supported when freebox is configured in bridge mode.""" + supports_hosts: bool = True + fbx_devices: list[dict[str, Any]] = [] + try: + fbx_devices = await fbx_api.lan.get_hosts_list() or [] + except HttpRequestError as err: + if ( + (matcher := re.search(r"Request failed \(APIResponse: (.+)\)", str(err))) + and is_json(json_str := matcher.group(1)) + and (json_resp := json.loads(json_str)).get("error_code") == "nodev" + ): + # No need to retry, Host list not available + supports_hosts = False + _LOGGER.debug( + "Host list is not available using bridge mode (%s)", + json_resp.get("msg"), + ) + + else: + raise err + + return supports_hosts, fbx_devices + + class FreeboxRouter: """Representation of a Freebox router.""" @@ -111,27 +138,9 @@ class FreeboxRouter: # Access to Host list not available in bridge mode, API return error_code 'nodev' if self.supports_hosts: - try: - fbx_devices = await self._api.lan.get_hosts_list() - except HttpRequestError as err: - if ( - ( - matcher := re.search( - r"Request failed \(APIResponse: (.+)\)", str(err) - ) - ) - and is_json(json_str := matcher.group(1)) - and (json_resp := json.loads(json_str)).get("error_code") == "nodev" - ): - # No need to retry, Host list not available - self.supports_hosts = False - _LOGGER.debug( - "Host list is not available using bridge mode (%s)", - json_resp.get("msg"), - ) - - else: - raise err + self.supports_hosts, fbx_devices = await get_hosts_list_if_supported( + self._api + ) # Adds the Freebox itself fbx_devices.append( diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py index e65856e03f4..feb1fb9fed9 100644 --- a/homeassistant/components/freedns/__init__.py +++ b/homeassistant/components/freedns/__init__.py @@ -96,7 +96,7 @@ async def _update_freedns(hass, session, url, auth_token): except aiohttp.ClientError: _LOGGER.warning("Can't connect to FreeDNS API") - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Timeout from FreeDNS API at %s", url) return False diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py index 7a4b0473600..3bb62cb23fb 100644 --- a/homeassistant/components/freedompro/climate.py +++ b/homeassistant/components/freedompro/climate.py @@ -64,10 +64,15 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], ClimateEntity): _attr_hvac_modes = SUPPORTED_HVAC_MODES _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_name = None - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_current_temperature = 0 _attr_target_temperature = 0 _attr_hvac_mode = HVACMode.OFF + _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 00e9f406ed4..f703fadb4b8 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -68,7 +68,7 @@ async def async_setup_entry( if description.is_suitable(connection_info) ] - async_add_entities(entities, True) + async_add_entities(entities) class FritzBoxBinarySensor(FritzBoxBaseCoordinatorEntity, BinarySensorEntity): diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index c9acd60b23c..3d287b57384 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -291,7 +291,7 @@ class FritzBoxTools( self.has_call_deflections = "X_AVM-DE_OnTel1" in self.connection.services - def register_entity_updates( + async def async_register_entity_updates( self, key: str, update_fn: Callable[[FritzStatus, StateType], Any] ) -> Callable[[], None]: """Register an entity to be updated by coordinator.""" @@ -305,6 +305,12 @@ class FritzBoxTools( if key not in self._entity_update_functions: _LOGGER.debug("register entity %s for updates", key) self._entity_update_functions[key] = update_fn + if self.fritz_status: + self.data["entity_states"][ + key + ] = await self.hass.async_add_executor_job( + update_fn, self.fritz_status, self.data["entity_states"].get(key) + ) return unregister_entity_updates async def _async_update_data(self) -> UpdateCoordinatorDataType: @@ -1121,16 +1127,20 @@ class FritzBoxBaseCoordinatorEntity(update_coordinator.CoordinatorEntity[AvmWrap ) -> None: """Init device info class.""" super().__init__(avm_wrapper) - if description.value_fn is not None: - self.async_on_remove( - avm_wrapper.register_entity_updates( - description.key, description.value_fn - ) - ) self.entity_description = description self._device_name = device_name self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + if self.entity_description.value_fn is not None: + self.async_on_remove( + await self.coordinator.async_register_entity_updates( + self.entity_description.key, self.entity_description.value_fn + ) + ) + @property def device_info(self) -> DeviceInfo: """Return the device information.""" diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 53a299cd576..980d86e2455 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -298,7 +298,7 @@ async def async_setup_entry( if description.is_suitable(connection_info) ] - async_add_entities(entities, True) + async_add_entities(entities) class FritzBoxSensor(FritzBoxBaseCoordinatorEntity, SensorEntity): diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index b76e0fda18a..8dc19c199a3 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -80,6 +80,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False @property def current_temperature(self) -> float: diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index 1ec62c54b6c..c2f635119aa 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -12,5 +12,5 @@ "iot_class": "local_polling", "loggers": ["pyfronius"], "quality_scale": "platinum", - "requirements": ["PyFronius==0.7.2"] + "requirements": ["PyFronius==0.7.3"] } diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 09419f2d3bd..48d5bcb0b05 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -644,9 +644,11 @@ class ManifestJSONView(HomeAssistantView): @callback def get(self, request: web.Request) -> web.Response: """Return the manifest.json.""" - return web.Response( + response = web.Response( text=MANIFEST_JSON.json, content_type="application/manifest+json" ) + response.enable_compression() + return response @websocket_api.websocket_command( diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 039328b9cac..21f4df79568 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20240202.0"] + "requirements": ["home-assistant-frontend==20240207.1"] } diff --git a/homeassistant/components/fully_kiosk/config_flow.py b/homeassistant/components/fully_kiosk/config_flow.py index 4f9dadd6901..00eb1dd7101 100644 --- a/homeassistant/components/fully_kiosk/config_flow.py +++ b/homeassistant/components/fully_kiosk/config_flow.py @@ -58,7 +58,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except ( ClientConnectorError, FullyKioskError, - asyncio.TimeoutError, + TimeoutError, ) as error: LOGGER.debug(error.args, exc_info=True) errors["base"] = "cannot_connect" diff --git a/homeassistant/components/fully_kiosk/media_player.py b/homeassistant/components/fully_kiosk/media_player.py index 0984d6a220f..8e6d2fad533 100644 --- a/homeassistant/components/fully_kiosk/media_player.py +++ b/homeassistant/components/fully_kiosk/media_player.py @@ -12,7 +12,7 @@ from homeassistant.components.media_player import ( async_process_play_media_url, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import AUDIOMANAGER_STREAM_MUSIC, DOMAIN, MEDIA_SUPPORT_FULLYKIOSK @@ -82,3 +82,13 @@ class FullyMediaPlayer(FullyKioskEntity, MediaPlayerEntity): media_content_id, content_filter=lambda item: item.media_content_type.startswith("audio/"), ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_state = ( + MediaPlayerState.PLAYING + if "soundUrlPlaying" in self.coordinator.data + else MediaPlayerState.IDLE + ) + self.async_write_ha_state() diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py index df41b0a1c43..99c8fa69acf 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -1,7 +1,6 @@ """The Gardena Bluetooth integration.""" from __future__ import annotations -import asyncio import logging from bleak.backends.device import BLEDevice @@ -60,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) uuids = await client.get_all_characteristics_uuid() await client.update_timestamp(dt_util.now()) - except (asyncio.TimeoutError, CommunicationFailure, DeviceUnavailable) as exception: + except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception: await client.disconnect() raise ConfigEntryNotReady( f"Unable to connect to device {address} due to {exception}" diff --git a/homeassistant/components/gdacs/manifest.json b/homeassistant/components/gdacs/manifest.json index b6fb3d8cee3..d743dd00424 100644 --- a/homeassistant/components/gdacs/manifest.json +++ b/homeassistant/components/gdacs/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aio_georss_gdacs", "aio_georss_client"], "quality_scale": "platinum", - "requirements": ["aio-georss-gdacs==0.8"] + "requirements": ["aio-georss-gdacs==0.9"] } diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 902f5ebadde..cadc855ade6 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -8,6 +8,7 @@ import logging from typing import Any import httpx +import voluptuous as vol import yarl from homeassistant.components.camera import Camera, CameraEntityFeature @@ -140,6 +141,12 @@ class GenericCamera(Camera): _LOGGER.error("Error parsing template %s: %s", self._still_image_url, err) return self._last_image + try: + vol.Schema(vol.Url())(url) + except vol.Invalid as err: + _LOGGER.warning("Invalid URL '%s': %s, returning last image", url, err) + return self._last_image + if url == self._last_url and self._limit_refetch: return self._last_image diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 7bc6c63697c..3a964204b70 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -178,6 +178,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): """Representation of a Generic Thermostat device.""" _attr_should_poll = False + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -225,7 +226,11 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self._target_temp = target_temp self._attr_temperature_unit = unit self._attr_unique_id = unique_id - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) if len(presets): self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._attr_preset_modes = [PRESET_NONE] + list(presets.keys()) diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py index bafda44501b..cb817c64930 100644 --- a/homeassistant/components/geniushub/climate.py +++ b/homeassistant/components/geniushub/climate.py @@ -50,8 +50,12 @@ class GeniusClimateZone(GeniusHeatingZone, ClimateEntity): """Representation of a Genius Hub climate device.""" _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, broker, zone) -> None: """Initialize the climate device.""" diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index 8915962c4ff..134f6a0e943 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -105,7 +105,8 @@ class GeoJsonLocationEvent(GeolocationEvent): def _update_from_feed(self, feed_entry: GenericFeedEntry) -> None: """Update the internal state from the provided feed entry.""" if feed_entry.properties and "name" in feed_entry.properties: - self._attr_name = feed_entry.properties.get("name") + # The entry name's type can vary, but our own name must be a string + self._attr_name = str(feed_entry.properties["name"]) else: self._attr_name = feed_entry.title self._attr_distance = feed_entry.distance_to_home diff --git a/homeassistant/components/geo_rss_events/manifest.json b/homeassistant/components/geo_rss_events/manifest.json index bdf8f126680..17640e37278 100644 --- a/homeassistant/components/geo_rss_events/manifest.json +++ b/homeassistant/components/geo_rss_events/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/geo_rss_events", "iot_class": "cloud_polling", "loggers": ["georss_client", "georss_generic_client"], - "requirements": ["georss-generic-client==0.6"] + "requirements": ["georss-generic-client==0.8"] } diff --git a/homeassistant/components/geonetnz_quakes/manifest.json b/homeassistant/components/geonetnz_quakes/manifest.json index 9ed59b2bc97..2314dabcf0f 100644 --- a/homeassistant/components/geonetnz_quakes/manifest.json +++ b/homeassistant/components/geonetnz_quakes/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aio_geojson_geonetnz_quakes"], "quality_scale": "platinum", - "requirements": ["aio-geojson-geonetnz-quakes==0.15"] + "requirements": ["aio-geojson-geonetnz-quakes==0.16"] } diff --git a/homeassistant/components/geonetnz_volcano/manifest.json b/homeassistant/components/geonetnz_volcano/manifest.json index 6e9503e0243..421222bb810 100644 --- a/homeassistant/components/geonetnz_volcano/manifest.json +++ b/homeassistant/components/geonetnz_volcano/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_geonetnz_volcano"], - "requirements": ["aio-geojson-geonetnz-volcano==0.8"] + "requirements": ["aio-geojson-geonetnz-volcano==0.9"] } diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index ffc34bd2b78..1595b7ad131 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -45,7 +45,7 @@ class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): title=gios.station_name, data=user_input, ) - except (ApiError, ClientConnectorError, asyncio.TimeoutError): + except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" except NoStationError: errors[CONF_STATION_ID] = "wrong_station_id" diff --git a/homeassistant/components/gios/icons.json b/homeassistant/components/gios/icons.json new file mode 100644 index 00000000000..e1d848e276b --- /dev/null +++ b/homeassistant/components/gios/icons.json @@ -0,0 +1,30 @@ +{ + "entity": { + "sensor": { + "aqi": { + "default": "mdi:air-filter" + }, + "c6h6": { + "default": "mdi:molecule" + }, + "co": { + "default": "mdi:molecule" + }, + "no2_index": { + "default": "mdi:molecule" + }, + "o3_index": { + "default": "mdi:molecule" + }, + "pm10_index": { + "default": "mdi:molecule" + }, + "pm25_index": { + "default": "mdi:molecule" + }, + "so2_index": { + "default": "mdi:molecule" + } + } + } +} diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 99c1775beef..1b13430128f 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -54,7 +54,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( GiosSensorEntityDescription( key=ATTR_AQI, value=lambda sensors: sensors.aqi.value if sensors.aqi else None, - icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], translation_key="aqi", @@ -63,7 +62,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( key=ATTR_C6H6, value=lambda sensors: sensors.c6h6.value if sensors.c6h6 else None, suggested_display_precision=0, - icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, translation_key="c6h6", @@ -72,7 +70,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( key=ATTR_CO, value=lambda sensors: sensors.co.value if sensors.co else None, suggested_display_precision=0, - icon="mdi:molecule", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, translation_key="co", @@ -89,7 +86,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( key=ATTR_NO2, subkey="index", value=lambda sensors: sensors.no2.index if sensors.no2 else None, - icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], translation_key="no2_index", @@ -106,7 +102,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( key=ATTR_O3, subkey="index", value=lambda sensors: sensors.o3.index if sensors.o3 else None, - icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], translation_key="o3_index", @@ -123,7 +118,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( key=ATTR_PM10, subkey="index", value=lambda sensors: sensors.pm10.index if sensors.pm10 else None, - icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], translation_key="pm10_index", @@ -140,7 +134,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( key=ATTR_PM25, subkey="index", value=lambda sensors: sensors.pm25.index if sensors.pm25 else None, - icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], translation_key="pm25_index", @@ -157,7 +150,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( key=ATTR_SO2, subkey="index", value=lambda sensors: sensors.so2.index if sensors.so2 else None, - icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], translation_key="so2_index", diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index f32cad5a488..7a7000ba780 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -1,6 +1,8 @@ """The Goal Zero Yeti integration.""" from __future__ import annotations +from typing import TYPE_CHECKING + from goalzero import Yeti, exceptions from homeassistant.config_entries import ConfigEntry @@ -8,6 +10,7 @@ from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN from .coordinator import GoalZeroDataUpdateCoordinator @@ -17,6 +20,17 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Goal Zero Yeti from a config entry.""" + + mac = entry.unique_id + + if TYPE_CHECKING: + assert mac is not None + + if (formatted_mac := format_mac(mac)) != mac: + # The DHCP discovery path did not format the MAC address + # so we need to update the config entry if it's different + hass.config_entries.async_update_entry(entry, unique_id=formatted_mac) + api = Yeti(entry.data[CONF_HOST], async_get_clientsession(hass)) try: await api.init_connect() diff --git a/homeassistant/components/goalzero/config_flow.py b/homeassistant/components/goalzero/config_flow.py index 2d8c0c848c9..2312b6bd183 100644 --- a/homeassistant/components/goalzero/config_flow.py +++ b/homeassistant/components/goalzero/config_flow.py @@ -32,7 +32,7 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle dhcp discovery.""" self.ip_address = discovery_info.ip - await self.async_set_unique_id(discovery_info.macaddress) + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) self._abort_if_unique_id_configured(updates={CONF_HOST: self.ip_address}) self._async_abort_entries_match({CONF_HOST: self.ip_address}) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index d75ebb49509..2aabaa59d01 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from asyncio import gather -from collections.abc import Callable, Mapping +from collections.abc import Callable, Collection, Mapping from datetime import datetime, timedelta from functools import lru_cache from http import HTTPStatus @@ -33,7 +33,6 @@ from homeassistant.helpers import ( from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url from homeassistant.helpers.redact import partial_redact -from homeassistant.helpers.storage import Store from homeassistant.util.dt import utcnow from . import trait @@ -46,8 +45,6 @@ from .const import ( ERR_FUNCTION_NOT_SUPPORTED, NOT_EXPOSE_LOCAL, SOURCE_LOCAL, - STORE_AGENT_USER_IDS, - STORE_GOOGLE_LOCAL_WEBHOOK_ID, ) from .data_redaction import async_redact_request_msg, async_redact_response_msg from .error import SmartHomeError @@ -94,7 +91,6 @@ def _get_registry_entries( class AbstractConfig(ABC): """Hold the configuration for Google Assistant.""" - _store: GoogleConfigStore _unsub_report_state: Callable[[], None] | None = None def __init__(self, hass: HomeAssistant) -> None: @@ -105,12 +101,10 @@ class AbstractConfig(ABC): self._local_last_active: datetime | None = None self._local_sdk_version_warn = False self.is_supported_cache: dict[str, tuple[int | None, bool]] = {} + self._on_deinitialize: list[CALLBACK_TYPE] = [] async def async_initialize(self) -> None: """Perform async initialization of config.""" - self._store = GoogleConfigStore(self.hass) - await self._store.async_initialize() - if not self.enabled: return @@ -118,22 +112,29 @@ class AbstractConfig(ABC): """Sync entities to Google.""" await self.async_sync_entities_all() - start.async_at_start(self.hass, sync_google) + self._on_deinitialize.append(start.async_at_start(self.hass, sync_google)) + + @callback + def async_deinitialize(self) -> None: + """Remove listeners.""" + _LOGGER.debug("async_deinitialize") + while self._on_deinitialize: + self._on_deinitialize.pop()() @property + @abstractmethod def enabled(self): """Return if Google is enabled.""" - return False @property + @abstractmethod def entity_config(self): """Return entity config.""" - return {} @property + @abstractmethod def secure_devices_pin(self): """Return entity config.""" - return None @property def is_reporting_state(self): @@ -146,9 +147,9 @@ class AbstractConfig(ABC): return self._local_sdk_active @property + @abstractmethod def should_report_state(self): """Return if states should be proactively reported.""" - return False @property def is_local_connected(self) -> bool: @@ -159,24 +160,19 @@ class AbstractConfig(ABC): and self._local_last_active > utcnow() - timedelta(seconds=70) ) - def get_local_agent_user_id(self, webhook_id): - """Return the user ID to be used for actions received via the local SDK. + @abstractmethod + def get_local_user_id(self, webhook_id): + """Map webhook ID to a Home Assistant user ID. - Return None is no agent user id is found. + Any action inititated by Google Assistant via the local SDK will be attributed + to the returned user ID. + + Return None if no user id is found for the webhook_id. """ - found_agent_user_id = None - for agent_user_id, agent_user_data in self._store.agent_user_ids.items(): - if agent_user_data[STORE_GOOGLE_LOCAL_WEBHOOK_ID] == webhook_id: - found_agent_user_id = agent_user_id - break - - return found_agent_user_id + @abstractmethod def get_local_webhook_id(self, agent_user_id): """Return the webhook ID to be used for actions for a given agent user id via the local SDK.""" - if data := self._store.agent_user_ids.get(agent_user_id): - return data[STORE_GOOGLE_LOCAL_WEBHOOK_ID] - return None @abstractmethod def get_agent_user_id(self, context): @@ -186,21 +182,21 @@ class AbstractConfig(ABC): def should_expose(self, state) -> bool: """Return if entity should be exposed.""" + @abstractmethod def should_2fa(self, state): """If an entity should have 2FA checked.""" - return True + @abstractmethod async def async_report_state( self, message: dict[str, Any], agent_user_id: str, event_id: str | None = None ) -> HTTPStatus | None: """Send a state report to Google.""" - raise NotImplementedError async def async_report_state_all(self, message): """Send a state report to Google for all previously synced users.""" jobs = [ self.async_report_state(message, agent_user_id) - for agent_user_id in self._store.agent_user_ids + for agent_user_id in self.async_get_agent_users() ] await gather(*jobs) @@ -232,13 +228,13 @@ class AbstractConfig(ABC): async def async_sync_entities_all(self) -> int: """Sync all entities to Google for all registered agents.""" - if not self._store.agent_user_ids: + if not self.async_get_agent_users(): return 204 res = await gather( *( self.async_sync_entities(agent_user_id) - for agent_user_id in self._store.agent_user_ids + for agent_user_id in self.async_get_agent_users() ) ) return max(res, default=204) @@ -259,13 +255,13 @@ class AbstractConfig(ABC): self, event_id: str, payload: dict[str, Any] ) -> HTTPStatus: """Sync notification to Google for all registered agents.""" - if not self._store.agent_user_ids: + if not self.async_get_agent_users(): return HTTPStatus.NO_CONTENT res = await gather( *( self.async_sync_notification(agent_user_id, event_id, payload) - for agent_user_id in self._store.agent_user_ids + for agent_user_id in self.async_get_agent_users() ) ) return max(res, default=HTTPStatus.NO_CONTENT) @@ -288,7 +284,7 @@ class AbstractConfig(ABC): @callback def async_schedule_google_sync_all(self) -> None: """Schedule a sync for all registered agents.""" - for agent_user_id in self._store.agent_user_ids: + for agent_user_id in self.async_get_agent_users(): self.async_schedule_google_sync(agent_user_id) async def _async_request_sync_devices(self, agent_user_id: str) -> int: @@ -298,13 +294,14 @@ class AbstractConfig(ABC): """ raise NotImplementedError + @abstractmethod async def async_connect_agent_user(self, agent_user_id: str): """Add a synced and known agent_user_id. Called before sending a sync response to Google. """ - self._store.add_agent_user_id(agent_user_id) + @abstractmethod async def async_disconnect_agent_user(self, agent_user_id: str): """Turn off report state and disable further state reporting. @@ -313,7 +310,11 @@ class AbstractConfig(ABC): - When the cloud configuration is initialized - When sync entities fails with 404 """ - self._store.pop_agent_user_id(agent_user_id) + + @callback + @abstractmethod + def async_get_agent_users(self) -> Collection[str]: + """Return known agent users.""" @callback def async_enable_local_sdk(self) -> None: @@ -327,7 +328,7 @@ class AbstractConfig(ABC): self._local_sdk_active = False return - for user_agent_id in self._store.agent_user_ids: + for user_agent_id in self.async_get_agent_users(): if (webhook_id := self.get_local_webhook_id(user_agent_id)) is None: setup_successful = False break @@ -372,7 +373,7 @@ class AbstractConfig(ABC): if not self._local_sdk_active: return - for agent_user_id in self._store.agent_user_ids: + for agent_user_id in self.async_get_agent_users(): webhook_id = self.get_local_webhook_id(agent_user_id) _LOGGER.debug( "Unregister webhook handler %s for agent user id %s", @@ -415,7 +416,7 @@ class AbstractConfig(ABC): pprint.pformat(async_redact_request_msg(payload)), ) - if (agent_user_id := self.get_local_agent_user_id(webhook_id)) is None: + if (agent_user_id := self.get_local_user_id(webhook_id)) is None: # No agent user linked to this webhook, means that the user has somehow unregistered # removing webhook and stopping processing of this request. _LOGGER.error( @@ -451,65 +452,6 @@ class AbstractConfig(ABC): return json_response(result) -class GoogleConfigStore: - """A configuration store for google assistant.""" - - _STORAGE_VERSION = 1 - _STORAGE_KEY = DOMAIN - - def __init__(self, hass): - """Initialize a configuration store.""" - self._hass = hass - self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) - self._data = None - - async def async_initialize(self): - """Finish initializing the ConfigStore.""" - should_save_data = False - if (data := await self._store.async_load()) is None: - # if the store is not found create an empty one - # Note that the first request is always a cloud request, - # and that will store the correct agent user id to be used for local requests - data = { - STORE_AGENT_USER_IDS: {}, - } - should_save_data = True - - for agent_user_id, agent_user_data in data[STORE_AGENT_USER_IDS].items(): - if STORE_GOOGLE_LOCAL_WEBHOOK_ID not in agent_user_data: - data[STORE_AGENT_USER_IDS][agent_user_id] = { - **agent_user_data, - STORE_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), - } - should_save_data = True - - if should_save_data: - await self._store.async_save(data) - - self._data = data - - @property - def agent_user_ids(self): - """Return a list of connected agent user_ids.""" - return self._data[STORE_AGENT_USER_IDS] - - @callback - def add_agent_user_id(self, agent_user_id): - """Add an agent user id to store.""" - if agent_user_id not in self._data[STORE_AGENT_USER_IDS]: - self._data[STORE_AGENT_USER_IDS][agent_user_id] = { - STORE_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), - } - self._store.async_delay_save(lambda: self._data, 1.0) - - @callback - def pop_agent_user_id(self, agent_user_id): - """Remove agent user id from store.""" - if agent_user_id in self._data[STORE_AGENT_USER_IDS]: - self._data[STORE_AGENT_USER_IDS].pop(agent_user_id, None) - self._store.async_delay_save(lambda: self._data, 1.0) - - class RequestData: """Hold data associated with a particular request.""" diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index c0e4f715c16..0eaed0ca48a 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -1,7 +1,6 @@ """Support for Google Actions Smart Home Control.""" from __future__ import annotations -import asyncio from datetime import timedelta from http import HTTPStatus import logging @@ -12,14 +11,15 @@ from aiohttp import ClientError, ClientResponseError from aiohttp.web import Request, Response import jwt +from homeassistant.components import webhook from homeassistant.components.http import HomeAssistantView from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES - -# Typing imports -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util import dt as dt_util +from homeassistant.helpers.storage import STORAGE_DIR, Store +from homeassistant.util import dt as dt_util, json as json_util from .const import ( CONF_CLIENT_EMAIL, @@ -31,12 +31,15 @@ from .const import ( CONF_REPORT_STATE, CONF_SECURE_DEVICES_PIN, CONF_SERVICE_ACCOUNT, + DOMAIN, GOOGLE_ASSISTANT_API_ENDPOINT, HOMEGRAPH_SCOPE, HOMEGRAPH_TOKEN_URL, REPORT_STATE_BASE_URL, REQUEST_SYNC_BASE_URL, SOURCE_CLOUD, + STORE_AGENT_USER_IDS, + STORE_GOOGLE_LOCAL_WEBHOOK_ID, ) from .helpers import AbstractConfig from .smart_home import async_handle_message @@ -78,6 +81,8 @@ async def _get_homegraph_token( class GoogleConfig(AbstractConfig): """Config for manual setup of Google.""" + _store: GoogleConfigStore + def __init__(self, hass, config): """Initialize the config.""" super().__init__(hass) @@ -87,6 +92,10 @@ class GoogleConfig(AbstractConfig): async def async_initialize(self): """Perform async initialization of config.""" + # We need to initialize the store before calling super + self._store = GoogleConfigStore(self.hass) + await self._store.async_initialize() + await super().async_initialize() self.async_enable_local_sdk() @@ -111,6 +120,34 @@ class GoogleConfig(AbstractConfig): """Return if states should be proactively reported.""" return self._config.get(CONF_REPORT_STATE) + def get_local_user_id(self, webhook_id): + """Map webhook ID to a Home Assistant user ID. + + Any action inititated by Google Assistant via the local SDK will be attributed + to the returned user ID. + + Return None if no user id is found for the webhook_id. + """ + # Note: The manually setup Google Assistant currently returns the Google agent + # user ID instead of a valid Home Assistant user ID + found_agent_user_id = None + for agent_user_id, agent_user_data in self._store.agent_user_ids.items(): + if agent_user_data[STORE_GOOGLE_LOCAL_WEBHOOK_ID] == webhook_id: + found_agent_user_id = agent_user_id + break + + return found_agent_user_id + + def get_local_webhook_id(self, agent_user_id): + """Return the webhook ID to be used for actions for a given agent user id via the local SDK.""" + if data := self._store.agent_user_ids.get(agent_user_id): + return data[STORE_GOOGLE_LOCAL_WEBHOOK_ID] + return None + + def get_agent_user_id(self, context): + """Get agent user ID making request.""" + return context.user_id + def should_expose(self, state) -> bool: """Return if entity should be exposed.""" expose_by_default = self._config.get(CONF_EXPOSE_BY_DEFAULT) @@ -150,10 +187,6 @@ class GoogleConfig(AbstractConfig): return is_default_exposed or explicit_expose - def get_agent_user_id(self, context): - """Get agent user ID making request.""" - return context.user_id - def should_2fa(self, state): """If an entity should have 2FA checked.""" return True @@ -167,6 +200,28 @@ class GoogleConfig(AbstractConfig): _LOGGER.error("No configuration for request_sync available") return HTTPStatus.INTERNAL_SERVER_ERROR + async def async_connect_agent_user(self, agent_user_id: str): + """Add a synced and known agent_user_id. + + Called before sending a sync response to Google. + """ + self._store.add_agent_user_id(agent_user_id) + + async def async_disconnect_agent_user(self, agent_user_id: str): + """Turn off report state and disable further state reporting. + + Called when: + - The user disconnects their account from Google. + - When the cloud configuration is initialized + - When sync entities fails with 404 + """ + self._store.pop_agent_user_id(agent_user_id) + + @callback + def async_get_agent_users(self): + """Return known agent users.""" + return self._store.agent_user_ids + async def _async_update_token(self, force=False): if CONF_SERVICE_ACCOUNT not in self._config: _LOGGER.error("Trying to get homegraph api token without service account") @@ -216,7 +271,7 @@ class GoogleConfig(AbstractConfig): except ClientResponseError as error: _LOGGER.error("Request for %s failed: %d", url, error.status) return error.status - except (asyncio.TimeoutError, ClientError): + except (TimeoutError, ClientError): _LOGGER.error("Could not contact %s", url) return HTTPStatus.INTERNAL_SERVER_ERROR @@ -234,6 +289,71 @@ class GoogleConfig(AbstractConfig): return await self.async_call_homegraph_api(REPORT_STATE_BASE_URL, data) +class GoogleConfigStore: + """A configuration store for google assistant.""" + + _STORAGE_VERSION = 1 + _STORAGE_VERSION_MINOR = 2 + _STORAGE_KEY = DOMAIN + _data: dict[str, Any] + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize a configuration store.""" + self._hass = hass + self._store: Store[dict[str, Any]] = Store( + hass, + self._STORAGE_VERSION, + self._STORAGE_KEY, + minor_version=self._STORAGE_VERSION_MINOR, + ) + + async def async_initialize(self) -> None: + """Finish initializing the ConfigStore.""" + should_save_data = False + if (data := await self._store.async_load()) is None: + # if the store is not found create an empty one + # Note that the first request is always a cloud request, + # and that will store the correct agent user id to be used for local requests + data = { + STORE_AGENT_USER_IDS: {}, + } + should_save_data = True + + for agent_user_id, agent_user_data in data[STORE_AGENT_USER_IDS].items(): + if STORE_GOOGLE_LOCAL_WEBHOOK_ID not in agent_user_data: + data[STORE_AGENT_USER_IDS][agent_user_id] = { + **agent_user_data, + STORE_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), + } + should_save_data = True + + if should_save_data: + await self._store.async_save(data) + + self._data = data + + @property + def agent_user_ids(self) -> dict[str, Any]: + """Return a list of connected agent user_ids.""" + return self._data[STORE_AGENT_USER_IDS] + + @callback + def add_agent_user_id(self, agent_user_id: str) -> None: + """Add an agent user id to store.""" + if agent_user_id not in self._data[STORE_AGENT_USER_IDS]: + self._data[STORE_AGENT_USER_IDS][agent_user_id] = { + STORE_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), + } + self._store.async_delay_save(lambda: self._data, 1.0) + + @callback + def pop_agent_user_id(self, agent_user_id: str) -> None: + """Remove agent user id from store.""" + if agent_user_id in self._data[STORE_AGENT_USER_IDS]: + self._data[STORE_AGENT_USER_IDS].pop(agent_user_id, None) + self._store.async_delay_save(lambda: self._data, 1.0) + + class GoogleAssistantView(HomeAssistantView): """Handle Google Assistant requests.""" @@ -256,3 +376,26 @@ class GoogleAssistantView(HomeAssistantView): SOURCE_CLOUD, ) return self.json(result) + + +async def async_get_users(hass: HomeAssistant) -> list[str]: + """Return stored users. + + This is called by the cloud integration to import from the previously shared store. + """ + # pylint: disable-next=protected-access + path = hass.config.path(STORAGE_DIR, GoogleConfigStore._STORAGE_KEY) + try: + store_data = await hass.async_add_executor_job(json_util.load_json, path) + except HomeAssistantError: + return [] + + if ( + not isinstance(store_data, dict) + or not (data := store_data.get("data")) + or not isinstance(data, dict) + or not (agent_user_ids := data.get("agent_user_ids")) + or not isinstance(agent_user_ids, dict) + ): + return [] + return list(agent_user_ids) diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 7d8cc752342..19f097151d7 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -255,7 +255,7 @@ async def handle_devices_execute( for entity_id, result in zip(executions, execute_results): if result is not None: results[entity_id] = result - except asyncio.TimeoutError: + except TimeoutError: pass final_results = list(results.values()) diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 720c7d9aa2b..8f30448ad61 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -292,7 +292,7 @@ class GoogleCloudTTSProvider(Provider): ) return _encoding, response.audio_content - except asyncio.TimeoutError as ex: + except TimeoutError as ex: _LOGGER.error("Timeout for Google Cloud TTS call: %s", ex) except Exception as ex: # pylint: disable=broad-except _LOGGER.exception("Error occurred during Google Cloud TTS call: %s", ex) diff --git a/homeassistant/components/google_domains/__init__.py b/homeassistant/components/google_domains/__init__.py index 52dcdb61e8f..1d420cb1497 100644 --- a/homeassistant/components/google_domains/__init__.py +++ b/homeassistant/components/google_domains/__init__.py @@ -80,7 +80,7 @@ async def _update_google_domains(hass, session, domain, user, password, timeout) except aiohttp.ClientError: _LOGGER.warning("Can't connect to Google Domains API") - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Timeout from Google Domains API for domain: %s", domain) return False diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index a522eeab5cd..73450e9f5b9 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -63,7 +63,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: for image_filename in image_filenames: if not hass.config.is_allowed_path(image_filename): raise HomeAssistantError( - f"Cannot read `{image_filename}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" + f"Cannot read `{image_filename}`, no access to path; " + "`allowlist_external_dirs` may need to be adjusted in " + "`configuration.yaml`" ) if not Path(image_filename).exists(): raise HomeAssistantError(f"`{image_filename}` does not exist") diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 76e6135b14d..306072f33a8 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -35,7 +35,7 @@ "prompt": { "name": "Prompt", "description": "The prompt", - "example": "Describe what you see in these images:" + "example": "Describe what you see in these images" }, "image_filename": { "name": "Image filename", diff --git a/homeassistant/components/govee_light_local/__init__.py b/homeassistant/components/govee_light_local/__init__.py index ab20f4cefcd..2d4594755c4 100644 --- a/homeassistant/components/govee_light_local/__init__.py +++ b/homeassistant/components/govee_light_local/__init__.py @@ -28,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async with asyncio.timeout(delay=5): while not coordinator.devices: await asyncio.sleep(delay=1) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise ConfigEntryNotReady from ex hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/govee_light_local/config_flow.py b/homeassistant/components/govee_light_local/config_flow.py index 8ab14966828..8058668f0ca 100644 --- a/homeassistant/components/govee_light_local/config_flow.py +++ b/homeassistant/components/govee_light_local/config_flow.py @@ -44,7 +44,7 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: async with asyncio.timeout(delay=5): while not controller.devices: await asyncio.sleep(delay=1) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.debug("No devices found") devices_count = len(controller.devices) diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index 8d50cdf2aed..1d061c06901 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -113,6 +113,8 @@ class GreeClimateEntity(GreeEntity, ClimateEntity): | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = TARGET_TEMPERATURE_STEP _attr_hvac_modes = [*HVAC_MODES_REVERSE, HVACMode.OFF] @@ -120,6 +122,7 @@ class GreeClimateEntity(GreeEntity, ClimateEntity): _attr_fan_modes = [*FAN_MODES_REVERSE] _attr_swing_modes = SWING_MODES _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: DeviceDataUpdateCoordinator) -> None: """Initialize the Gree device.""" diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 3c8f7059901..8e1a0a24207 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -1,4 +1,5 @@ """Platform allowing several sensors to be grouped into one sensor to provide numeric combinations.""" + from __future__ import annotations from collections.abc import Callable @@ -13,6 +14,7 @@ from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.sensor import ( CONF_STATE_CLASS, + DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, DOMAIN, PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, @@ -313,6 +315,7 @@ class SensorGroup(GroupEntity, SensorEntity): self._device_class = device_class self._native_unit_of_measurement = unit_of_measurement self._valid_units: set[str | None] = set() + self._can_convert: bool = False self._attr_name = name if name == DEFAULT_NAME: self._attr_name = f"{DEFAULT_NAME} {sensor_type}".capitalize() @@ -352,10 +355,18 @@ class SensorGroup(GroupEntity, SensorEntity): self._valid_units and (uom := state.attributes["unit_of_measurement"]) in self._valid_units + and self._can_convert is True ): numeric_state = UNIT_CONVERTERS[self.device_class].convert( numeric_state, uom, self.native_unit_of_measurement ) + if ( + self._valid_units + and (uom := state.attributes["unit_of_measurement"]) + not in self._valid_units + ): + raise HomeAssistantError("Not a valid unit") + sensor_values.append((entity_id, numeric_state, state)) if entity_id in self._state_incorrect: self._state_incorrect.remove(entity_id) @@ -465,7 +476,7 @@ class SensorGroup(GroupEntity, SensorEntity): translation_placeholders={ "entity_id": self.entity_id, "source_entities": ", ".join(self._entity_ids), - "state_classes:": ", ".join(state_classes), + "state_classes": ", ".join(state_classes), }, ) return None @@ -508,7 +519,7 @@ class SensorGroup(GroupEntity, SensorEntity): translation_placeholders={ "entity_id": self.entity_id, "source_entities": ", ".join(self._entity_ids), - "device_classes:": ", ".join(device_classes), + "device_classes": ", ".join(device_classes), }, ) return None @@ -536,8 +547,21 @@ class SensorGroup(GroupEntity, SensorEntity): unit_of_measurements.append(_unit_of_measurement) # Ensure only valid unit of measurements for the specific device class can be used - if (device_class := self.device_class) in UNIT_CONVERTERS and all( - x in UNIT_CONVERTERS[device_class].VALID_UNITS for x in unit_of_measurements + if ( + # Test if uom's in device class is convertible + (device_class := self.device_class) in UNIT_CONVERTERS + and all( + uom in UNIT_CONVERTERS[device_class].VALID_UNITS + for uom in unit_of_measurements + ) + ) or ( + # Test if uom's in device class is not convertible + device_class + and device_class not in UNIT_CONVERTERS + and device_class in DEVICE_CLASS_UNITS + and all( + uom in DEVICE_CLASS_UNITS[device_class] for uom in unit_of_measurements + ) ): async_delete_issue( self.hass, DOMAIN, f"{self.entity_id}_uoms_not_matching_device_class" @@ -546,6 +570,7 @@ class SensorGroup(GroupEntity, SensorEntity): self.hass, DOMAIN, f"{self.entity_id}_uoms_not_matching_no_device_class" ) return unit_of_measurements[0] + if device_class: async_create_issue( self.hass, @@ -587,5 +612,13 @@ class SensorGroup(GroupEntity, SensorEntity): if ( device_class := self.device_class ) in UNIT_CONVERTERS and self.native_unit_of_measurement: + self._can_convert = True return UNIT_CONVERTERS[device_class].VALID_UNITS + if ( + device_class + and (device_class) in DEVICE_CLASS_UNITS + and self.native_unit_of_measurement + ): + valid_uoms: set = DEVICE_CLASS_UNITS[device_class] + return valid_uoms return set() diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index 25ae20da995..ba571bb1008 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -265,7 +265,7 @@ }, "state_classes_not_matching": { "title": "State classes is not correct", - "description": "Device classes `{state_classes}` on source entities `{source_entities}` needs to be same for sensor group `{entity_id}`.\n\nPlease correct the state classes on the source entities and reload the group sensor to fix this issue." + "description": "State classes `{state_classes}` on source entities `{source_entities}` needs to be same for sensor group `{entity_id}`.\n\nPlease correct the state classes on the source entities and reload the group sensor to fix this issue." } } } diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index 4e548ef2c2a..e4e7c638fa3 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -8,13 +8,16 @@ DEFAULT_PLANT_ID = "0" DEFAULT_NAME = "Growatt" SERVER_URLS = [ - "https://server-api.growatt.com/", - "https://server-us.growatt.com/", - "http://server.smten.com/", + "https://openapi.growatt.com/", # Other regional server + "https://openapi-cn.growatt.com/", # Chinese server + "https://openapi-us.growatt.com/", # North American server + "http://server.smten.com/", # smten server ] DEPRECATED_URLS = [ "https://server.growatt.com/", + "https://server-api.growatt.com/", + "https://server-us.growatt.com/", ] DEFAULT_URL = SERVER_URLS[0] diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index ffa57322551..a5e91dce813 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -48,9 +48,10 @@ def async_finish_entity_domain_replacements( try: [registry_entry] = [ registry_entry - for registry_entry in ent_reg.entities.values() - if registry_entry.config_entry_id == entry.entry_id - and registry_entry.domain == strategy.old_domain + for registry_entry in er.async_entries_for_config_entry( + ent_reg, entry.entry_id + ) + if registry_entry.domain == strategy.old_domain and registry_entry.unique_id == strategy.old_unique_id ] except ValueError: diff --git a/homeassistant/components/hardkernel/__init__.py b/homeassistant/components/hardkernel/__init__.py index c81ad7860be..41e65ff8b5e 100644 --- a/homeassistant/components/hardkernel/__init__.py +++ b/homeassistant/components/hardkernel/__init__.py @@ -1,7 +1,7 @@ """The Hardkernel integration.""" from __future__ import annotations -from homeassistant.components.hassio import get_os_info +from homeassistant.components.hassio import get_os_info, is_hassio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -9,6 +9,11 @@ from homeassistant.exceptions import ConfigEntryNotReady async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Hardkernel config entry.""" + if not is_hassio(hass): + # Not running under supervisor, Home Assistant may have been migrated + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False + if (os_info := get_os_info(hass)) is None: # The hassio integration has not yet fetched data from the supervisor raise ConfigEntryNotReady diff --git a/homeassistant/components/hardkernel/manifest.json b/homeassistant/components/hardkernel/manifest.json index 1b29f0b0b22..2a528a5173e 100644 --- a/homeassistant/components/hardkernel/manifest.json +++ b/homeassistant/components/hardkernel/manifest.json @@ -1,9 +1,10 @@ { "domain": "hardkernel", "name": "Hardkernel", + "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/core"], "config_flow": false, - "dependencies": ["hardware", "hassio"], + "dependencies": ["hardware"], "documentation": "https://www.home-assistant.io/integrations/hardkernel", "integration_type": "hardware" } diff --git a/homeassistant/components/harmony/data.py b/homeassistant/components/harmony/data.py index 44c0fde19c1..f7eb96d6a8f 100644 --- a/homeassistant/components/harmony/data.py +++ b/homeassistant/components/harmony/data.py @@ -1,7 +1,6 @@ """Harmony data object which contains the Harmony Client.""" from __future__ import annotations -import asyncio from collections.abc import Iterable import logging @@ -121,7 +120,7 @@ class HarmonyData(HarmonySubscriberMixin): connected = False try: connected = await self._client.connect() - except (asyncio.TimeoutError, aioexc.TimeOut) as err: + except (TimeoutError, aioexc.TimeOut) as err: await self._client.close() raise ConfigEntryNotReady( f"{self._name}: Connection timed-out to {self._address}:8088" diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 87860644754..1472843e14d 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -1001,12 +1001,18 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=has raise_on_entry_error: bool = False, ) -> None: """Refresh data.""" - if not scheduled: + if not scheduled and not raise_on_auth_failed: # Force refreshing updates for non-scheduled updates + # If `raise_on_auth_failed` is set, it means this is + # the first refresh and we do not want to delay + # startup or cause a timeout so we only refresh the + # updates if this is not a scheduled refresh and + # we are not doing the first refresh. try: await self.hassio.refresh_updates() except HassioAPIError as err: _LOGGER.warning("Error on Supervisor API: %s", err) + await super()._async_refresh( log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error ) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index c3532d553f4..38762fbc648 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -2,11 +2,11 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine from http import HTTPStatus import logging import os -from typing import Any +from typing import Any, ParamSpec import aiohttp from yarl import URL @@ -23,6 +23,8 @@ from homeassistant.loader import bind_hass from .const import ATTR_DISCOVERY, DOMAIN, X_HASS_SOURCE +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) @@ -30,10 +32,12 @@ class HassioAPIError(RuntimeError): """Return if a API trow a error.""" -def _api_bool(funct): +def _api_bool( + funct: Callable[_P, Coroutine[Any, Any, dict[str, Any]]], +) -> Callable[_P, Coroutine[Any, Any, bool]]: """Return a boolean.""" - async def _wrapper(*argv, **kwargs): + async def _wrapper(*argv: _P.args, **kwargs: _P.kwargs) -> bool: """Wrap function.""" try: data = await funct(*argv, **kwargs) @@ -44,10 +48,12 @@ def _api_bool(funct): return _wrapper -def api_data(funct): +def api_data( + funct: Callable[_P, Coroutine[Any, Any, dict[str, Any]]], +) -> Callable[_P, Coroutine[Any, Any, Any]]: """Return data of an api.""" - async def _wrapper(*argv, **kwargs): + async def _wrapper(*argv: _P.args, **kwargs: _P.kwargs) -> Any: """Wrap function.""" data = await funct(*argv, **kwargs) if data["result"] == "ok": @@ -80,7 +86,7 @@ async def async_get_addon_store_info(hass: HomeAssistant, slug: str) -> dict: @bind_hass -async def async_update_diagnostics(hass: HomeAssistant, diagnostics: bool) -> dict: +async def async_update_diagnostics(hass: HomeAssistant, diagnostics: bool) -> bool: """Update Supervisor diagnostics toggle. The caller of the function should handle HassioAPIError. @@ -255,7 +261,7 @@ async def async_update_core( @bind_hass @_api_bool -async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> bool: +async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> dict: """Apply a suggestion from supervisor's resolution center. The caller of the function should handle HassioAPIError. @@ -459,7 +465,7 @@ class HassIO: This method returns a coroutine. """ - return self.send_command("/refresh_updates", timeout=None) + return self.send_command("/refresh_updates", timeout=300) @api_data def retrieve_discovery_messages(self) -> Coroutine: @@ -506,7 +512,6 @@ class HassIO: options = { "ssl": CONF_SSL_CERTIFICATE in http_config, "port": port, - "watchdog": True, "refresh_token": refresh_token.token, } @@ -593,7 +598,7 @@ class HassIO: return await request.json(encoding="utf-8") - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Timeout on %s request", command) except aiohttp.ClientError as err: diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 9d72d5842fd..8ba389f9054 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -1,7 +1,6 @@ """HTTP Support for Hass.io.""" from __future__ import annotations -import asyncio from http import HTTPStatus import logging import os @@ -193,7 +192,7 @@ class HassIOView(HomeAssistantView): except aiohttp.ClientError as err: _LOGGER.error("Client error on api %s request %s", path, err) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Client timeout error on API request %s", path) raise HTTPBadGateway() diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 4f3933d0f5c..499a83b0444 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -288,13 +288,13 @@ async def _websocket_forward( """Handle websocket message directly.""" try: async for msg in ws_from: - if msg.type == aiohttp.WSMsgType.TEXT: + if msg.type is aiohttp.WSMsgType.TEXT: await ws_to.send_str(msg.data) - elif msg.type == aiohttp.WSMsgType.BINARY: + elif msg.type is aiohttp.WSMsgType.BINARY: await ws_to.send_bytes(msg.data) - elif msg.type == aiohttp.WSMsgType.PING: + elif msg.type is aiohttp.WSMsgType.PING: await ws_to.ping() - elif msg.type == aiohttp.WSMsgType.PONG: + elif msg.type is aiohttp.WSMsgType.PONG: await ws_to.pong() elif ws_to.closed: await ws_to.close(code=ws_to.close_code, message=msg.extra) # type: ignore[arg-type] diff --git a/homeassistant/components/heatmiser/climate.py b/homeassistant/components/heatmiser/climate.py index 24a0c88b45a..566a4696a73 100644 --- a/homeassistant/components/heatmiser/climate.py +++ b/homeassistant/components/heatmiser/climate.py @@ -76,7 +76,12 @@ class HeatmiserV3Thermostat(ClimateEntity): """Representation of a HeatmiserV3 thermostat.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, therm, device, uh1): """Initialize the thermostat.""" diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py index ca5ec694eab..0e3fa9981c1 100644 --- a/homeassistant/components/hisense_aehw4a1/climate.py +++ b/homeassistant/components/hisense_aehw4a1/climate.py @@ -144,6 +144,8 @@ class ClimateAehW4a1(ClimateEntity): | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_fan_modes = FAN_MODES _attr_swing_modes = SWING_MODES @@ -152,6 +154,7 @@ class ClimateAehW4a1(ClimateEntity): _attr_target_temperature_step = 1 _previous_state: HVACMode | str | None = None _on: str | None = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, device): """Initialize the climate device.""" diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 25422004797..f903a9904a9 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -179,8 +179,8 @@ def _generate_stream_message( """Generate a history stream message response.""" return { "states": states, - "start_time": dt_util.utc_to_timestamp(start_day), - "end_time": dt_util.utc_to_timestamp(end_day), + "start_time": start_day.timestamp(), + "end_time": end_day.timestamp(), } diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index 99de8b99675..8085719d8c5 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -92,8 +92,12 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.AUTO, HVACMode.HEAT, HVACMode.OFF] _attr_preset_modes = [PRESET_BOOST, PRESET_NONE] _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, hive_session, hive_device): """Initialize the Climate device.""" diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index a340aee0764..d173751c6c8 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -54,6 +54,7 @@ class HiveDeviceLight(HiveEntity, LightEntity): self._attr_color_mode = ColorMode.COLOR_TEMP elif self.device["hiveType"] == "colourtuneablelight": self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} + self._attr_color_mode = ColorMode.UNKNOWN self._attr_min_mireds = 153 self._attr_max_mireds = 370 diff --git a/homeassistant/components/hlk_sw16/config_flow.py b/homeassistant/components/hlk_sw16/config_flow.py index 01f695ad1a6..6ea5f9d43db 100644 --- a/homeassistant/components/hlk_sw16/config_flow.py +++ b/homeassistant/components/hlk_sw16/config_flow.py @@ -43,7 +43,7 @@ async def validate_input(hass: HomeAssistant, user_input): """Validate the user input allows us to connect.""" try: client = await connect_client(hass, user_input) - except asyncio.TimeoutError as err: + except TimeoutError as err: raise CannotConnect from err try: diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 4d26e93e591..0608f8c404e 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.41", "babel==2.13.1"] + "requirements": ["holidays==0.42", "babel==2.13.1"] } diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 9eabc9b5d43..5b0a9e3e9d8 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -10,10 +10,14 @@ BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On" BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off" BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby" BSH_ACTIVE_PROGRAM = "BSH.Common.Root.ActiveProgram" -BSH_OPERATION_STATE = "BSH.Common.Status.OperationState" BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive" BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed" +BSH_OPERATION_STATE = "BSH.Common.Status.OperationState" +BSH_OPERATION_STATE_RUN = "BSH.Common.EnumType.OperationState.Run" +BSH_OPERATION_STATE_PAUSE = "BSH.Common.EnumType.OperationState.Pause" +BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished" + COOKING_LIGHTING = "Cooking.Common.Setting.Lighting" COOKING_LIGHTING_BRIGHTNESS = "Cooking.Common.Setting.LightingBrightness" diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 07edfb4bd4b..a01cae5862a 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -10,7 +10,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import ATTR_VALUE, BSH_OPERATION_STATE, DOMAIN +from .const import ( + ATTR_VALUE, + BSH_OPERATION_STATE, + BSH_OPERATION_STATE_FINISHED, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_RUN, + DOMAIN, +) from .entity import HomeConnectEntity _LOGGER = logging.getLogger(__name__) @@ -69,9 +76,20 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): # if the date is supposed to be in the future but we're # already past it, set state to None. self._attr_native_value = None - else: + elif ( + BSH_OPERATION_STATE in status + and ATTR_VALUE in status[BSH_OPERATION_STATE] + and status[BSH_OPERATION_STATE][ATTR_VALUE] + in [ + BSH_OPERATION_STATE_RUN, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_FINISHED, + ] + ): seconds = self._sign * float(status[self._key][ATTR_VALUE]) self._attr_native_value = dt_util.utcnow() + timedelta(seconds=seconds) + else: + self._attr_native_value = None else: self._attr_native_value = status[self._key].get(ATTR_VALUE) if self._key == BSH_OPERATION_STATE: diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index 036eb07e067..f391b990761 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -1,7 +1,6 @@ """The Home Assistant alerts integration.""" from __future__ import annotations -import asyncio import dataclasses from datetime import timedelta import logging @@ -53,7 +52,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: f"https://alerts.home-assistant.io/alerts/{alert.alert_id}.json", timeout=aiohttp.ClientTimeout(total=30), ) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Error fetching %s: timeout", alert.filename) continue diff --git a/homeassistant/components/homeassistant_green/__init__.py b/homeassistant/components/homeassistant_green/__init__.py index fbcd2093778..ed86723ab94 100644 --- a/homeassistant/components/homeassistant_green/__init__.py +++ b/homeassistant/components/homeassistant_green/__init__.py @@ -1,7 +1,7 @@ """The Home Assistant Green integration.""" from __future__ import annotations -from homeassistant.components.hassio import get_os_info +from homeassistant.components.hassio import get_os_info, is_hassio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -9,6 +9,11 @@ from homeassistant.exceptions import ConfigEntryNotReady async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant Green config entry.""" + if not is_hassio(hass): + # Not running under supervisor, Home Assistant may have been migrated + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False + if (os_info := get_os_info(hass)) is None: # The hassio integration has not yet fetched data from the supervisor raise ConfigEntryNotReady diff --git a/homeassistant/components/homeassistant_green/manifest.json b/homeassistant/components/homeassistant_green/manifest.json index 7c9dd0322ec..d543d562ee3 100644 --- a/homeassistant/components/homeassistant_green/manifest.json +++ b/homeassistant/components/homeassistant_green/manifest.json @@ -1,9 +1,10 @@ { "domain": "homeassistant_green", "name": "Home Assistant Green", + "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/core"], "config_flow": false, - "dependencies": ["hardware", "hassio", "homeassistant_hardware"], + "dependencies": ["hardware", "homeassistant_hardware"], "documentation": "https://www.home-assistant.io/integrations/homeassistant_green", "integration_type": "hardware" } diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index b61e01061c3..092911d1532 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -1,7 +1,7 @@ """The Home Assistant Yellow integration.""" from __future__ import annotations -from homeassistant.components.hassio import get_os_info +from homeassistant.components.hassio import get_os_info, is_hassio from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( check_multi_pan_addon, get_zigbee_socket, @@ -16,6 +16,11 @@ from .const import RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant Yellow config entry.""" + if not is_hassio(hass): + # Not running under supervisor, Home Assistant may have been migrated + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False + if (os_info := get_os_info(hass)) is None: # The hassio integration has not yet fetched data from the supervisor raise ConfigEntryNotReady diff --git a/homeassistant/components/homeassistant_yellow/manifest.json b/homeassistant/components/homeassistant_yellow/manifest.json index dd74df9295f..a9715003172 100644 --- a/homeassistant/components/homeassistant_yellow/manifest.json +++ b/homeassistant/components/homeassistant_yellow/manifest.json @@ -1,9 +1,10 @@ { "domain": "homeassistant_yellow", "name": "Home Assistant Yellow", + "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/core"], "config_flow": false, - "dependencies": ["hardware", "hassio", "homeassistant_hardware"], + "dependencies": ["hardware", "homeassistant_hardware"], "documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow", "integration_type": "hardware" } diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index a6984ae2121..d7c8ea65e2d 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -7,7 +7,7 @@ from operator import itemgetter import random import re import string -from typing import Any +from typing import Any, Final, TypedDict import voluptuous as vol @@ -34,12 +34,6 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.entityfilter import ( - CONF_EXCLUDE_DOMAINS, - CONF_EXCLUDE_ENTITIES, - CONF_INCLUDE_DOMAINS, - CONF_INCLUDE_ENTITIES, -) from homeassistant.loader import async_get_integrations from .const import ( @@ -69,13 +63,13 @@ MODE_EXCLUDE = "exclude" INCLUDE_EXCLUDE_MODES = [MODE_EXCLUDE, MODE_INCLUDE] -DOMAINS_NEED_ACCESSORY_MODE = [ +DOMAINS_NEED_ACCESSORY_MODE = { CAMERA_DOMAIN, LOCK_DOMAIN, MEDIA_PLAYER_DOMAIN, REMOTE_DOMAIN, -] -NEVER_BRIDGED_DOMAINS = [CAMERA_DOMAIN] +} +NEVER_BRIDGED_DOMAINS = {CAMERA_DOMAIN} CAMERA_ENTITY_PREFIX = f"{CAMERA_DOMAIN}." @@ -124,12 +118,34 @@ DEFAULT_DOMAINS = [ "water_heater", ] -_EMPTY_ENTITY_FILTER: dict[str, list[str]] = { - CONF_INCLUDE_DOMAINS: [], - CONF_EXCLUDE_DOMAINS: [], - CONF_INCLUDE_ENTITIES: [], - CONF_EXCLUDE_ENTITIES: [], -} +CONF_INCLUDE_DOMAINS: Final = "include_domains" +CONF_INCLUDE_ENTITIES: Final = "include_entities" +CONF_EXCLUDE_DOMAINS: Final = "exclude_domains" +CONF_EXCLUDE_ENTITIES: Final = "exclude_entities" + + +class EntityFilterDict(TypedDict, total=False): + """Entity filter dict.""" + + include_domains: list[str] + include_entities: list[str] + exclude_domains: list[str] + exclude_entities: list[str] + + +def _make_entity_filter( + include_domains: list[str] | None = None, + include_entities: list[str] | None = None, + exclude_domains: list[str] | None = None, + exclude_entities: list[str] | None = None, +) -> EntityFilterDict: + """Create a filter dict.""" + return EntityFilterDict( + include_domains=include_domains or [], + include_entities=include_entities or [], + exclude_domains=exclude_domains or [], + exclude_entities=exclude_entities or [], + ) async def _async_domain_names(hass: HomeAssistant, domains: list[str]) -> str: @@ -141,19 +157,18 @@ async def _async_domain_names(hass: HomeAssistant, domains: list[str]) -> str: @callback -def _async_build_entites_filter( +def _async_build_entities_filter( domains: list[str], entities: list[str] -) -> dict[str, Any]: +) -> EntityFilterDict: """Build an entities filter from domains and entities.""" - entity_filter = deepcopy(_EMPTY_ENTITY_FILTER) - entity_filter[CONF_INCLUDE_ENTITIES] = entities # Include all of the domain if there are no entities # explicitly included as the user selected the domain - domains_with_entities_selected = _domains_set_from_entities(entities) - entity_filter[CONF_INCLUDE_DOMAINS] = [ - domain for domain in domains if domain not in domains_with_entities_selected - ] - return entity_filter + return _make_entity_filter( + include_domains=sorted( + set(domains).difference(_domains_set_from_entities(entities)) + ), + include_entities=entities, + ) def _async_cameras_from_entities(entities: list[str]) -> dict[str, str]: @@ -190,13 +205,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Choose specific domains in bridge mode.""" if user_input is not None: - entity_filter = deepcopy(_EMPTY_ENTITY_FILTER) - entity_filter[CONF_INCLUDE_DOMAINS] = user_input[CONF_INCLUDE_DOMAINS] - self.hk_data[CONF_FILTER] = entity_filter + self.hk_data[CONF_FILTER] = _make_entity_filter( + include_domains=user_input[CONF_INCLUDE_DOMAINS] + ) return await self.async_step_pairing() self.hk_data[CONF_HOMEKIT_MODE] = HOMEKIT_MODE_BRIDGE - default_domains = [] if self._async_current_names() else DEFAULT_DOMAINS + default_domains = ( + [] if self._async_current_entries(include_ignore=False) else DEFAULT_DOMAINS + ) name_to_type_map = await _async_name_to_type_map(self.hass) return self.async_show_form( step_id="user", @@ -213,24 +230,28 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Pairing instructions.""" + hk_data = self.hk_data + if user_input is not None: port = async_find_next_available_port(self.hass, DEFAULT_CONFIG_FLOW_PORT) await self._async_add_entries_for_accessory_mode_entities(port) - self.hk_data[CONF_PORT] = port - include_domains_filter = self.hk_data[CONF_FILTER][CONF_INCLUDE_DOMAINS] - for domain in NEVER_BRIDGED_DOMAINS: - if domain in include_domains_filter: - include_domains_filter.remove(domain) + hk_data[CONF_PORT] = port + conf_filter: EntityFilterDict = hk_data[CONF_FILTER] + conf_filter[CONF_INCLUDE_DOMAINS] = [ + domain + for domain in conf_filter[CONF_INCLUDE_DOMAINS] + if domain not in NEVER_BRIDGED_DOMAINS + ] return self.async_create_entry( - title=f"{self.hk_data[CONF_NAME]}:{self.hk_data[CONF_PORT]}", - data=self.hk_data, + title=f"{hk_data[CONF_NAME]}:{hk_data[CONF_PORT]}", + data=hk_data, ) - self.hk_data[CONF_NAME] = self._async_available_name(SHORT_BRIDGE_NAME) - self.hk_data[CONF_EXCLUDE_ACCESSORY_MODE] = True + hk_data[CONF_NAME] = self._async_available_name(SHORT_BRIDGE_NAME) + hk_data[CONF_EXCLUDE_ACCESSORY_MODE] = True return self.async_show_form( step_id="pairing", - description_placeholders={CONF_NAME: self.hk_data[CONF_NAME]}, + description_placeholders={CONF_NAME: hk_data[CONF_NAME]}, ) async def _async_add_entries_for_accessory_mode_entities( @@ -265,14 +286,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): state = self.hass.states.get(entity_id) assert state is not None name = state.attributes.get(ATTR_FRIENDLY_NAME) or state.entity_id - entity_filter = _EMPTY_ENTITY_FILTER.copy() - entity_filter[CONF_INCLUDE_ENTITIES] = [entity_id] entry_data = { CONF_PORT: port, CONF_NAME: self._async_available_name(name), CONF_HOMEKIT_MODE: HOMEKIT_MODE_ACCESSORY, - CONF_FILTER: entity_filter, + CONF_FILTER: _make_entity_filter(include_entities=[entity_id]), } if entity_id.startswith(CAMERA_ENTITY_PREFIX): entry_data[CONF_ENTITY_CONFIG] = { @@ -360,26 +379,19 @@ class OptionsFlowHandler(config_entries.OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Choose advanced options.""" - if ( - not self.show_advanced_options - or user_input is not None - or self.hk_options[CONF_HOMEKIT_MODE] != HOMEKIT_MODE_BRIDGE - ): + hk_options = self.hk_options + show_advanced_options = self.show_advanced_options + bridge_mode = hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_BRIDGE + + if not show_advanced_options or user_input is not None or not bridge_mode: if user_input: - self.hk_options.update(user_input) - if ( - self.show_advanced_options - and self.hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_BRIDGE - ): - self.hk_options[CONF_DEVICES] = user_input[CONF_DEVICES] - - for key in (CONF_DOMAINS, CONF_ENTITIES): - if key in self.hk_options: - del self.hk_options[key] - - if CONF_INCLUDE_EXCLUDE_MODE in self.hk_options: - del self.hk_options[CONF_INCLUDE_EXCLUDE_MODE] + hk_options.update(user_input) + if show_advanced_options and bridge_mode: + hk_options[CONF_DEVICES] = user_input[CONF_DEVICES] + hk_options.pop(CONF_DOMAINS, None) + hk_options.pop(CONF_ENTITIES, None) + hk_options.pop(CONF_INCLUDE_EXCLUDE_MODE, None) return self.async_create_entry(title="", data=self.hk_options) all_supported_devices = await _async_get_supported_devices(self.hass) @@ -404,35 +416,37 @@ class OptionsFlowHandler(config_entries.OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Choose camera config.""" + hk_options = self.hk_options + all_entity_config: dict[str, dict[str, Any]] + if user_input is not None: - entity_config = self.hk_options[CONF_ENTITY_CONFIG] + all_entity_config = hk_options[CONF_ENTITY_CONFIG] for entity_id in self.included_cameras: + entity_config = all_entity_config.setdefault(entity_id, {}) + if entity_id in user_input[CONF_CAMERA_COPY]: - entity_config.setdefault(entity_id, {})[ - CONF_VIDEO_CODEC - ] = VIDEO_CODEC_COPY - elif ( - entity_id in entity_config - and CONF_VIDEO_CODEC in entity_config[entity_id] - ): - del entity_config[entity_id][CONF_VIDEO_CODEC] + entity_config[CONF_VIDEO_CODEC] = VIDEO_CODEC_COPY + elif CONF_VIDEO_CODEC in entity_config: + del entity_config[CONF_VIDEO_CODEC] + if entity_id in user_input[CONF_CAMERA_AUDIO]: - entity_config.setdefault(entity_id, {})[CONF_SUPPORT_AUDIO] = True - elif ( - entity_id in entity_config - and CONF_SUPPORT_AUDIO in entity_config[entity_id] - ): - del entity_config[entity_id][CONF_SUPPORT_AUDIO] + entity_config[CONF_SUPPORT_AUDIO] = True + elif CONF_SUPPORT_AUDIO in entity_config: + del entity_config[CONF_SUPPORT_AUDIO] + + if not entity_config: + all_entity_config.pop(entity_id) + return await self.async_step_advanced() cameras_with_audio = [] cameras_with_copy = [] - entity_config = self.hk_options.setdefault(CONF_ENTITY_CONFIG, {}) + all_entity_config = hk_options.setdefault(CONF_ENTITY_CONFIG, {}) for entity in self.included_cameras: - hk_entity_config = entity_config.get(entity, {}) - if hk_entity_config.get(CONF_VIDEO_CODEC) == VIDEO_CODEC_COPY: + entity_config = all_entity_config.get(entity, {}) + if entity_config.get(CONF_VIDEO_CODEC) == VIDEO_CODEC_COPY: cameras_with_copy.append(entity) - if hk_entity_config.get(CONF_SUPPORT_AUDIO): + if entity_config.get(CONF_SUPPORT_AUDIO): cameras_with_audio.append(entity) data_schema = vol.Schema( @@ -453,18 +467,20 @@ class OptionsFlowHandler(config_entries.OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Choose entity for the accessory.""" - domains = self.hk_options[CONF_DOMAINS] + hk_options = self.hk_options + domains = hk_options[CONF_DOMAINS] + entity_filter: EntityFilterDict if user_input is not None: entities = cv.ensure_list(user_input[CONF_ENTITIES]) - entity_filter = _async_build_entites_filter(domains, entities) + entity_filter = _async_build_entities_filter(domains, entities) self.included_cameras = _async_cameras_from_entities(entities) - self.hk_options[CONF_FILTER] = entity_filter + hk_options[CONF_FILTER] = entity_filter if self.included_cameras: return await self.async_step_cameras() return await self.async_step_advanced() - entity_filter = self.hk_options.get(CONF_FILTER, {}) + entity_filter = hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) all_supported_entities = _async_get_matching_entities( self.hass, domains, include_entity_category=True, include_hidden=True @@ -494,24 +510,21 @@ class OptionsFlowHandler(config_entries.OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Choose entities to include from the domain on the bridge.""" - domains = self.hk_options[CONF_DOMAINS] + hk_options = self.hk_options + domains = hk_options[CONF_DOMAINS] if user_input is not None: entities = cv.ensure_list(user_input[CONF_ENTITIES]) - entity_filter = _async_build_entites_filter(domains, entities) self.included_cameras = _async_cameras_from_entities(entities) - self.hk_options[CONF_FILTER] = entity_filter + hk_options[CONF_FILTER] = _async_build_entities_filter(domains, entities) if self.included_cameras: return await self.async_step_cameras() return await self.async_step_advanced() - entity_filter = self.hk_options.get(CONF_FILTER, {}) + entity_filter: EntityFilterDict = hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) - all_supported_entities = _async_get_matching_entities( self.hass, domains, include_entity_category=True, include_hidden=True ) - if not entities: - entities = entity_filter.get(CONF_EXCLUDE_ENTITIES, []) # Strip out entities that no longer exist to prevent error in the UI default_value = [ entity_id for entity_id in entities if entity_id in all_supported_entities @@ -535,15 +548,13 @@ class OptionsFlowHandler(config_entries.OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Choose entities to exclude from the domain on the bridge.""" - domains = self.hk_options[CONF_DOMAINS] + hk_options = self.hk_options + domains = hk_options[CONF_DOMAINS] if user_input is not None: - entity_filter = deepcopy(_EMPTY_ENTITY_FILTER) - entities = cv.ensure_list(user_input[CONF_ENTITIES]) - entity_filter[CONF_INCLUDE_DOMAINS] = domains - entity_filter[CONF_EXCLUDE_ENTITIES] = entities self.included_cameras = {} - if CAMERA_DOMAIN in entity_filter[CONF_INCLUDE_DOMAINS]: + entities = cv.ensure_list(user_input[CONF_ENTITIES]) + if CAMERA_DOMAIN in domains: camera_entities = _async_get_matching_entities( self.hass, [CAMERA_DOMAIN] ) @@ -552,7 +563,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): for entity_id in camera_entities if entity_id not in entities } - self.hk_options[CONF_FILTER] = entity_filter + hk_options[CONF_FILTER] = _make_entity_filter( + include_domains=domains, exclude_entities=entities + ) if self.included_cameras: return await self.async_step_cameras() return await self.async_step_advanced() @@ -600,14 +613,13 @@ class OptionsFlowHandler(config_entries.OptionsFlow): self.hk_options = deepcopy(dict(self.config_entry.options)) homekit_mode = self.hk_options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) - entity_filter = self.hk_options.get(CONF_FILTER, {}) + entity_filter: EntityFilterDict = self.hk_options.get(CONF_FILTER, {}) include_exclude_mode = MODE_INCLUDE entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) if homekit_mode != HOMEKIT_MODE_ACCESSORY: include_exclude_mode = MODE_INCLUDE if entities else MODE_EXCLUDE domains = entity_filter.get(CONF_INCLUDE_DOMAINS, []) - include_entities = entity_filter.get(CONF_INCLUDE_ENTITIES) - if include_entities: + if include_entities := entity_filter.get(CONF_INCLUDE_ENTITIES): domains.extend(_domains_set_from_entities(include_entities)) name_to_type_map = await _async_name_to_type_map(self.hass) return self.async_show_form( @@ -708,7 +720,7 @@ def _async_get_entity_ids_for_accessory_mode( def _async_entity_ids_with_accessory_mode(hass: HomeAssistant) -> set[str]: """Return a set of entity ids that have config entries in accessory mode.""" - entity_ids = set() + entity_ids: set[str] = set() current_entries = hass.config_entries.async_entries(DOMAIN) for entry in current_entries: diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index ed9b8ca4622..1043164c801 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -43,13 +43,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await conn.async_setup() except ( - asyncio.TimeoutError, + TimeoutError, AccessoryNotFoundError, EncryptionError, AccessoryDisconnectedError, ) as ex: del hass.data[KNOWN_DEVICES][conn.unique_id] - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): await conn.pairing.close() raise ConfigEntryNotReady from ex diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index c127c6dd95e..299b01e5b00 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -46,6 +46,7 @@ from .const import ( SUBSCRIBE_COOLDOWN, ) from .device_trigger import async_fire_triggers, async_setup_triggers_for_entry +from .utils import IidTuple, unique_id_to_iids RETRY_INTERVAL = 60 # seconds MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE = 3 @@ -513,6 +514,54 @@ class HKDevice: device_registry.async_update_device(device.id, new_identifiers=identifiers) + @callback + def async_reap_stale_entity_registry_entries(self) -> None: + """Delete entity registry entities for removed characteristics, services and accessories.""" + _LOGGER.debug( + "Removing stale entity registry entries for pairing %s", + self.unique_id, + ) + + reg = er.async_get(self.hass) + + # For the current config entry only, visit all registry entity entries + # Build a set of (unique_id, aid, sid, iid) + # For services, (unique_id, aid, sid, None) + # For accessories, (unique_id, aid, None, None) + entries = er.async_entries_for_config_entry(reg, self.config_entry.entry_id) + existing_entities = { + iids: entry.entity_id + for entry in entries + if (iids := unique_id_to_iids(entry.unique_id)) + } + + # Process current entity map and produce a similar set + current_unique_id: set[IidTuple] = set() + for accessory in self.entity_map.accessories: + current_unique_id.add((accessory.aid, None, None)) + + for service in accessory.services: + current_unique_id.add((accessory.aid, service.iid, None)) + + for char in service.characteristics: + current_unique_id.add( + ( + accessory.aid, + service.iid, + char.iid, + ) + ) + + # Remove the difference + if stale := existing_entities.keys() - current_unique_id: + for parts in stale: + _LOGGER.debug( + "Removing stale entity registry entry %s for pairing %s", + existing_entities[parts], + self.unique_id, + ) + reg.async_remove(existing_entities[parts]) + @callback def async_migrate_ble_unique_id(self) -> None: """Config entries from step_bluetooth used incorrect identifier for unique_id.""" @@ -615,6 +664,8 @@ class HKDevice: self.async_migrate_ble_unique_id() + self.async_reap_stale_entity_registry_entries() + self.async_create_devices() # Load any triggers for this config entry diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index cc2c28cb5dc..aea5a6661ee 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -1,5 +1,4 @@ """Constants for the homekit_controller component.""" -import asyncio from aiohomekit.exceptions import ( AccessoryDisconnectedError, @@ -56,6 +55,7 @@ HOMEKIT_ACCESSORY_DISPATCH = { ServicesTypes.DOORBELL: "event", ServicesTypes.STATELESS_PROGRAMMABLE_SWITCH: "event", ServicesTypes.SERVICE_LABEL: "event", + ServicesTypes.AIR_PURIFIER: "fan", } CHARACTERISTIC_PLATFORMS = { @@ -105,10 +105,12 @@ CHARACTERISTIC_PLATFORMS = { CharacteristicsTypes.FILTER_LIFE_LEVEL: "sensor", CharacteristicsTypes.VENDOR_AIRVERSA_SLEEP_MODE: "switch", CharacteristicsTypes.TEMPERATURE_UNITS: "select", + CharacteristicsTypes.AIR_PURIFIER_STATE_CURRENT: "sensor", + CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET: "select", } STARTUP_EXCEPTIONS = ( - asyncio.TimeoutError, + TimeoutError, AccessoryNotFoundError, EncryptionError, AccessoryDisconnectedError, diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index d87b6ab3e39..1b2d572f2b6 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -206,6 +206,7 @@ class HomeKitFanV2(BaseHomeKitFan): ENTITY_TYPES = { ServicesTypes.FAN: HomeKitFanV1, ServicesTypes.FAN_V2: HomeKitFanV2, + ServicesTypes.AIR_PURIFIER: HomeKitFanV2, } diff --git a/homeassistant/components/homekit_controller/select.py b/homeassistant/components/homekit_controller/select.py index e6eae1c51ca..c3185bcba55 100644 --- a/homeassistant/components/homekit_controller/select.py +++ b/homeassistant/components/homekit_controller/select.py @@ -5,7 +5,10 @@ from dataclasses import dataclass from enum import IntEnum from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes -from aiohomekit.model.characteristics.const import TemperatureDisplayUnits +from aiohomekit.model.characteristics.const import ( + TargetAirPurifierStateValues, + TemperatureDisplayUnits, +) from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -47,6 +50,16 @@ SELECT_ENTITIES: dict[str, HomeKitSelectEntityDescription] = { "fahrenheit": TemperatureDisplayUnits.FAHRENHEIT, }, ), + CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET: HomeKitSelectEntityDescription( + key="air_purifier_state_target", + translation_key="air_purifier_state_target", + name="Air Purifier Mode", + entity_category=EntityCategory.CONFIG, + choices={ + "automatic": TargetAirPurifierStateValues.AUTOMATIC, + "manual": TargetAirPurifierStateValues.MANUAL, + }, + ), } _ECOBEE_MODE_TO_TEXT = { diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index ebfba110e48..26476417a56 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -3,10 +3,15 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from enum import IntEnum from aiohomekit.model import Accessory, Transport from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes -from aiohomekit.model.characteristics.const import ThreadNodeCapabilities, ThreadStatus +from aiohomekit.model.characteristics.const import ( + CurrentAirPurifierStateValues, + ThreadNodeCapabilities, + ThreadStatus, +) from aiohomekit.model.services import Service, ServicesTypes from homeassistant.components.bluetooth import ( @@ -52,6 +57,7 @@ class HomeKitSensorEntityDescription(SensorEntityDescription): probe: Callable[[Characteristic], bool] | None = None format: Callable[[Characteristic], str] | None = None + enum: dict[IntEnum, str] | None = None def thread_node_capability_to_str(char: Characteristic) -> str: @@ -324,6 +330,18 @@ SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = { ], translation_key="thread_status", ), + CharacteristicsTypes.AIR_PURIFIER_STATE_CURRENT: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.AIR_PURIFIER_STATE_CURRENT, + name="Air Purifier Status", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + enum={ + CurrentAirPurifierStateValues.INACTIVE: "inactive", + CurrentAirPurifierStateValues.IDLE: "idle", + CurrentAirPurifierStateValues.ACTIVE: "purifying", + }, + translation_key="air_purifier_state_current", + ), CharacteristicsTypes.VENDOR_NETATMO_NOISE: HomeKitSensorEntityDescription( key=CharacteristicsTypes.VENDOR_NETATMO_NOISE, name="Noise", @@ -535,6 +553,8 @@ class SimpleSensor(CharacteristicEntity, SensorEntity): ) -> None: """Initialise a secondary HomeKit characteristic sensor.""" self.entity_description = description + if self.entity_description.enum: + self._attr_options = list(self.entity_description.enum.values()) super().__init__(conn, info, char) def get_characteristic_types(self) -> list[str]: @@ -551,10 +571,11 @@ class SimpleSensor(CharacteristicEntity, SensorEntity): @property def native_value(self) -> str | int | float: """Return the current sensor value.""" - val = self._char.value + if self.entity_description.enum: + return self.entity_description.enum[self._char.value] if self.entity_description.format: - return self.entity_description.format(val) - return val + return self.entity_description.format(self._char) + return self._char.value ENTITY_TYPES = { diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index 998c375aac1..d1205645fd3 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -108,6 +108,12 @@ "celsius": "Celsius", "fahrenheit": "Fahrenheit" } + }, + "air_purifier_state_target": { + "state": { + "automatic": "Automatic", + "manual": "Manual" + } } }, "sensor": { @@ -131,6 +137,13 @@ "leader": "Leader", "router": "Router" } + }, + "air_purifier_state_current": { + "state": { + "inactive": "Inactive", + "idle": "Idle", + "purifying": "Purifying" + } } } } diff --git a/homeassistant/components/homekit_controller/utils.py b/homeassistant/components/homekit_controller/utils.py index 33a08504724..489dee5584c 100644 --- a/homeassistant/components/homekit_controller/utils.py +++ b/homeassistant/components/homekit_controller/utils.py @@ -11,6 +11,31 @@ from homeassistant.core import Event, HomeAssistant from .const import CONTROLLER from .storage import async_get_entity_storage +IidTuple = tuple[int, int | None, int | None] + + +def unique_id_to_iids(unique_id: str) -> IidTuple | None: + """Convert a unique_id to a tuple of accessory id, service iid and characteristic iid. + + Depending on the field in the accessory map that is referenced, some of these may be None. + + Returns None if this unique_id doesn't follow the homekit_controller scheme and is invalid. + """ + try: + match unique_id.split("_"): + case (unique_id, aid, sid, cid): + return (int(aid), int(sid), int(cid)) + case (unique_id, aid, sid): + return (int(aid), int(sid), None) + case (unique_id, aid): + return (int(aid), None, None) + except ValueError: + # One of the int conversions failed - this can't be a valid homekit_controller unique id + # Fall through and return None + pass + + return None + @lru_cache def folded_name(name: str) -> str: diff --git a/homeassistant/components/homematic/climate.py b/homeassistant/components/homematic/climate.py index c1dead1835e..76d9dff4d46 100644 --- a/homeassistant/components/homematic/climate.py +++ b/homeassistant/components/homematic/climate.py @@ -56,9 +56,13 @@ class HMThermostat(HMDevice, ClimateEntity): """Representation of a Homematic thermostat.""" _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 09d00e9bee1..63b78e91a2f 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -70,6 +70,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, hap: HomematicipHAP, device: AsyncHeatingGroup) -> None: """Initialize heating group.""" diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index baabf4ca4d8..f58db72a07e 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -1,5 +1,4 @@ """Support for Honeywell (US) Total Connect Comfort climate systems.""" -import asyncio from dataclasses import dataclass import aiosomecomfort @@ -68,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b aiosomecomfort.device.ConnectionError, aiosomecomfort.device.ConnectionTimeout, aiosomecomfort.device.SomeComfortError, - asyncio.TimeoutError, + TimeoutError, ) as ex: raise ConfigEntryNotReady( "Failed to initialize the Honeywell client: Connection error" diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 803ca1da1aa..61ccdd00e49 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -1,12 +1,12 @@ """Support for Honeywell (US) Total Connect Comfort climate systems.""" from __future__ import annotations -import asyncio import datetime from typing import Any from aiohttp import ClientConnectionError from aiosomecomfort import ( + APIRateLimited, AuthError, ConnectionError as AscConnectionError, SomeComfortError, @@ -143,6 +143,7 @@ class HoneywellUSThermostat(ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_translation_key = "honeywell" + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -187,6 +188,10 @@ class HoneywellUSThermostat(ClimateEntity): | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) + if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) if device._data.get("canControlHumidification"): self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY @@ -500,10 +505,11 @@ class HoneywellUSThermostat(ClimateEntity): await self._device.refresh() except ( + TimeoutError, + AscConnectionError, + APIRateLimited, AuthError, ClientConnectionError, - AscConnectionError, - asyncio.TimeoutError, ): self._retry += 1 self._attr_available = self._retry <= RETRY @@ -518,8 +524,12 @@ class HoneywellUSThermostat(ClimateEntity): except UnauthorizedError: await _login() return - - except (AscConnectionError, ClientConnectionError, asyncio.TimeoutError): + except ( + TimeoutError, + AscConnectionError, + APIRateLimited, + ClientConnectionError, + ): self._retry += 1 self._attr_available = self._retry <= RETRY return diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index 43d08ee2294..aeb72899e11 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -1,7 +1,6 @@ """Config flow to configure the honeywell integration.""" from __future__ import annotations -import asyncio from collections.abc import Mapping from typing import Any @@ -61,7 +60,7 @@ class HoneywellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except ( aiosomecomfort.ConnectionError, aiosomecomfort.ConnectionTimeout, - asyncio.TimeoutError, + TimeoutError, ): errors["base"] = "cannot_connect" @@ -93,7 +92,7 @@ class HoneywellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except ( aiosomecomfort.ConnectionError, aiosomecomfort.ConnectionTimeout, - asyncio.TimeoutError, + TimeoutError, ): errors["base"] = "cannot_connect" diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 29c59d3ff9c..81be4e462d1 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -557,14 +557,12 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> recipient = options.get(CONF_RECIPIENT) if isinstance(recipient, str): options[CONF_RECIPIENT] = [x.strip() for x in recipient.split(",")] - config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry, options=options) + hass.config_entries.async_update_entry(config_entry, options=options, version=2) _LOGGER.info("Migrated config entry to version %d", config_entry.version) if config_entry.version == 2: - config_entry.version = 3 data = dict(config_entry.data) data[CONF_MAC] = [] - hass.config_entries.async_update_entry(config_entry, data=data) + hass.config_entries.async_update_entry(config_entry, data=data, version=3) _LOGGER.info("Migrated config entry to version %d", config_entry.version) # There can be no longer needed *_from_yaml data and options things left behind # from pre-2022.4ish; they can be removed while at it when/if we eventually bump and diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index fd1b9850054..1bb5077a2b4 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -70,11 +70,10 @@ async def async_setup_entry( track_wired_clients = router.config_entry.options.get( CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS ) - for entity in registry.entities.values(): - if ( - entity.domain == DEVICE_TRACKER_DOMAIN - and entity.config_entry_id == config_entry.entry_id - ): + for entity in registry.entities.get_entries_for_config_entry_id( + config_entry.entry_id + ): + if entity.domain == DEVICE_TRACKER_DOMAIN: mac = entity.unique_id.partition("-")[2] # Do not add known wired clients if not tracking them (any more) skip = False diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index c5ceebec3f8..abf91cf4577 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -83,7 +83,7 @@ class HueBridge: create_config_flow(self.hass, self.host) return False except ( - asyncio.TimeoutError, + TimeoutError, client_exceptions.ClientOSError, client_exceptions.ServerDisconnectedError, client_exceptions.ContentTypeError, diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 7262dea39ef..a1345cf3bba 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -111,7 +111,7 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): bridges = await discover_nupnp( websession=aiohttp_client.async_get_clientsession(self.hass) ) - except asyncio.TimeoutError: + except TimeoutError: return self.async_abort(reason="discover_timeout") if bridges: diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index f1bcd0bbbe3..4707302d288 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -1,6 +1,7 @@ """Support for Hue binary sensors.""" from __future__ import annotations +from functools import partial from typing import TypeAlias from aiohue.v2 import HueBridgeV2 @@ -58,14 +59,15 @@ async def async_setup_entry( @callback def register_items(controller: ControllerType, sensor_class: SensorType): + make_binary_sensor_entity = partial(sensor_class, bridge, controller) + @callback def async_add_sensor(event_type: EventType, resource: SensorType) -> None: """Add Hue Binary Sensor.""" - async_add_entities([sensor_class(bridge, controller, resource)]) + async_add_entities([make_binary_sensor_entity(resource)]) # add all current items in controller - for sensor in controller: - async_add_sensor(EventType.RESOURCE_ADDED, sensor) + async_add_entities(make_binary_sensor_entity(sensor) for sensor in controller) # register listener for new sensors config_entry.async_on_unload( diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index 348d60d8de2..bbf5dc9c19f 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -1,6 +1,7 @@ """Support for Hue lights.""" from __future__ import annotations +from functools import partial from typing import Any from aiohue import HueBridgeV2 @@ -21,6 +22,7 @@ from homeassistant.components.light import ( LightEntity, LightEntityDescription, LightEntityFeature, + filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -50,17 +52,15 @@ async def async_setup_entry( bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] api: HueBridgeV2 = bridge.api controller: LightsController = api.lights + make_light_entity = partial(HueLight, bridge, controller) @callback def async_add_light(event_type: EventType, resource: Light) -> None: """Add Hue Light.""" - light = HueLight(bridge, controller, resource) - async_add_entities([light]) + async_add_entities([make_light_entity(resource)]) # add all current items in controller - for light in controller: - async_add_light(EventType.RESOURCE_ADDED, resource=light) - + async_add_entities(make_light_entity(light) for light in controller) # register listener for new lights config_entry.async_on_unload( controller.subscribe(async_add_light, event_filter=EventType.RESOURCE_ADDED) @@ -70,6 +70,7 @@ async def async_setup_entry( class HueLight(HueBaseEntity, LightEntity): """Representation of a Hue light.""" + _fixed_color_mode: ColorMode | None = None entity_description = LightEntityDescription( key="hue_light", has_entity_name=True, name=None ) @@ -83,17 +84,20 @@ class HueLight(HueBaseEntity, LightEntity): self._attr_supported_features |= LightEntityFeature.FLASH self.resource = resource self.controller = controller - self._supported_color_modes: set[ColorMode | str] = set() + supported_color_modes = {ColorMode.ONOFF} if self.resource.supports_color: - self._supported_color_modes.add(ColorMode.XY) + supported_color_modes.add(ColorMode.XY) if self.resource.supports_color_temperature: - self._supported_color_modes.add(ColorMode.COLOR_TEMP) + supported_color_modes.add(ColorMode.COLOR_TEMP) if self.resource.supports_dimming: - if len(self._supported_color_modes) == 0: - # only add color mode brightness if no color variants - self._supported_color_modes.add(ColorMode.BRIGHTNESS) + supported_color_modes.add(ColorMode.BRIGHTNESS) # support transition if brightness control self._attr_supported_features |= LightEntityFeature.TRANSITION + supported_color_modes = filter_supported_color_modes(supported_color_modes) + self._attr_supported_color_modes = supported_color_modes + if len(self._attr_supported_color_modes) == 1: + # If the light supports only a single color mode, set it now + self._fixed_color_mode = next(iter(self._attr_supported_color_modes)) self._last_brightness: float | None = None self._color_temp_active: bool = False # get list of supported effects (combine effects and timed_effects) @@ -128,14 +132,15 @@ class HueLight(HueBaseEntity, LightEntity): @property def color_mode(self) -> ColorMode: """Return the color mode of the light.""" + if self._fixed_color_mode: + # The light supports only a single color mode, return it + return self._fixed_color_mode + + # The light supports both color temperature and XY, determine which + # mode the light is in if self.color_temp_active: return ColorMode.COLOR_TEMP - if self.resource.supports_color: - return ColorMode.XY - if self.resource.supports_dimming: - return ColorMode.BRIGHTNESS - # fallback to on_off - return ColorMode.ONOFF + return ColorMode.XY @property def color_temp_active(self) -> bool: @@ -180,11 +185,6 @@ class HueLight(HueBaseEntity, LightEntity): # return a fallback value to prevent issues with mired->kelvin conversions return FALLBACK_MAX_MIREDS - @property - def supported_color_modes(self) -> set | None: - """Flag supported features.""" - return self._supported_color_modes - @property def extra_state_attributes(self) -> dict[str, str] | None: """Return the optional state attributes.""" diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index 56f708e2dfd..59dc8de2975 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -1,6 +1,7 @@ """Support for Hue sensors.""" from __future__ import annotations +from functools import partial from typing import Any, TypeAlias from aiohue.v2 import HueBridgeV2 @@ -53,14 +54,15 @@ async def async_setup_entry( @callback def register_items(controller: ControllerType, sensor_class: SensorType): + make_sensor_entity = partial(sensor_class, bridge, controller) + @callback def async_add_sensor(event_type: EventType, resource: SensorType) -> None: """Add Hue Sensor.""" - async_add_entities([sensor_class(bridge, controller, resource)]) + async_add_entities([make_sensor_entity(resource)]) # add all current items in controller - for sensor in controller: - async_add_sensor(EventType.RESOURCE_ADDED, sensor) + async_add_entities(make_sensor_entity(sensor) for sensor in controller) # register listener for new sensors config_entry.async_on_unload( diff --git a/homeassistant/components/huisbaasje/__init__.py b/homeassistant/components/huisbaasje/__init__.py index b1c2d865e0c..9ea4b547596 100644 --- a/homeassistant/components/huisbaasje/__init__.py +++ b/homeassistant/components/huisbaasje/__init__.py @@ -84,7 +84,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_huisbaasje(energyflip: EnergyFlip) -> dict[str, dict[str, Any]]: """Update the data by performing a request to Huisbaasje.""" try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # Note: TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. async with asyncio.timeout(FETCH_TIMEOUT): if not energyflip.is_authenticated(): diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index 56ebbe6fb26..2f238a3fe6f 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -3,40 +3,23 @@ import asyncio import logging from aiopvapi.helpers.aiorequest import AioRequest -from aiopvapi.helpers.api_base import ApiEntryPoint -from aiopvapi.helpers.tools import base64_to_unicode +from aiopvapi.hub import Hub +from aiopvapi.resources.model import PowerviewData from aiopvapi.rooms import Rooms from aiopvapi.scenes import Scenes from aiopvapi.shades import Shades -from aiopvapi.userdata import UserData from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import CONF_API_VERSION, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from .const import ( - API_PATH_FWVERSION, - DEFAULT_LEGACY_MAINPROCESSOR, - DOMAIN, - FIRMWARE, - FIRMWARE_MAINPROCESSOR, - FIRMWARE_NAME, - HUB_EXCEPTIONS, - HUB_NAME, - MAC_ADDRESS_IN_USERDATA, - ROOM_DATA, - SCENE_DATA, - SERIAL_NUMBER_IN_USERDATA, - SHADE_DATA, - USER_DATA, -) +from .const import DOMAIN, HUB_EXCEPTIONS from .coordinator import PowerviewShadeUpdateCoordinator from .model import PowerviewDeviceInfo, PowerviewEntryData from .shade_data import PowerviewShadeData -from .util import async_map_data_by_id PARALLEL_UPDATES = 1 @@ -58,46 +41,70 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config = entry.data hub_address = config[CONF_HOST] + api_version = config.get(CONF_API_VERSION, None) + _LOGGER.debug("Connecting %s at %s with v%s api", DOMAIN, hub_address, api_version) + websession = async_get_clientsession(hass) - pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession) + pv_request = AioRequest( + hub_address, loop=hass.loop, websession=websession, api_version=api_version + ) try: async with asyncio.timeout(10): - device_info = await async_get_device_info(pv_request, hub_address) - - async with asyncio.timeout(10): - rooms = Rooms(pv_request) - room_data = async_map_data_by_id((await rooms.get_resources())[ROOM_DATA]) - - async with asyncio.timeout(10): - scenes = Scenes(pv_request) - scene_data = async_map_data_by_id( - (await scenes.get_resources())[SCENE_DATA] - ) - - async with asyncio.timeout(10): - shades = Shades(pv_request) - shade_entries = await shades.get_resources() - shade_data = async_map_data_by_id(shade_entries[SHADE_DATA]) - + hub = Hub(pv_request) + await hub.query_firmware() + device_info = await async_get_device_info(hub) except HUB_EXCEPTIONS as err: raise ConfigEntryNotReady( - f"Connection error to PowerView hub: {hub_address}: {err}" + f"Connection error to PowerView hub {hub_address}: {err}" ) from err + + if hub.role != "Primary": + # this should be caught in config_flow, but account for a hub changing roles + # this will only happen manually by a user + _LOGGER.error( + "%s (%s) is performing role of %s Hub. " + "Only the Primary Hub can manage shades", + hub.name, + hub.hub_address, + hub.role, + ) + return False + + try: + async with asyncio.timeout(10): + rooms = Rooms(pv_request) + room_data: PowerviewData = await rooms.get_rooms() + async with asyncio.timeout(10): + scenes = Scenes(pv_request) + scene_data: PowerviewData = await scenes.get_scenes() + async with asyncio.timeout(10): + shades = Shades(pv_request) + shade_data: PowerviewData = await shades.get_shades() + except HUB_EXCEPTIONS as err: + raise ConfigEntryNotReady( + f"Connection error to PowerView hub {hub_address}: {err}" + ) from err + if not device_info: raise ConfigEntryNotReady(f"Unable to initialize PowerView hub: {hub_address}") - coordinator = PowerviewShadeUpdateCoordinator(hass, shades, hub_address) + if CONF_API_VERSION not in config: + new_data = {**entry.data} + new_data[CONF_API_VERSION] = hub.api_version + hass.config_entries.async_update_entry(entry, data=new_data) + + coordinator = PowerviewShadeUpdateCoordinator(hass, shades, hub) coordinator.async_set_updated_data(PowerviewShadeData()) # populate raw shade data into the coordinator for diagnostics - coordinator.data.store_group_data(shade_entries[SHADE_DATA]) + coordinator.data.store_group_data(shade_data) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = PowerviewEntryData( api=pv_request, - room_data=room_data, - scene_data=scene_data, - shade_data=shade_data, + room_data=room_data.processed, + scene_data=scene_data.processed, + shade_data=shade_data.processed, coordinator=coordinator, device_info=device_info, ) @@ -107,39 +114,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_get_device_info( - pv_request: AioRequest, hub_address: str -) -> PowerviewDeviceInfo: +async def async_get_device_info(hub: Hub) -> PowerviewDeviceInfo: """Determine device info.""" - userdata = UserData(pv_request) - resources = await userdata.get_resources() - userdata_data = resources[USER_DATA] - - if FIRMWARE in userdata_data: - main_processor_info = userdata_data[FIRMWARE][FIRMWARE_MAINPROCESSOR] - elif userdata_data: - # Legacy devices - fwversion = ApiEntryPoint(pv_request, API_PATH_FWVERSION) - resources = await fwversion.get_resources() - - if FIRMWARE in resources: - main_processor_info = resources[FIRMWARE][FIRMWARE_MAINPROCESSOR] - else: - main_processor_info = DEFAULT_LEGACY_MAINPROCESSOR - return PowerviewDeviceInfo( - name=base64_to_unicode(userdata_data[HUB_NAME]), - mac_address=userdata_data[MAC_ADDRESS_IN_USERDATA], - serial_number=userdata_data[SERIAL_NUMBER_IN_USERDATA], - firmware=main_processor_info, - model=main_processor_info[FIRMWARE_NAME], - hub_address=hub_address, + name=hub.name, + mac_address=hub.mac_address, + serial_number=hub.serial_number, + firmware=hub.firmware, + model=hub.model, + hub_address=hub.ip, ) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py index cb6bc72954f..c37741fcb09 100644 --- a/homeassistant/components/hunterdouglas_powerview/button.py +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -5,7 +5,14 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any, Final -from aiopvapi.resources.shade import BaseShade, factory as PvShade +from aiopvapi.helpers.constants import ( + ATTR_NAME, + MOTION_CALIBRATE, + MOTION_FAVORITE, + MOTION_JOG, +) +from aiopvapi.hub import Hub +from aiopvapi.resources.shade import BaseShade from homeassistant.components.button import ( ButtonDeviceClass, @@ -17,7 +24,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, ROOM_ID_IN_SHADE, ROOM_NAME_UNICODE +from .const import DOMAIN from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData @@ -27,7 +34,8 @@ from .model import PowerviewDeviceInfo, PowerviewEntryData class PowerviewButtonDescriptionMixin: """Mixin to describe a Button entity.""" - press_action: Callable[[BaseShade], Any] + press_action: Callable[[BaseShade | Hub], Any] + create_entity_fn: Callable[[BaseShade | Hub], bool] @dataclass(frozen=True) @@ -37,18 +45,20 @@ class PowerviewButtonDescription( """Class to describe a Button entity.""" -BUTTONS: Final = [ +BUTTONS_SHADE: Final = [ PowerviewButtonDescription( key="calibrate", translation_key="calibrate", icon="mdi:swap-vertical-circle-outline", entity_category=EntityCategory.DIAGNOSTIC, + create_entity_fn=lambda shade: shade.is_supported(MOTION_CALIBRATE), press_action=lambda shade: shade.calibrate(), ), PowerviewButtonDescription( key="identify", device_class=ButtonDeviceClass.IDENTIFY, entity_category=EntityCategory.DIAGNOSTIC, + create_entity_fn=lambda shade: shade.is_supported(MOTION_JOG), press_action=lambda shade: shade.jog(), ), PowerviewButtonDescription( @@ -56,6 +66,7 @@ BUTTONS: Final = [ translation_key="favorite", icon="mdi:heart", entity_category=EntityCategory.DIAGNOSTIC, + create_entity_fn=lambda shade: shade.is_supported(MOTION_FAVORITE), press_action=lambda shade: shade.favorite(), ), ] @@ -71,28 +82,25 @@ async def async_setup_entry( pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] entities: list[ButtonEntity] = [] - for raw_shade in pv_entry.shade_data.values(): - shade: BaseShade = PvShade(raw_shade, pv_entry.api) - name_before_refresh = shade.name - room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") - - for description in BUTTONS: - entities.append( - PowerviewButton( - pv_entry.coordinator, - pv_entry.device_info, - room_name, - shade, - name_before_refresh, - description, + for shade in pv_entry.shade_data.values(): + room_name = getattr(pv_entry.room_data.get(shade.room_id), ATTR_NAME, "") + for description in BUTTONS_SHADE: + if description.create_entity_fn(shade): + entities.append( + PowerviewShadeButton( + pv_entry.coordinator, + pv_entry.device_info, + room_name, + shade, + shade.name, + description, + ) ) - ) async_add_entities(entities) -class PowerviewButton(ShadeEntity, ButtonEntity): +class PowerviewShadeButton(ShadeEntity, ButtonEntity): """Representation of an advanced feature button.""" def __init__( diff --git a/homeassistant/components/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py index 81532187bbf..359edfb340e 100644 --- a/homeassistant/components/hunterdouglas_powerview/config_flow.py +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -3,14 +3,15 @@ from __future__ import annotations import asyncio import logging -from typing import Any +from typing import TYPE_CHECKING, Any from aiopvapi.helpers.aiorequest import AioRequest +from aiopvapi.hub import Hub import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.components import dhcp, zeroconf -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_NAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -19,9 +20,9 @@ from .const import DOMAIN, HUB_EXCEPTIONS _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) HAP_SUFFIX = "._hap._tcp.local." -POWERVIEW_SUFFIX = "._powerview._tcp.local." +POWERVIEW_G2_SUFFIX = "._powerview._tcp.local." +POWERVIEW_G3_SUFFIX = "._powerview-g3._tcp.local." async def validate_input(hass: core.HomeAssistant, hub_address: str) -> dict[str, str]: @@ -36,44 +37,70 @@ async def validate_input(hass: core.HomeAssistant, hub_address: str) -> dict[str try: async with asyncio.timeout(10): - device_info = await async_get_device_info(pv_request, hub_address) + hub = Hub(pv_request) + await hub.query_firmware() + device_info = await async_get_device_info(hub) except HUB_EXCEPTIONS as err: raise CannotConnect from err + if hub.role != "Primary": + raise UnsupportedDevice( + f"{hub.name} ({hub.hub_address}) is the {hub.role} Hub. " + "Only the Primary can manage shades" + ) + + _LOGGER.debug("Connection made using api version: %s", hub.api_version) + # Return info that you want to store in the config entry. return { "title": device_info.name, "unique_id": device_info.serial_number, + CONF_API_VERSION: hub.api_version, } -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class PowerviewConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Hunter Douglas PowerView.""" VERSION = 1 def __init__(self) -> None: """Initialize the powerview config flow.""" - self.powerview_config: dict[str, str] = {} + self.powerview_config: dict = {} self.discovered_ip: str | None = None self.discovered_name: str | None = None + self.data_schema: dict = {vol.Required(CONF_HOST): str} async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" - errors: dict[str, Any] = {} + errors: dict[str, str] = {} + if user_input is not None: info, error = await self._async_validate_or_error(user_input[CONF_HOST]) + if info and not error: + self.powerview_config = { + CONF_HOST: user_input[CONF_HOST], + CONF_NAME: info["title"], + CONF_API_VERSION: info[CONF_API_VERSION], + } await self.async_set_unique_id(info["unique_id"]) return self.async_create_entry( - title=info["title"], data={CONF_HOST: user_input[CONF_HOST]} + title=self.powerview_config[CONF_NAME], + data={ + CONF_HOST: self.powerview_config[CONF_HOST], + CONF_API_VERSION: self.powerview_config[CONF_API_VERSION], + }, ) + + if TYPE_CHECKING: + assert error is not None errors["base"] = error return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", data_schema=vol.Schema(self.data_schema), errors=errors ) async def _async_validate_or_error( @@ -85,6 +112,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): info = await validate_input(self.hass, host) except CannotConnect: return None, "cannot_connect" + except UnsupportedDevice: + return None, "unsupported_device" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") return None, "unknown" @@ -102,7 +131,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle zeroconf discovery.""" self.discovered_ip = discovery_info.host - name = discovery_info.name.removesuffix(POWERVIEW_SUFFIX) + name = discovery_info.name.removesuffix(POWERVIEW_G2_SUFFIX) + name = name.removesuffix(POWERVIEW_G3_SUFFIX) self.discovered_name = name return await self.async_step_discovery_confirm() @@ -137,6 +167,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.powerview_config = { CONF_HOST: self.discovered_ip, CONF_NAME: self.discovered_name, + CONF_API_VERSION: info[CONF_API_VERSION], } return await self.async_step_link() @@ -147,7 +178,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: return self.async_create_entry( title=self.powerview_config[CONF_NAME], - data={CONF_HOST: self.powerview_config[CONF_HOST]}, + data={ + CONF_HOST: self.powerview_config[CONF_HOST], + CONF_API_VERSION: self.powerview_config[CONF_API_VERSION], + }, ) self._set_confirm_only() @@ -159,3 +193,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class CannotConnect(exceptions.HomeAssistantError): """Error to indicate we cannot connect.""" + + +class UnsupportedDevice(exceptions.HomeAssistantError): + """Error to indicate the device is not supported.""" diff --git a/homeassistant/components/hunterdouglas_powerview/const.py b/homeassistant/components/hunterdouglas_powerview/const.py index 7dd4c229c48..a2d18c6f512 100644 --- a/homeassistant/components/hunterdouglas_powerview/const.py +++ b/homeassistant/components/hunterdouglas_powerview/const.py @@ -1,92 +1,28 @@ -"""Support for Powerview scenes from a Powerview hub.""" +"""Constants for Hunter Douglas Powerview hub.""" -import asyncio from aiohttp.client_exceptions import ServerDisconnectedError -from aiopvapi.helpers.aiorequest import PvApiConnectionError, PvApiResponseStatusError +from aiopvapi.helpers.aiorequest import ( + PvApiConnectionError, + PvApiEmptyData, + PvApiMaintenance, + PvApiResponseStatusError, +) DOMAIN = "hunterdouglas_powerview" - MANUFACTURER = "Hunter Douglas" -HUB_ADDRESS = "address" - -SCENE_DATA = "sceneData" -SHADE_DATA = "shadeData" -ROOM_DATA = "roomData" -USER_DATA = "userData" - -MAC_ADDRESS_IN_USERDATA = "macAddress" -SERIAL_NUMBER_IN_USERDATA = "serialNumber" -HUB_NAME = "hubName" - -FIRMWARE = "firmware" -FIRMWARE_MAINPROCESSOR = "mainProcessor" -FIRMWARE_NAME = "name" -FIRMWARE_REVISION = "revision" -FIRMWARE_SUB_REVISION = "subRevision" -FIRMWARE_BUILD = "build" - REDACT_MAC_ADDRESS = "mac_address" REDACT_SERIAL_NUMBER = "serial_number" REDACT_HUB_ADDRESS = "hub_address" -SCENE_NAME = "name" -SCENE_ID = "id" -ROOM_ID_IN_SCENE = "roomId" - -SHADE_NAME = "name" -SHADE_ID = "id" -ROOM_ID_IN_SHADE = "roomId" - -ROOM_NAME = "name" -ROOM_NAME_UNICODE = "name_unicode" -ROOM_ID = "id" - -SHADE_BATTERY_LEVEL = "batteryStrength" -SHADE_BATTERY_LEVEL_MAX = 200 - -ATTR_SIGNAL_STRENGTH = "signalStrength" -ATTR_SIGNAL_STRENGTH_MAX = 4 - -STATE_ATTRIBUTE_ROOM_NAME = "roomName" +STATE_ATTRIBUTE_ROOM_NAME = "room_name" HUB_EXCEPTIONS = ( ServerDisconnectedError, - asyncio.TimeoutError, + TimeoutError, PvApiConnectionError, PvApiResponseStatusError, + PvApiMaintenance, + PvApiEmptyData, ) - -LEGACY_DEVICE_SUB_REVISION = 1 -LEGACY_DEVICE_REVISION = 0 -LEGACY_DEVICE_BUILD = 0 -LEGACY_DEVICE_MODEL = "PowerView Hub" - -DEFAULT_LEGACY_MAINPROCESSOR = { - FIRMWARE_REVISION: LEGACY_DEVICE_REVISION, - FIRMWARE_SUB_REVISION: LEGACY_DEVICE_SUB_REVISION, - FIRMWARE_BUILD: LEGACY_DEVICE_BUILD, - FIRMWARE_NAME: LEGACY_DEVICE_MODEL, -} - -API_PATH_FWVERSION = "api/fwversion" - -POS_KIND_NONE = 0 -POS_KIND_PRIMARY = 1 -POS_KIND_SECONDARY = 2 -POS_KIND_VANE = 3 -POS_KIND_ERROR = 4 - - -ATTR_BATTERY_KIND = "batteryKind" -BATTERY_KIND_HARDWIRED = 1 -BATTERY_KIND_BATTERY = 2 -BATTERY_KIND_RECHARGABLE = 3 - -POWER_SUPPLY_TYPE_MAP = { - BATTERY_KIND_HARDWIRED: "Hardwired Power Supply", - BATTERY_KIND_BATTERY: "Battery Wand", - BATTERY_KIND_RECHARGABLE: "Rechargeable Battery", -} -POWER_SUPPLY_TYPE_REVERSE_MAP = {v: k for k, v in POWER_SUPPLY_TYPE_MAP.items()} diff --git a/homeassistant/components/hunterdouglas_powerview/coordinator.py b/homeassistant/components/hunterdouglas_powerview/coordinator.py index 4643536d56d..db4079f2b58 100644 --- a/homeassistant/components/hunterdouglas_powerview/coordinator.py +++ b/homeassistant/components/hunterdouglas_powerview/coordinator.py @@ -5,12 +5,14 @@ import asyncio from datetime import timedelta import logging +from aiopvapi.helpers.aiorequest import PvApiMaintenance +from aiopvapi.hub import Hub from aiopvapi.shades import Shades from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import SHADE_DATA +from .const import HUB_EXCEPTIONS from .shade_data import PowerviewShadeData _LOGGER = logging.getLogger(__name__) @@ -19,18 +21,14 @@ _LOGGER = logging.getLogger(__name__) class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData]): """DataUpdateCoordinator to gather data from a powerview hub.""" - def __init__( - self, - hass: HomeAssistant, - shades: Shades, - hub_address: str, - ) -> None: + def __init__(self, hass: HomeAssistant, shades: Shades, hub: Hub) -> None: """Initialize DataUpdateCoordinator to gather data for specific Powerview Hub.""" self.shades = shades + self.hub = hub super().__init__( hass, _LOGGER, - name=f"powerview hub {hub_address}", + name=f"powerview hub {hub.hub_address}", update_interval=timedelta(seconds=60), ) @@ -38,17 +36,20 @@ class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData]) """Fetch data from shade endpoint.""" async with asyncio.timeout(10): - shade_entries = await self.shades.get_resources() - - if isinstance(shade_entries, bool): - # hub returns boolean on a 204/423 empty response (maintenance) - # continual polling results in inevitable error - raise UpdateFailed("Powerview Hub is undergoing maintenance") + try: + shade_entries = await self.shades.get_shades() + except PvApiMaintenance as error: + # hub is undergoing maintenance, pause polling + raise UpdateFailed(error) from error + except HUB_EXCEPTIONS as error: + raise UpdateFailed( + f"Powerview Hub {self.hub.hub_address} did not return any data: {error}" + ) from error if not shade_entries: - raise UpdateFailed("Failed to fetch new shade data") + raise UpdateFailed("No new shade data was returned") # only update if shade_entries is valid - self.data.store_group_data(shade_entries[SHADE_DATA]) + self.data.store_group_data(shade_entries) return self.data diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 6d050bc1dbd..5637af57b72 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -4,21 +4,20 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Iterable from contextlib import suppress +from dataclasses import replace from datetime import datetime, timedelta import logging from math import ceil from typing import Any from aiopvapi.helpers.constants import ( - ATTR_POSITION1, - ATTR_POSITION2, - ATTR_POSITION_DATA, - ATTR_POSKIND1, - ATTR_POSKIND2, + ATTR_NAME, + CLOSED_POSITION, MAX_POSITION, MIN_POSITION, + MOTION_STOP, ) -from aiopvapi.resources.shade import BaseShade, factory as PvShade +from aiopvapi.resources.shade import BaseShade, ShadePosition from homeassistant.components.cover import ( ATTR_POSITION, @@ -32,20 +31,10 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from .const import ( - DOMAIN, - LEGACY_DEVICE_MODEL, - POS_KIND_PRIMARY, - POS_KIND_SECONDARY, - POS_KIND_VANE, - ROOM_ID_IN_SHADE, - ROOM_NAME_UNICODE, - STATE_ATTRIBUTE_ROOM_NAME, -) +from .const import DOMAIN, STATE_ATTRIBUTE_ROOM_NAME from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData -from .shade_data import PowerviewShadeMove _LOGGER = logging.getLogger(__name__) @@ -57,14 +46,6 @@ PARALLEL_UPDATES = 1 RESYNC_DELAY = 60 -# this equates to 0.75/100 in terms of hass blind position -# some blinds in a closed position report less than 655.35 (1%) -# but larger than 0 even though they are clearly closed -# Find 1 percent of MAX_POSITION, then find 75% of that number -# The means currently 491.5125 or less is closed position -# implemented for top/down shades, but also works fine with normal shades -CLOSED_POSITION = (0.75 / 100) * (MAX_POSITION - MIN_POSITION) - SCAN_INTERVAL = timedelta(minutes=10) @@ -77,42 +58,23 @@ async def async_setup_entry( coordinator: PowerviewShadeUpdateCoordinator = pv_entry.coordinator entities: list[ShadeEntity] = [] - for raw_shade in pv_entry.shade_data.values(): + for shade in pv_entry.shade_data.values(): # The shade may be out of sync with the hub # so we force a refresh when we add it if possible - shade: BaseShade = PvShade(raw_shade, pv_entry.api) - name_before_refresh = shade.name - with suppress(asyncio.TimeoutError): + with suppress(TimeoutError): async with asyncio.timeout(1): await shade.refresh() - - if ATTR_POSITION_DATA not in shade.raw_data: - _LOGGER.info( - "The %s shade was skipped because it is missing position data", - name_before_refresh, - ) - continue - coordinator.data.update_shade_positions(shade.raw_data) - room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") + coordinator.data.update_shade_position(shade.id, shade.current_position) + room_name = getattr(pv_entry.room_data.get(shade.room_id), ATTR_NAME, "") entities.extend( create_powerview_shade_entity( - coordinator, pv_entry.device_info, room_name, shade, name_before_refresh + coordinator, pv_entry.device_info, room_name, shade, shade.name ) ) + async_add_entities(entities) -def hd_position_to_hass(hd_position: int, max_val: int = MAX_POSITION) -> int: - """Convert hunter douglas position to hass position.""" - return round((hd_position / max_val) * 100) - - -def hass_position_to_hd(hass_position: int, max_val: int = MAX_POSITION) -> int: - """Convert hass position to hunter douglas position.""" - return int(hass_position / 100 * max_val) - - class PowerViewShadeBase(ShadeEntity, CoverEntity): """Representation of a powerview shade.""" @@ -135,7 +97,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): super().__init__(coordinator, device_info, room_name, shade, name) self._shade: BaseShade = shade self._scheduled_transition_update: CALLBACK_TYPE | None = None - if self._device_info.model != LEGACY_DEVICE_MODEL: + if self._shade.is_supported(MOTION_STOP): self._attr_supported_features |= CoverEntityFeature.STOP self._forced_resync: Callable[[], None] | None = None @@ -172,22 +134,22 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): @property def current_cover_position(self) -> int: """Return the current position of cover.""" - return hd_position_to_hass(self.positions.primary, MAX_POSITION) + return self.positions.primary @property def transition_steps(self) -> int: """Return the steps to make a move.""" - return hd_position_to_hass(self.positions.primary, MAX_POSITION) + return self.positions.primary @property - def open_position(self) -> PowerviewShadeMove: + def open_position(self) -> ShadePosition: """Return the open position and required additional positions.""" - return PowerviewShadeMove(self._shade.open_position, {}) + return replace(self._shade.open_position, velocity=self.positions.velocity) @property - def close_position(self) -> PowerviewShadeMove: + def close_position(self) -> ShadePosition: """Return the close position and required additional positions.""" - return PowerviewShadeMove(self._shade.close_position, {}) + return replace(self._shade.close_position, velocity=self.positions.velocity) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" @@ -208,12 +170,12 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self._async_cancel_scheduled_transition_update() - self.data.update_from_response(await self._shade.stop()) + await self._shade.stop() await self._async_force_refresh_state() @callback def _clamp_cover_limit(self, target_hass_position: int) -> int: - """Dont allow a cover to go into an impossbile position.""" + """Don't allow a cover to go into an impossbile position.""" # no override required in base return target_hass_position @@ -222,21 +184,21 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): await self._async_set_cover_position(kwargs[ATTR_POSITION]) @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_one = hass_position_to_hd(target_hass_position) - return PowerviewShadeMove( - {ATTR_POSITION1: position_one, ATTR_POSKIND1: POS_KIND_PRIMARY}, {} + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=target_hass_position, + velocity=self.positions.velocity, ) - async def _async_execute_move(self, move: PowerviewShadeMove) -> None: + async def _async_execute_move(self, move: ShadePosition) -> None: """Execute a move that can affect multiple positions.""" - response = await self._shade.move(move.request) - # Process any positions we know will update as result - # of the request since the hub won't return them - for kind, position in move.new_positions.items(): - self.data.update_shade_position(self._shade.id, position, kind) - # Finally process the response - self.data.update_from_response(response) + _LOGGER.debug("Move request %s: %s", self.name, move) + response = await self._shade.move(move) + _LOGGER.debug("Move response %s: %s", self.name, response) + + # Process the response from the hub (including new positions) + self.data.update_shade_position(self._shade.id, response) async def _async_set_cover_position(self, target_hass_position: int) -> None: """Move the shade to a position.""" @@ -251,9 +213,9 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): self.async_write_ha_state() @callback - def _async_update_shade_data(self, shade_data: dict[str | int, Any]) -> None: + def _async_update_shade_data(self, shade_data: ShadePosition) -> None: """Update the current cover position from the data.""" - self.data.update_shade_positions(shade_data) + self.data.update_shade_position(self._shade.id, shade_data) self._attr_is_opening = False self._attr_is_closing = False @@ -283,7 +245,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): est_time_to_complete_transition, ) - # Schedule an forced update for when we expect the transition + # Schedule a forced update for when we expect the transition # to be completed. self._scheduled_transition_update = async_call_later( self.hass, @@ -342,8 +304,12 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): # The update will likely timeout and # error if are already have one in flight return - await self._shade.refresh() - self._async_update_shade_data(self._shade.raw_data) + # suppress timeouts caused by hub nightly reboot + with suppress(asyncio.TimeoutError): + async with asyncio.timeout(5): + await self._shade.refresh() + _LOGGER.debug("Process update %s: %s", self.name, self._shade.current_position) + self._async_update_shade_data(self._shade.current_position) class PowerViewShade(PowerViewShadeBase): @@ -372,31 +338,31 @@ class PowerViewShadeWithTiltBase(PowerViewShadeBase): | CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.SET_TILT_POSITION ) - if self._device_info.model != LEGACY_DEVICE_MODEL: + if self._shade.is_supported(MOTION_STOP): self._attr_supported_features |= CoverEntityFeature.STOP_TILT self._max_tilt = self._shade.shade_limits.tilt_max @property def current_cover_tilt_position(self) -> int: """Return the current cover tile position.""" - return hd_position_to_hass(self.positions.vane, self._max_tilt) + return self.positions.tilt @property def transition_steps(self) -> int: """Return the steps to make a move.""" - return hd_position_to_hass( - self.positions.primary, MAX_POSITION - ) + hd_position_to_hass(self.positions.vane, self._max_tilt) + return self.positions.primary + self.positions.tilt @property - def open_tilt_position(self) -> PowerviewShadeMove: + def open_tilt_position(self) -> ShadePosition: """Return the open tilt position and required additional positions.""" - return PowerviewShadeMove(self._shade.open_position_tilt, {}) + return replace(self._shade.open_position_tilt, velocity=self.positions.velocity) @property - def close_tilt_position(self) -> PowerviewShadeMove: + def close_tilt_position(self) -> ShadePosition: """Return the close tilt position and required additional positions.""" - return PowerviewShadeMove(self._shade.close_position_tilt, {}) + return replace( + self._shade.close_position_tilt, velocity=self.positions.velocity + ) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" @@ -411,13 +377,13 @@ class PowerViewShadeWithTiltBase(PowerViewShadeBase): self.async_write_ha_state() async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: - """Move the vane to a specific position.""" + """Move the tilt to a specific position.""" await self._async_set_cover_tilt_position(kwargs[ATTR_TILT_POSITION]) async def _async_set_cover_tilt_position( self, target_hass_tilt_position: int ) -> None: - """Move the vane to a specific position.""" + """Move the tilt to a specific position.""" final_position = self.current_cover_position + target_hass_tilt_position self._async_schedule_update_for_transition( abs(self.transition_steps - final_position) @@ -426,11 +392,19 @@ class PowerViewShadeWithTiltBase(PowerViewShadeBase): self.async_write_ha_state() @callback - def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: - """Return a PowerviewShadeMove.""" - position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) - return PowerviewShadeMove( - {ATTR_POSITION1: position_vane, ATTR_POSKIND1: POS_KIND_VANE}, {} + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=target_hass_position, + velocity=self.positions.velocity, + ) + + @callback + def _get_shade_tilt(self, target_hass_tilt_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + tilt=target_hass_tilt_position, + velocity=self.positions.velocity, ) async def async_stop_cover_tilt(self, **kwargs: Any) -> None: @@ -450,49 +424,25 @@ class PowerViewShadeWithTiltOnClosed(PowerViewShadeWithTiltBase): _attr_name = None @property - def open_position(self) -> PowerviewShadeMove: + def open_position(self) -> ShadePosition: """Return the open position and required additional positions.""" - return PowerviewShadeMove( - self._shade.open_position, {POS_KIND_VANE: MIN_POSITION} - ) + return replace(self._shade.open_position, velocity=self.positions.velocity) @property - def close_position(self) -> PowerviewShadeMove: + def close_position(self) -> ShadePosition: """Return the close position and required additional positions.""" - return PowerviewShadeMove( - self._shade.close_position, {POS_KIND_VANE: MIN_POSITION} - ) + return replace(self._shade.close_position, velocity=self.positions.velocity) @property - def open_tilt_position(self) -> PowerviewShadeMove: + def open_tilt_position(self) -> ShadePosition: """Return the open tilt position and required additional positions.""" - return PowerviewShadeMove( - self._shade.open_position_tilt, {POS_KIND_PRIMARY: MIN_POSITION} - ) + return replace(self._shade.open_position_tilt, velocity=self.positions.velocity) @property - def close_tilt_position(self) -> PowerviewShadeMove: + def close_tilt_position(self) -> ShadePosition: """Return the close tilt position and required additional positions.""" - return PowerviewShadeMove( - self._shade.close_position_tilt, {POS_KIND_PRIMARY: MIN_POSITION} - ) - - @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - """Return a PowerviewShadeMove.""" - position_shade = hass_position_to_hd(target_hass_position) - return PowerviewShadeMove( - {ATTR_POSITION1: position_shade, ATTR_POSKIND1: POS_KIND_PRIMARY}, - {POS_KIND_VANE: MIN_POSITION}, - ) - - @callback - def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: - """Return a PowerviewShadeMove.""" - position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) - return PowerviewShadeMove( - {ATTR_POSITION1: position_vane, ATTR_POSKIND1: POS_KIND_VANE}, - {POS_KIND_PRIMARY: MIN_POSITION}, + return replace( + self._shade.close_position_tilt, velocity=self.positions.velocity ) @@ -506,32 +456,21 @@ class PowerViewShadeWithTiltAnywhere(PowerViewShadeWithTiltBase): """ @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) - position_vane = self.positions.vane - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSITION2: position_vane, - ATTR_POSKIND1: POS_KIND_PRIMARY, - ATTR_POSKIND2: POS_KIND_VANE, - }, - {}, + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=target_hass_position, + tilt=self.positions.tilt, + velocity=self.positions.velocity, ) @callback - def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: - """Return a PowerviewShadeMove.""" - position_shade = self.positions.primary - position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSITION2: position_vane, - ATTR_POSKIND1: POS_KIND_PRIMARY, - ATTR_POSKIND2: POS_KIND_VANE, - }, - {}, + def _get_shade_tilt(self, target_hass_tilt_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=self.positions.primary, + tilt=target_hass_tilt_position, + velocity=self.positions.velocity, ) @@ -558,7 +497,7 @@ class PowerViewShadeTiltOnly(PowerViewShadeWithTiltBase): | CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.SET_TILT_POSITION ) - if self._device_info.model != LEGACY_DEVICE_MODEL: + if self._shade.is_supported(MOTION_STOP): self._attr_supported_features |= CoverEntityFeature.STOP_TILT self._max_tilt = self._shade.shade_limits.tilt_max @@ -577,17 +516,18 @@ class PowerViewShadeTopDown(PowerViewShadeBase): @property def current_cover_position(self) -> int: """Return the current position of cover.""" - return hd_position_to_hass(MAX_POSITION - self.positions.primary, MAX_POSITION) + # inverted positioning + return MAX_POSITION - self.positions.primary + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the shade to a specific position.""" + await self._async_set_cover_position(MAX_POSITION - kwargs[ATTR_POSITION]) @property def is_closed(self) -> bool: """Return if the cover is closed.""" return (MAX_POSITION - self.positions.primary) <= CLOSED_POSITION - async def async_set_cover_position(self, **kwargs: Any) -> None: - """Move the shade to a specific position.""" - await self._async_set_cover_position(100 - kwargs[ATTR_POSITION]) - class PowerViewShadeDualRailBase(PowerViewShadeBase): """Representation of a shade with top/down bottom/up capabilities. @@ -600,9 +540,7 @@ class PowerViewShadeDualRailBase(PowerViewShadeBase): @property def transition_steps(self) -> int: """Return the steps to make a move.""" - return hd_position_to_hass( - self.positions.primary, MAX_POSITION - ) + hd_position_to_hass(self.positions.secondary, MAX_POSITION) + return self.positions.primary + self.positions.secondary class PowerViewShadeTDBUBottom(PowerViewShadeDualRailBase): @@ -629,22 +567,16 @@ class PowerViewShadeTDBUBottom(PowerViewShadeDualRailBase): @callback def _clamp_cover_limit(self, target_hass_position: int) -> int: - """Dont allow a cover to go into an impossbile position.""" - cover_top = hd_position_to_hass(self.positions.secondary, MAX_POSITION) - return min(target_hass_position, (100 - cover_top)) + """Don't allow a cover to go into an impossbile position.""" + return min(target_hass_position, (MAX_POSITION - self.positions.secondary)) @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_bottom = hass_position_to_hd(target_hass_position) - position_top = self.positions.secondary - return PowerviewShadeMove( - { - ATTR_POSITION1: position_bottom, - ATTR_POSITION2: position_top, - ATTR_POSKIND1: POS_KIND_PRIMARY, - ATTR_POSKIND2: POS_KIND_SECONDARY, - }, - {}, + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=target_hass_position, + secondary=self.positions.secondary, + velocity=self.positions.velocity, ) @@ -689,41 +621,31 @@ class PowerViewShadeTDBUTop(PowerViewShadeDualRailBase): def current_cover_position(self) -> int: """Return the current position of cover.""" # these need to be inverted to report state correctly in HA - return hd_position_to_hass(self.positions.secondary, MAX_POSITION) + return self.positions.secondary @property - def open_position(self) -> PowerviewShadeMove: + def open_position(self) -> ShadePosition: """Return the open position and required additional positions.""" # these shades share a class in parent API # override open position for top shade - return PowerviewShadeMove( - { - ATTR_POSITION1: MIN_POSITION, - ATTR_POSITION2: MAX_POSITION, - ATTR_POSKIND1: POS_KIND_PRIMARY, - ATTR_POSKIND2: POS_KIND_SECONDARY, - }, - {}, + return ShadePosition( + primary=MIN_POSITION, + secondary=MAX_POSITION, + velocity=self.positions.velocity, ) @callback def _clamp_cover_limit(self, target_hass_position: int) -> int: """Don't allow a cover to go into an impossbile position.""" - cover_bottom = hd_position_to_hass(self.positions.primary, MAX_POSITION) - return min(target_hass_position, (100 - cover_bottom)) + return min(target_hass_position, (MAX_POSITION - self.positions.primary)) @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_bottom = self.positions.primary - position_top = hass_position_to_hd(target_hass_position, MAX_POSITION) - return PowerviewShadeMove( - { - ATTR_POSITION1: position_bottom, - ATTR_POSITION2: position_top, - ATTR_POSKIND1: POS_KIND_PRIMARY, - ATTR_POSKIND2: POS_KIND_SECONDARY, - }, - {}, + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=self.positions.primary, + secondary=target_hass_position, + velocity=self.positions.velocity, ) @@ -739,33 +661,27 @@ class PowerViewShadeDualOverlappedBase(PowerViewShadeBase): # poskind 1 represents the second half of the shade in hass # front must be fully closed before rear can move # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades - primary = (hd_position_to_hass(self.positions.primary, MAX_POSITION) / 2) + 50 + primary = (self.positions.primary / 2) + 50 # poskind 2 represents the shade first half of the shade in hass # rear (opaque) must be fully open before front can move # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades - secondary = hd_position_to_hass(self.positions.secondary, MAX_POSITION) / 2 + secondary = self.positions.secondary / 2 return ceil(primary + secondary) @property - def open_position(self) -> PowerviewShadeMove: + def open_position(self) -> ShadePosition: """Return the open position and required additional positions.""" - return PowerviewShadeMove( - { - ATTR_POSITION1: MAX_POSITION, - ATTR_POSKIND1: POS_KIND_PRIMARY, - }, - {POS_KIND_SECONDARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + primary=MAX_POSITION, + velocity=self.positions.velocity, ) @property - def close_position(self) -> PowerviewShadeMove: + def close_position(self) -> ShadePosition: """Return the open position and required additional positions.""" - return PowerviewShadeMove( - { - ATTR_POSITION1: MIN_POSITION, - ATTR_POSKIND1: POS_KIND_SECONDARY, - }, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + secondary=MIN_POSITION, + velocity=self.positions.velocity, ) @@ -782,7 +698,6 @@ class PowerViewShadeDualOverlappedCombined(PowerViewShadeDualOverlappedBase): _attr_translation_key = "combined" - # type def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, @@ -806,36 +721,28 @@ class PowerViewShadeDualOverlappedCombined(PowerViewShadeDualOverlappedBase): """Return the current position of cover.""" # if front is open return that (other positions are impossible) # if front shade is closed get position of rear - position = (hd_position_to_hass(self.positions.primary, MAX_POSITION) / 2) + 50 + position = (self.positions.primary / 2) + 50 if self.positions.primary == MIN_POSITION: - position = hd_position_to_hass(self.positions.secondary, MAX_POSITION) / 2 + position = self.positions.secondary / 2 return ceil(position) @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) - # note we set POS_KIND_VANE: MIN_POSITION here even with shades without - # tilt so no additional override is required for differences between type 8/9/10 - # this just stores the value in the coordinator for future reference + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + # 0 - 50 represents the rear blockut shade if target_hass_position <= 50: target_hass_position = target_hass_position * 2 - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSKIND1: POS_KIND_SECONDARY, - }, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + secondary=target_hass_position, + velocity=self.positions.velocity, ) # 51 <= target_hass_position <= 100 (51-100 represents front sheer shade) target_hass_position = (target_hass_position - 50) * 2 - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSKIND1: POS_KIND_PRIMARY, - }, - {POS_KIND_SECONDARY: MAX_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + primary=target_hass_position, + velocity=self.positions.velocity, ) @@ -879,28 +786,19 @@ class PowerViewShadeDualOverlappedFront(PowerViewShadeDualOverlappedBase): return False @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) - # note we set POS_KIND_VANE: MIN_POSITION here even with shades without tilt so no additional - # override is required for differences between type 8/9/10 - # this just stores the value in the coordinator for future reference - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSKIND1: POS_KIND_PRIMARY, - }, - {POS_KIND_SECONDARY: MAX_POSITION, POS_KIND_VANE: MIN_POSITION}, + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=target_hass_position, + velocity=self.positions.velocity, ) @property - def close_position(self) -> PowerviewShadeMove: + def close_position(self) -> ShadePosition: """Return the close position and required additional positions.""" - return PowerviewShadeMove( - { - ATTR_POSITION1: MIN_POSITION, - ATTR_POSKIND1: POS_KIND_PRIMARY, - }, - {POS_KIND_SECONDARY: MAX_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + primary=MIN_POSITION, + velocity=self.positions.velocity, ) @@ -952,31 +850,22 @@ class PowerViewShadeDualOverlappedRear(PowerViewShadeDualOverlappedBase): @property def current_cover_position(self) -> int: """Return the current position of cover.""" - return hd_position_to_hass(self.positions.secondary, MAX_POSITION) + return self.positions.secondary @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) - # note we set POS_KIND_VANE: MIN_POSITION here even with shades without tilt so no additional - # override is required for differences between type 8/9/10 - # this just stores the value in the coordinator for future reference - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSKIND1: POS_KIND_SECONDARY, - }, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + secondary=target_hass_position, + velocity=self.positions.velocity, ) @property - def open_position(self) -> PowerviewShadeMove: + def open_position(self) -> ShadePosition: """Return the open position and required additional positions.""" - return PowerviewShadeMove( - { - ATTR_POSITION1: MAX_POSITION, - ATTR_POSKIND1: POS_KIND_SECONDARY, - }, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + secondary=MAX_POSITION, + velocity=self.positions.velocity, ) @@ -1010,7 +899,7 @@ class PowerViewShadeDualOverlappedCombinedTilt(PowerViewShadeDualOverlappedCombi | CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.SET_TILT_POSITION ) - if self._device_info.model != LEGACY_DEVICE_MODEL: + if self._shade.is_supported(MOTION_STOP): self._attr_supported_features |= CoverEntityFeature.STOP_TILT self._max_tilt = self._shade.shade_limits.tilt_max @@ -1020,40 +909,32 @@ class PowerViewShadeDualOverlappedCombinedTilt(PowerViewShadeDualOverlappedCombi # poskind 1 represents the second half of the shade in hass # front must be fully closed before rear can move # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades - primary = (hd_position_to_hass(self.positions.primary, MAX_POSITION) / 2) + 50 + primary = (self.positions.primary / 2) + 50 # poskind 2 represents the shade first half of the shade in hass # rear (opaque) must be fully open before front can move # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades - secondary = hd_position_to_hass(self.positions.secondary, MAX_POSITION) / 2 - vane = hd_position_to_hass(self.positions.vane, self._max_tilt) - return ceil(primary + secondary + vane) + secondary = self.positions.secondary / 2 + tilt = self.positions.tilt + return ceil(primary + secondary + tilt) @callback - def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: - """Return a PowerviewShadeMove.""" - position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) - return PowerviewShadeMove( - { - ATTR_POSITION1: position_vane, - ATTR_POSKIND1: POS_KIND_VANE, - }, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION}, + def _get_shade_tilt(self, target_hass_tilt_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + tilt=target_hass_tilt_position, + velocity=self.positions.velocity, ) @property - def open_tilt_position(self) -> PowerviewShadeMove: + def open_tilt_position(self) -> ShadePosition: """Return the open tilt position and required additional positions.""" - return PowerviewShadeMove( - self._shade.open_position_tilt, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION}, - ) + return replace(self._shade.open_position_tilt, velocity=self.positions.velocity) @property - def close_tilt_position(self) -> PowerviewShadeMove: + def close_tilt_position(self) -> ShadePosition: """Return the open tilt position and required additional positions.""" - return PowerviewShadeMove( - self._shade.open_position_tilt, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION}, + return replace( + self._shade.close_position_tilt, velocity=self.positions.velocity ) @@ -1099,7 +980,8 @@ def create_powerview_shade_entity( shade.capability.type, (PowerViewShade,) ) _LOGGER.debug( - "%s (%s) detected as %a %s", + "%s %s (%s) detected as %a %s", + room_name, shade.name, shade.capability.type, classes, diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index 78f63e16879..424d314c4b9 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -1,25 +1,19 @@ """The powerview integration base entity.""" -from aiopvapi.resources.shade import ATTR_TYPE, BaseShade +import logging + +from aiopvapi.resources.shade import BaseShade, ShadePosition -from homeassistant.const import ATTR_MODEL, ATTR_SW_VERSION import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - ATTR_BATTERY_KIND, - BATTERY_KIND_HARDWIRED, - DOMAIN, - FIRMWARE, - FIRMWARE_BUILD, - FIRMWARE_REVISION, - FIRMWARE_SUB_REVISION, - MANUFACTURER, -) +from .const import DOMAIN, MANUFACTURER from .coordinator import PowerviewShadeUpdateCoordinator from .model import PowerviewDeviceInfo -from .shade_data import PowerviewShadeData, PowerviewShadePositions +from .shade_data import PowerviewShadeData + +_LOGGER = logging.getLogger(__name__) class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): @@ -39,6 +33,7 @@ class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): self._room_name = room_name self._attr_unique_id = unique_id self._device_info = device_info + self._configuration_url = self.coordinator.hub.url @property def data(self) -> PowerviewShadeData: @@ -48,17 +43,14 @@ class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): @property def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" - firmware = self._device_info.firmware - sw_version = f"{firmware[FIRMWARE_REVISION]}.{firmware[FIRMWARE_SUB_REVISION]}.{firmware[FIRMWARE_BUILD]}" return DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)}, identifiers={(DOMAIN, self._device_info.serial_number)}, manufacturer=MANUFACTURER, model=self._device_info.model, name=self._device_info.name, - suggested_area=self._room_name, - sw_version=sw_version, - configuration_url=f"http://{self._device_info.hub_address}/api/shades", + sw_version=self._device_info.firmware, + configuration_url=self._configuration_url, ) @@ -77,42 +69,24 @@ class ShadeEntity(HDEntity): super().__init__(coordinator, device_info, room_name, shade.id) self._shade_name = shade_name self._shade = shade - self._is_hard_wired = bool( - shade.raw_data.get(ATTR_BATTERY_KIND) == BATTERY_KIND_HARDWIRED - ) + self._is_hard_wired = not shade.is_battery_powered() + self._configuration_url = shade.url @property - def positions(self) -> PowerviewShadePositions: + def positions(self) -> ShadePosition: """Return the PowerviewShadeData.""" - return self.data.get_shade_positions(self._shade.id) + return self.data.get_shade_position(self._shade.id) @property def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" - - device_info = DeviceInfo( + return DeviceInfo( identifiers={(DOMAIN, self._shade.id)}, name=self._shade_name, suggested_area=self._room_name, manufacturer=MANUFACTURER, - model=str(self._shade.raw_data[ATTR_TYPE]), + model=self._shade.type_name, + sw_version=self._shade.firmware, via_device=(DOMAIN, self._device_info.serial_number), - configuration_url=( - f"http://{self._device_info.hub_address}/api/shades/{self._shade.id}" - ), + configuration_url=self._configuration_url, ) - - for shade in self._shade.shade_types: - if str(shade.shade_type) == device_info[ATTR_MODEL]: - device_info[ATTR_MODEL] = shade.description - break - - if FIRMWARE not in self._shade.raw_data: - return device_info - - firmware = self._shade.raw_data[FIRMWARE] - sw_version = f"{firmware[FIRMWARE_REVISION]}.{firmware[FIRMWARE_SUB_REVISION]}.{firmware[FIRMWARE_BUILD]}" - - device_info[ATTR_SW_VERSION] = sw_version - - return device_info diff --git a/homeassistant/components/hunterdouglas_powerview/manifest.json b/homeassistant/components/hunterdouglas_powerview/manifest.json index f62879aed78..276b10f5e8d 100644 --- a/homeassistant/components/hunterdouglas_powerview/manifest.json +++ b/homeassistant/components/hunterdouglas_powerview/manifest.json @@ -18,6 +18,6 @@ }, "iot_class": "local_polling", "loggers": ["aiopvapi"], - "requirements": ["aiopvapi==2.0.4"], - "zeroconf": ["_powerview._tcp.local."] + "requirements": ["aiopvapi==3.0.2"], + "zeroconf": ["_powerview._tcp.local.", "_powerview-g3._tcp.local."] } diff --git a/homeassistant/components/hunterdouglas_powerview/model.py b/homeassistant/components/hunterdouglas_powerview/model.py index b7ad4a7439c..e2311eb4e4c 100644 --- a/homeassistant/components/hunterdouglas_powerview/model.py +++ b/homeassistant/components/hunterdouglas_powerview/model.py @@ -2,9 +2,11 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any from aiopvapi.helpers.aiorequest import AioRequest +from aiopvapi.resources.room import Room +from aiopvapi.resources.scene import Scene +from aiopvapi.resources.shade import BaseShade from .coordinator import PowerviewShadeUpdateCoordinator @@ -14,9 +16,9 @@ class PowerviewEntryData: """Define class for main domain information.""" api: AioRequest - room_data: dict[str, Any] - scene_data: dict[str, Any] - shade_data: dict[str, Any] + room_data: dict[str, Room] + scene_data: dict[str, Scene] + shade_data: dict[str, BaseShade] coordinator: PowerviewShadeUpdateCoordinator device_info: PowerviewDeviceInfo @@ -28,6 +30,6 @@ class PowerviewDeviceInfo: name: str mac_address: str serial_number: str - firmware: dict[str, Any] + firmware: str | None model: str hub_address: str diff --git a/homeassistant/components/hunterdouglas_powerview/scene.py b/homeassistant/components/hunterdouglas_powerview/scene.py index 4676a8d1505..0ba9b13d03b 100644 --- a/homeassistant/components/hunterdouglas_powerview/scene.py +++ b/homeassistant/components/hunterdouglas_powerview/scene.py @@ -1,8 +1,10 @@ """Support for Powerview scenes from a Powerview hub.""" from __future__ import annotations +import logging from typing import Any +from aiopvapi.helpers.constants import ATTR_NAME from aiopvapi.resources.scene import Scene as PvScene from homeassistant.components.scene import Scene @@ -10,11 +12,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, ROOM_NAME_UNICODE, STATE_ATTRIBUTE_ROOM_NAME +from .const import DOMAIN, STATE_ATTRIBUTE_ROOM_NAME from .coordinator import PowerviewShadeUpdateCoordinator from .entity import HDEntity from .model import PowerviewDeviceInfo, PowerviewEntryData +_LOGGER = logging.getLogger(__name__) + +RESYNC_DELAY = 60 + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -24,9 +30,8 @@ async def async_setup_entry( pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] pvscenes: list[PowerViewScene] = [] - for raw_scene in pv_entry.scene_data.values(): - scene = PvScene(raw_scene, pv_entry.api) - room_name = pv_entry.room_data.get(scene.room_id, {}).get(ROOM_NAME_UNICODE, "") + for scene in pv_entry.scene_data.values(): + room_name = getattr(pv_entry.room_data.get(scene.room_id), ATTR_NAME, "") pvscenes.append( PowerViewScene(pv_entry.coordinator, pv_entry.device_info, room_name, scene) ) @@ -47,10 +52,11 @@ class PowerViewScene(HDEntity, Scene): ) -> None: """Initialize the scene.""" super().__init__(coordinator, device_info, room_name, scene.id) - self._scene = scene + self._scene: PvScene = scene self._attr_name = scene.name self._attr_extra_state_attributes = {STATE_ATTRIBUTE_ROOM_NAME: room_name} async def async_activate(self, **kwargs: Any) -> None: """Activate scene. Try to get entities into requested state.""" - await self._scene.activate() + shades = await self._scene.activate() + _LOGGER.debug("Scene activated for shade(s) %s", shades) diff --git a/homeassistant/components/hunterdouglas_powerview/select.py b/homeassistant/components/hunterdouglas_powerview/select.py index 65fe61851df..bbe4614afd1 100644 --- a/homeassistant/components/hunterdouglas_powerview/select.py +++ b/homeassistant/components/hunterdouglas_powerview/select.py @@ -3,9 +3,11 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass +import logging from typing import Any, Final -from aiopvapi.resources.shade import BaseShade, factory as PvShade +from aiopvapi.helpers.constants import ATTR_NAME, FUNCTION_SET_POWER +from aiopvapi.resources.shade import BaseShade from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -13,19 +15,13 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ATTR_BATTERY_KIND, - DOMAIN, - POWER_SUPPLY_TYPE_MAP, - POWER_SUPPLY_TYPE_REVERSE_MAP, - ROOM_ID_IN_SHADE, - ROOM_NAME_UNICODE, - SHADE_BATTERY_LEVEL, -) +from .const import DOMAIN from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData +_LOGGER = logging.getLogger(__name__) + @dataclass(frozen=True) class PowerviewSelectDescriptionMixin: @@ -33,6 +29,8 @@ class PowerviewSelectDescriptionMixin: current_fn: Callable[[BaseShade], Any] select_fn: Callable[[BaseShade, str], Coroutine[Any, Any, bool]] + create_entity_fn: Callable[[BaseShade], bool] + options_fn: Callable[[BaseShade], list[str]] @dataclass(frozen=True) @@ -49,13 +47,10 @@ DROPDOWNS: Final = [ key="powersource", translation_key="power_source", icon="mdi:power-plug-outline", - current_fn=lambda shade: POWER_SUPPLY_TYPE_MAP.get( - shade.raw_data.get(ATTR_BATTERY_KIND), None - ), - options=list(POWER_SUPPLY_TYPE_MAP.values()), - select_fn=lambda shade, option: shade.set_power_source( - POWER_SUPPLY_TYPE_REVERSE_MAP.get(option) - ), + current_fn=lambda shade: shade.get_power_source(), + options_fn=lambda shade: shade.supported_power_sources(), + select_fn=lambda shade, option: shade.set_power_source(option), + create_entity_fn=lambda shade: shade.is_supported(FUNCTION_SET_POWER), ), ] @@ -67,26 +62,23 @@ async def async_setup_entry( pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] - entities = [] - for raw_shade in pv_entry.shade_data.values(): - shade: BaseShade = PvShade(raw_shade, pv_entry.api) - if SHADE_BATTERY_LEVEL not in shade.raw_data: + entities: list[PowerViewSelect] = [] + for shade in pv_entry.shade_data.values(): + if not shade.has_battery_info(): continue - name_before_refresh = shade.name - room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") - + room_name = getattr(pv_entry.room_data.get(shade.room_id), ATTR_NAME, "") for description in DROPDOWNS: - entities.append( - PowerViewSelect( - pv_entry.coordinator, - pv_entry.device_info, - room_name, - shade, - name_before_refresh, - description, + if description.create_entity_fn(shade): + entities.append( + PowerViewSelect( + pv_entry.coordinator, + pv_entry.device_info, + room_name, + shade, + shade.name, + description, + ) ) - ) async_add_entities(entities) @@ -113,6 +105,11 @@ class PowerViewSelect(ShadeEntity, SelectEntity): """Return the selected entity option to represent the entity state.""" return self.entity_description.current_fn(self._shade) + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + return self.entity_description.options_fn(self._shade) + async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.entity_description.select_fn(self._shade, option) diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 8e16d53ae09..02b4ae7c557 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -4,7 +4,8 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any, Final -from aiopvapi.resources.shade import BaseShade, factory as PvShade +from aiopvapi.helpers.constants import ATTR_NAME +from aiopvapi.resources.shade import BaseShade from homeassistant.components.sensor import ( SensorDeviceClass, @@ -13,21 +14,11 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ATTR_BATTERY_KIND, - ATTR_SIGNAL_STRENGTH, - ATTR_SIGNAL_STRENGTH_MAX, - BATTERY_KIND_HARDWIRED, - DOMAIN, - ROOM_ID_IN_SHADE, - ROOM_NAME_UNICODE, - SHADE_BATTERY_LEVEL, - SHADE_BATTERY_LEVEL_MAX, -) +from .const import DOMAIN from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData @@ -38,8 +29,10 @@ class PowerviewSensorDescriptionMixin: """Mixin to describe a Sensor entity.""" update_fn: Callable[[BaseShade], Any] + device_class_fn: Callable[[BaseShade], SensorDeviceClass | None] native_value_fn: Callable[[BaseShade], int] - create_sensor_fn: Callable[[BaseShade], bool] + native_unit_fn: Callable[[BaseShade], str | None] + create_entity_fn: Callable[[BaseShade], bool] @dataclass(frozen=True) @@ -52,29 +45,33 @@ class PowerviewSensorDescription( state_class = SensorStateClass.MEASUREMENT +def get_signal_device_class(shade: BaseShade) -> SensorDeviceClass | None: + """Get the signal value based on version of API.""" + return SensorDeviceClass.SIGNAL_STRENGTH if shade.api_version >= 3 else None + + +def get_signal_native_unit(shade: BaseShade) -> str: + """Get the unit of measurement for signal based on version of API.""" + return SIGNAL_STRENGTH_DECIBELS if shade.api_version >= 3 else PERCENTAGE + + SENSORS: Final = [ PowerviewSensorDescription( key="charge", - device_class=SensorDeviceClass.BATTERY, - native_unit_of_measurement=PERCENTAGE, - native_value_fn=lambda shade: round( - shade.raw_data[SHADE_BATTERY_LEVEL] / SHADE_BATTERY_LEVEL_MAX * 100 - ), - create_sensor_fn=lambda shade: bool( - shade.raw_data.get(ATTR_BATTERY_KIND) != BATTERY_KIND_HARDWIRED - and SHADE_BATTERY_LEVEL in shade.raw_data - ), + device_class_fn=lambda shade: SensorDeviceClass.BATTERY, + native_unit_fn=lambda shade: PERCENTAGE, + native_value_fn=lambda shade: shade.get_battery_strength(), + create_entity_fn=lambda shade: shade.is_battery_powered(), update_fn=lambda shade: shade.refresh_battery(), ), PowerviewSensorDescription( key="signal", translation_key="signal_strength", icon="mdi:signal", - native_unit_of_measurement=PERCENTAGE, - native_value_fn=lambda shade: round( - shade.raw_data[ATTR_SIGNAL_STRENGTH] / ATTR_SIGNAL_STRENGTH_MAX * 100 - ), - create_sensor_fn=lambda shade: bool(ATTR_SIGNAL_STRENGTH in shade.raw_data), + device_class_fn=get_signal_device_class, + native_unit_fn=get_signal_native_unit, + native_value_fn=lambda shade: shade.get_signal_strength(), + create_entity_fn=lambda shade: shade.has_signal_strength(), update_fn=lambda shade: shade.refresh(), entity_registry_enabled_default=False, ), @@ -89,21 +86,17 @@ async def async_setup_entry( pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] entities: list[PowerViewSensor] = [] - for raw_shade in pv_entry.shade_data.values(): - shade: BaseShade = PvShade(raw_shade, pv_entry.api) - name_before_refresh = shade.name - room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") - + for shade in pv_entry.shade_data.values(): + room_name = getattr(pv_entry.room_data.get(shade.room_id), ATTR_NAME, "") for description in SENSORS: - if description.create_sensor_fn(shade): + if description.create_entity_fn(shade): entities.append( PowerViewSensor( pv_entry.coordinator, pv_entry.device_info, room_name, shade, - name_before_refresh, + shade.name, description, ) ) @@ -125,17 +118,27 @@ class PowerViewSensor(ShadeEntity, SensorEntity): name: str, description: PowerviewSensorDescription, ) -> None: - """Initialize the select entity.""" + """Initialize the sensor entity.""" super().__init__(coordinator, device_info, room_name, shade, name) self.entity_description = description + self.entity_description: PowerviewSensorDescription = description self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" - self._attr_native_unit_of_measurement = description.native_unit_of_measurement @property def native_value(self) -> int: - """Get the current value in percentage.""" + """Get the current value of the sensor.""" return self.entity_description.native_value_fn(self._shade) + @property + def native_unit_of_measurement(self) -> str | None: + """Return native unit of measurement of sensor.""" + return self.entity_description.native_unit_fn(self._shade) + + @property + def device_class(self) -> SensorDeviceClass | None: + """Return the class of this entity.""" + return self.entity_description.device_class_fn(self._shade) + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" diff --git a/homeassistant/components/hunterdouglas_powerview/shade_data.py b/homeassistant/components/hunterdouglas_powerview/shade_data.py index fab14b540b7..86f232c3b66 100644 --- a/homeassistant/components/hunterdouglas_powerview/shade_data.py +++ b/homeassistant/components/hunterdouglas_powerview/shade_data.py @@ -1,59 +1,25 @@ """Shade data for the Hunter Douglas PowerView integration.""" from __future__ import annotations -from collections.abc import Iterable -from dataclasses import dataclass import logging from typing import Any -from aiopvapi.helpers.constants import ( - ATTR_ID, - ATTR_POSITION1, - ATTR_POSITION2, - ATTR_POSITION_DATA, - ATTR_POSKIND1, - ATTR_POSKIND2, - ATTR_SHADE, -) -from aiopvapi.resources.shade import MIN_POSITION +from aiopvapi.resources.model import PowerviewData +from aiopvapi.resources.shade import BaseShade, ShadePosition -from .const import POS_KIND_PRIMARY, POS_KIND_SECONDARY, POS_KIND_VANE from .util import async_map_data_by_id -POSITIONS = ((ATTR_POSITION1, ATTR_POSKIND1), (ATTR_POSITION2, ATTR_POSKIND2)) - _LOGGER = logging.getLogger(__name__) -@dataclass -class PowerviewShadeMove: - """Request to move a powerview shade.""" - - # The positions to request on the hub - request: dict[str, int] - - # The positions that will also change - # as a result of the request that the - # hub will not send back - new_positions: dict[int, int] - - -@dataclass -class PowerviewShadePositions: - """Positions for a powerview shade.""" - - primary: int = MIN_POSITION - secondary: int = MIN_POSITION - vane: int = MIN_POSITION - - class PowerviewShadeData: """Coordinate shade data between multiple api calls.""" def __init__(self) -> None: """Init the shade data.""" self._group_data_by_id: dict[int, dict[str | int, Any]] = {} - self.positions: dict[int, PowerviewShadePositions] = {} + self._shade_data_by_id: dict[int, BaseShade] = {} + self.positions: dict[int, ShadePosition] = {} def get_raw_data(self, shade_id: int) -> dict[str | int, Any]: """Get data for the shade.""" @@ -63,17 +29,21 @@ class PowerviewShadeData: """Get data for all shades.""" return self._group_data_by_id - def get_shade_positions(self, shade_id: int) -> PowerviewShadePositions: + def get_shade(self, shade_id: int) -> BaseShade: + """Get specific shade from the coordinator.""" + return self._shade_data_by_id[shade_id] + + def get_shade_position(self, shade_id: int) -> ShadePosition: """Get positions for a shade.""" if shade_id not in self.positions: - self.positions[shade_id] = PowerviewShadePositions() + self.positions[shade_id] = ShadePosition() return self.positions[shade_id] def update_from_group_data(self, shade_id: int) -> None: """Process an update from the group data.""" - self.update_shade_positions(self._group_data_by_id[shade_id]) + self.update_shade_positions(self._shade_data_by_id[shade_id]) - def store_group_data(self, shade_data: Iterable[dict[str | int, Any]]) -> None: + def store_group_data(self, shade_data: PowerviewData) -> None: """Store data from the all shades endpoint. This does not update the shades or positions @@ -81,37 +51,34 @@ class PowerviewShadeData: with a shade_id will update a specific shade from the group data. """ - self._group_data_by_id = async_map_data_by_id(shade_data) + self._shade_data_by_id = shade_data.processed + self._group_data_by_id = async_map_data_by_id(shade_data.raw) - def update_shade_position(self, shade_id: int, position: int, kind: int) -> None: - """Update a single shade position.""" - positions = self.get_shade_positions(shade_id) - if kind == POS_KIND_PRIMARY: - positions.primary = position - elif kind == POS_KIND_SECONDARY: - positions.secondary = position - elif kind == POS_KIND_VANE: - positions.vane = position + def update_shade_position(self, shade_id: int, shade_data: ShadePosition) -> None: + """Update a single shades position.""" + if shade_id not in self.positions: + self.positions[shade_id] = ShadePosition() - def update_from_position_data( - self, shade_id: int, position_data: dict[str, Any] - ) -> None: - """Update the shade positions from the position data.""" - for position_key, kind_key in POSITIONS: - if position_key in position_data: - self.update_shade_position( - shade_id, position_data[position_key], position_data[kind_key] - ) + # ShadePosition will return None if the value is not set + if shade_data.primary is not None: + self.positions[shade_id].primary = shade_data.primary + if shade_data.secondary is not None: + self.positions[shade_id].secondary = shade_data.secondary + if shade_data.tilt is not None: + self.positions[shade_id].tilt = shade_data.tilt - def update_shade_positions(self, data: dict[int | str, Any]) -> None: + def update_shade_velocity(self, shade_id: int, shade_data: ShadePosition) -> None: + """Update a single shades velocity.""" + if shade_id not in self.positions: + self.positions[shade_id] = ShadePosition() + + # the hub will always return a velocity of 0 on initial connect, + # separate definition to store consistent value in HA + # this value is purely driven from HA + if shade_data.velocity is not None: + self.positions[shade_id].velocity = shade_data.velocity + + def update_shade_positions(self, data: BaseShade) -> None: """Update a shades from data dict.""" - _LOGGER.debug("Raw data update: %s", data) - shade_id = data[ATTR_ID] - position_data = data[ATTR_POSITION_DATA] - self.update_from_position_data(shade_id, position_data) - - def update_from_response(self, response: dict[str, Any]) -> None: - """Update from the response to a command.""" - if response and ATTR_SHADE in response: - shade_data: dict[int | str, Any] = response[ATTR_SHADE] - self.update_shade_positions(shade_data) + _LOGGER.debug("Raw data update: %s", data.raw_data) + self.update_shade_position(data.id, data.current_position) diff --git a/homeassistant/components/hunterdouglas_powerview/strings.json b/homeassistant/components/hunterdouglas_powerview/strings.json index 7c17788be83..a107e2c5be4 100644 --- a/homeassistant/components/hunterdouglas_powerview/strings.json +++ b/homeassistant/components/hunterdouglas_powerview/strings.json @@ -4,7 +4,11 @@ "user": { "title": "Connect to the PowerView Hub", "data": { - "host": "[%key:common::config_flow::data::ip%]" + "host": "[%key:common::config_flow::data::ip%]", + "api_version": "Hub Generation" + }, + "data_description": { + "api_version": "API version is detectable, but you can override and force a specific version" } }, "link": { @@ -15,6 +19,7 @@ "flow_title": "{name} ({host})", "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unsupported_device": "Only the primary powerview hub can be added", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py new file mode 100644 index 00000000000..057c1fcc617 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -0,0 +1,62 @@ +"""The Husqvarna Automower integration.""" + +import logging + +from aioautomower.session import AutomowerSession +from aiohttp import ClientError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow + +from . import api +from .const import DOMAIN +from .coordinator import AutomowerDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +PLATFORMS: list[Platform] = [ + Platform.LAWN_MOWER, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + api_api = api.AsyncConfigEntryAuth( + aiohttp_client.async_get_clientsession(hass), + session, + ) + automower_api = AutomowerSession(api_api) + try: + await api_api.async_get_access_token() + except ClientError as err: + raise ConfigEntryNotReady from err + coordinator = AutomowerDataUpdateCoordinator(hass, automower_api) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.shutdown) + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle unload of an entry.""" + coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + await coordinator.shutdown() + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/husqvarna_automower/api.py b/homeassistant/components/husqvarna_automower/api.py new file mode 100644 index 00000000000..e5dc00ad7cb --- /dev/null +++ b/homeassistant/components/husqvarna_automower/api.py @@ -0,0 +1,29 @@ +"""API for Husqvarna Automower bound to Home Assistant OAuth.""" + +import logging + +from aioautomower.auth import AbstractAuth +from aioautomower.const import API_BASE_URL +from aiohttp import ClientSession + +from homeassistant.helpers import config_entry_oauth2_flow + +_LOGGER = logging.getLogger(__name__) + + +class AsyncConfigEntryAuth(AbstractAuth): + """Provide Husqvarna Automower authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Husqvarna Automower auth.""" + super().__init__(websession, API_BASE_URL) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + await self._oauth_session.async_ensure_token_valid() + return self._oauth_session.token["access_token"] diff --git a/homeassistant/components/husqvarna_automower/application_credentials.py b/homeassistant/components/husqvarna_automower/application_credentials.py new file mode 100644 index 00000000000..f201130ab22 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/application_credentials.py @@ -0,0 +1,14 @@ +"""Application credentials platform for Husqvarna Automower.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/husqvarna_automower/config_flow.py b/homeassistant/components/husqvarna_automower/config_flow.py new file mode 100644 index 00000000000..cafe942a894 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/config_flow.py @@ -0,0 +1,43 @@ +"""Config flow to add the integration via the UI.""" +import logging +from typing import Any + +from aioautomower.utils import async_structure_token + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN, NAME + +_LOGGER = logging.getLogger(__name__) +CONF_USER_ID = "user_id" + + +class HusqvarnaConfigFlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, + domain=DOMAIN, +): + """Handle a config flow.""" + + VERSION = 1 + DOMAIN = DOMAIN + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow.""" + token = data[CONF_TOKEN] + user_id = token[CONF_USER_ID] + structured_token = await async_structure_token(token[CONF_ACCESS_TOKEN]) + first_name = structured_token.user.first_name + last_name = structured_token.user.last_name + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{NAME} of {first_name} {last_name}", + data=data, + ) + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) diff --git a/homeassistant/components/husqvarna_automower/const.py b/homeassistant/components/husqvarna_automower/const.py new file mode 100644 index 00000000000..ab30bae45f2 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/const.py @@ -0,0 +1,7 @@ +"""The constants for the Husqvarna Automower integration.""" + +DOMAIN = "husqvarna_automower" +NAME = "Husqvarna Automower" +HUSQVARNA_URL = "https://developer.husqvarnagroup.cloud/login" +OAUTH2_AUTHORIZE = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/authorize" +OAUTH2_TOKEN = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/token" diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py new file mode 100644 index 00000000000..70d69f90549 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -0,0 +1,47 @@ +"""Data UpdateCoordinator for the Husqvarna Automower integration.""" +from datetime import timedelta +import logging +from typing import Any + +from aioautomower.model import MowerAttributes + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .api import AsyncConfigEntryAuth +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]): + """Class to manage fetching Husqvarna data.""" + + def __init__(self, hass: HomeAssistant, api: AsyncConfigEntryAuth) -> None: + """Initialize data updater.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=5), + ) + self.api = api + + self.ws_connected: bool = False + + async def _async_update_data(self) -> dict[str, MowerAttributes]: + """Subscribe for websocket and poll data from the API.""" + if not self.ws_connected: + await self.api.connect() + self.api.register_data_callback(self.callback) + self.ws_connected = True + return await self.api.get_status() + + async def shutdown(self, *_: Any) -> None: + """Close resources.""" + await self.api.close() + + @callback + def callback(self, ws_data: dict[str, MowerAttributes]) -> None: + """Process websocket callbacks and write them to the DataUpdateCoordinator.""" + self.async_set_updated_data(ws_data) diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py new file mode 100644 index 00000000000..25951aad1e3 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -0,0 +1,40 @@ +"""Platform for Husqvarna Automower base entity.""" + +import logging + +from aioautomower.model import MowerAttributes + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import AutomowerDataUpdateCoordinator +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]): + """Defining the Automower base Entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + ) -> None: + """Initialize AutomowerEntity.""" + super().__init__(coordinator) + self.mower_id = mower_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, mower_id)}, + name=self.mower_attributes.system.name, + manufacturer="Husqvarna", + model=self.mower_attributes.system.model, + suggested_area="Garden", + ) + + @property + def mower_attributes(self) -> MowerAttributes: + """Get the mower attributes of the current mower.""" + return self.coordinator.data[self.mower_id] diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py new file mode 100644 index 00000000000..b14f9e5d72c --- /dev/null +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -0,0 +1,126 @@ +"""Husqvarna Automower lawn mower entity.""" +import logging + +from aioautomower.exceptions import ApiException +from aioautomower.model import MowerActivities, MowerStates + +from homeassistant.components.lawn_mower import ( + LawnMowerActivity, + LawnMowerEntity, + LawnMowerEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AutomowerDataUpdateCoordinator +from .entity import AutomowerBaseEntity + +SUPPORT_STATE_SERVICES = ( + LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.START_MOWING +) + +DOCKED_ACTIVITIES = (MowerActivities.PARKED_IN_CS, MowerActivities.CHARGING) +ERROR_ACTIVITIES = ( + MowerActivities.STOPPED_IN_GARDEN, + MowerActivities.UNKNOWN, + MowerActivities.NOT_APPLICABLE, +) +ERROR_STATES = [ + MowerStates.FATAL_ERROR, + MowerStates.ERROR, + MowerStates.ERROR_AT_POWER_UP, + MowerStates.NOT_APPLICABLE, + MowerStates.UNKNOWN, + MowerStates.STOPPED, + MowerStates.OFF, +] +MOWING_ACTIVITIES = ( + MowerActivities.MOWING, + MowerActivities.LEAVING, + MowerActivities.GOING_HOME, +) +PAUSED_STATES = [ + MowerStates.PAUSED, + MowerStates.WAIT_UPDATING, + MowerStates.WAIT_POWER_UP, +] + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up lawn mower platform.""" + coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + AutomowerLawnMowerEntity(mower_id, coordinator) for mower_id in coordinator.data + ) + + +class AutomowerLawnMowerEntity(AutomowerBaseEntity, LawnMowerEntity): + """Defining each mower Entity.""" + + _attr_name = None + _attr_supported_features = SUPPORT_STATE_SERVICES + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + ) -> None: + """Set up HusqvarnaAutomowerEntity.""" + super().__init__(mower_id, coordinator) + self._attr_unique_id = mower_id + + @property + def available(self) -> bool: + """Return True if the device is available.""" + return super().available and self.mower_attributes.metadata.connected + + @property + def activity(self) -> LawnMowerActivity: + """Return the state of the mower.""" + mower_attributes = self.mower_attributes + if mower_attributes.mower.state in PAUSED_STATES: + return LawnMowerActivity.PAUSED + if mower_attributes.mower.activity in MOWING_ACTIVITIES: + return LawnMowerActivity.MOWING + if (mower_attributes.mower.state == "RESTRICTED") or ( + mower_attributes.mower.activity in DOCKED_ACTIVITIES + ): + return LawnMowerActivity.DOCKED + return LawnMowerActivity.ERROR + + async def async_start_mowing(self) -> None: + """Resume schedule.""" + try: + await self.coordinator.api.resume_schedule(self.mower_id) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception + + async def async_pause(self) -> None: + """Pauses the mower.""" + try: + await self.coordinator.api.pause_mowing(self.mower_id) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception + + async def async_dock(self) -> None: + """Parks the mower until next schedule.""" + try: + await self.coordinator.api.park_until_next_schedule(self.mower_id) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json new file mode 100644 index 00000000000..48458e0d6a5 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "husqvarna_automower", + "name": "Husqvarna Automower", + "codeowners": ["@Thomas55555"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower", + "iot_class": "cloud_push", + "requirements": ["aioautomower==2024.2.6"] +} diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json new file mode 100644 index 00000000000..6a5b28153b4 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index dcf025082cc..2bc3c626deb 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -41,7 +41,11 @@ class HuumDevice(ClimateEntity): """Representation of a heater.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_max_temp = 110 @@ -51,6 +55,7 @@ class HuumDevice(ClimateEntity): _target_temperature: int | None = None _status: HuumStatusResponse | None = None + _enable_turn_on_off_backwards_compatibility = False def __init__(self, huum_handler: Huum, unique_id: str) -> None: """Initialize the heater.""" diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index b30a9b375b0..3e55cb741a4 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -42,7 +42,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor platform.""" hub = hass.data[DOMAIN][config_entry.entry_id] @@ -50,7 +50,7 @@ async def async_setup_entry( session = aiohttp_client.async_get_clientsession(hass) sensor = HVVDepartureSensor(hass, config_entry, session, hub) - async_add_devices([sensor], True) + async_add_entities([sensor], True) class HVVDepartureSensor(SensorEntity): diff --git a/homeassistant/components/ialarm/__init__.py b/homeassistant/components/ialarm/__init__.py index 1b821025953..ff54c02a2d4 100644 --- a/homeassistant/components/ialarm/__init__.py +++ b/homeassistant/components/ialarm/__init__.py @@ -28,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: async with asyncio.timeout(10): mac = await hass.async_add_executor_job(ialarm.get_mac) - except (asyncio.TimeoutError, ConnectionError) as ex: + except (TimeoutError, ConnectionError) as ex: raise ConfigEntryNotReady from ex coordinator = IAlarmDataUpdateCoordinator(hass, ialarm, mac) diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py index df3a873b6c1..3537737f122 100644 --- a/homeassistant/components/iammeter/sensor.py +++ b/homeassistant/components/iammeter/sensor.py @@ -1,7 +1,6 @@ """Support for iammeter via local API.""" from __future__ import annotations -import asyncio from asyncio import timeout from collections.abc import Callable from dataclasses import dataclass @@ -117,7 +116,7 @@ async def async_setup_platform( api = await hass.async_add_executor_job( IamMeter, config_host, config_port, config_name ) - except asyncio.TimeoutError as err: + except TimeoutError as err: _LOGGER.error("Device is not ready") raise PlatformNotReady from err @@ -125,7 +124,7 @@ async def async_setup_platform( try: async with timeout(PLATFORM_TIMEOUT): return await hass.async_add_executor_job(api.client.get_data) - except asyncio.TimeoutError as err: + except TimeoutError as err: raise UpdateFailed from err coordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 062548666c4..49eaa2b24a5 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -1,7 +1,6 @@ """Component to embed Aqualink devices.""" from __future__ import annotations -import asyncio from collections.abc import Awaitable, Callable, Coroutine from datetime import datetime from functools import wraps @@ -79,7 +78,7 @@ async def async_setup_entry( # noqa: C901 _LOGGER.error("Failed to login: %s", login_exception) await aqualink.close() return False - except (asyncio.TimeoutError, httpx.HTTPError) as aio_exception: + except (TimeoutError, httpx.HTTPError) as aio_exception: await aqualink.close() raise ConfigEntryNotReady( f"Error while attempting login: {aio_exception}" diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index b7dbe43fca9..5a81ad3d681 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -42,7 +42,12 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): """Representation of a thermostat.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + _enable_turn_on_off_backwards_compatibility = False def __init__(self, dev: AqualinkThermostat) -> None: """Initialize AquaLink thermostat.""" diff --git a/homeassistant/components/ihc/ihcdevice.py b/homeassistant/components/ihc/ihcdevice.py index 0c077f8698e..30c84da40f8 100644 --- a/homeassistant/components/ihc/ihcdevice.py +++ b/homeassistant/components/ihc/ihcdevice.py @@ -39,7 +39,7 @@ class IHCDevice(Entity): self.ihc_name = product["name"] self.ihc_note = product["note"] self.ihc_position = product["position"] - self.suggested_area = product["group"] if "group" in product else None + self.suggested_area = product.get("group") if "id" in product: product_id = product["id"] self.device_id = f"{controller_id}_{product_id }" diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 4c5a9df8810..17ceac9c8db 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -75,7 +75,7 @@ def valid_image_content_type(content_type: str | None) -> str: async def _async_get_image(image_entity: ImageEntity, timeout: int) -> Image: """Fetch image from an image entity.""" - with suppress(asyncio.CancelledError, asyncio.TimeoutError, ImageContentTypeError): + with suppress(asyncio.CancelledError, TimeoutError, ImageContentTypeError): async with asyncio.timeout(timeout): if image_bytes := await image_entity.async_image(): content_type = valid_image_content_type(image_entity.content_type) diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index fea2583a27a..924408c30b9 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -1,8 +1,6 @@ """The imap integration.""" from __future__ import annotations -import asyncio - from aioimaplib import IMAP4_SSL, AioImapException from homeassistant.config_entries import ConfigEntry @@ -33,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryAuthFailed from err except InvalidFolder as err: raise ConfigEntryError("Selected mailbox folder is invalid.") from err - except (asyncio.TimeoutError, AioImapException) as err: + except (TimeoutError, AioImapException) as err: raise ConfigEntryNotReady from err coordinator_class: type[ diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index dea7a0e2e71..15b52ce6333 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -1,7 +1,6 @@ """Config flow for imap integration.""" from __future__ import annotations -import asyncio from collections.abc import Mapping import ssl from typing import Any @@ -108,7 +107,7 @@ async def validate_input( # See https://github.com/bamthomas/aioimaplib/issues/91 # This handler is added to be able to supply a better error message errors["base"] = "ssl_error" - except (asyncio.TimeoutError, AioImapException, ConnectionRefusedError): + except (TimeoutError, AioImapException, ConnectionRefusedError): errors["base"] = "cannot_connect" else: if result != "OK": diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 5591980b2f1..f0c9099863a 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -9,7 +9,7 @@ from email.header import decode_header, make_header from email.message import Message from email.utils import parseaddr, parsedate_to_datetime import logging -from typing import Any +from typing import TYPE_CHECKING, Any from aioimaplib import AUTH, IMAP4_SSL, NONAUTH, SELECTED, AioImapException @@ -30,6 +30,7 @@ from homeassistant.exceptions import ( from homeassistant.helpers.json import json_bytes from homeassistant.helpers.template import Template from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from homeassistant.util.ssl import ( SSLCipherList, client_context, @@ -57,6 +58,8 @@ EVENT_IMAP = "imap_content" MAX_ERRORS = 3 MAX_EVENT_DATA_BYTES = 32168 +DIAGNOSTICS_ATTRIBUTES = ["date", "initial"] + async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" @@ -97,11 +100,27 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: class ImapMessage: """Class to parse an RFC822 email message.""" - def __init__(self, raw_message: bytes, charset: str = "utf-8") -> None: + def __init__(self, raw_message: bytes) -> None: """Initialize IMAP message.""" - self._charset = charset self.email_message = email.message_from_bytes(raw_message) + @staticmethod + def _decode_payload(part: Message) -> str: + """Try to decode text payloads. + + Common text encodings are quoted-printable or base64. + Falls back to the raw content part if decoding fails. + """ + try: + decoded_payload: Any = part.get_payload(decode=True) + if TYPE_CHECKING: + assert isinstance(decoded_payload, bytes) + content_charset = part.get_content_charset() or "utf-8" + return decoded_payload.decode(content_charset) + except ValueError: + # return undecoded payload + return str(part.get_payload()) + @property def headers(self) -> dict[str, tuple[str,]]: """Get the email headers.""" @@ -153,31 +172,20 @@ class ImapMessage: def text(self) -> str: """Get the message text from the email. - Will look for text/plain or use text/html if not found. + Will look for text/plain or use/ text/html if not found. """ message_text: str | None = None message_html: str | None = None message_untyped_text: str | None = None - def _decode_payload(part: Message) -> str: - """Try to decode text payloads. - - Common text encodings are quoted-printable or base64. - Falls back to the raw content part if decoding fails. - """ - try: - return str(part.get_payload(decode=True).decode(self._charset)) - except ValueError: - return str(part.get_payload()) - part: Message for part in self.email_message.walk(): if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: if message_text is None: - message_text = _decode_payload(part) + message_text = self._decode_payload(part) elif part.get_content_type() == "text/html": if message_html is None: - message_html = _decode_payload(part) + message_html = self._decode_payload(part) elif ( part.get_content_type().startswith("text") and message_untyped_text is None @@ -215,6 +223,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): self._last_message_uid: str | None = None self._last_message_id: str | None = None self.custom_event_template = None + self._diagnostics_data: dict[str, Any] = {} _custom_event_template = entry.data.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE) if _custom_event_template is not None: self.custom_event_template = Template(_custom_event_template, hass=hass) @@ -237,9 +246,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): """Send a event for the last message if the last message was changed.""" response = await self.imap_client.fetch(last_message_uid, "BODY.PEEK[]") if response.result == "OK": - message = ImapMessage( - response.lines[1], charset=self.config_entry.data[CONF_CHARSET] - ) + message = ImapMessage(response.lines[1]) # Set `initial` to `False` if the last message is triggered again initial: bool = True if (message_id := message.message_id) == self._last_message_id: @@ -284,6 +291,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): CONF_MAX_MESSAGE_SIZE, DEFAULT_MAX_MESSAGE_SIZE ) ] + self._update_diagnostics(data) if (size := len(json_bytes(data))) > MAX_EVENT_DATA_BYTES: _LOGGER.warning( "Custom imap_content event skipped, size (%s) exceeds " @@ -344,7 +352,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): await self.imap_client.stop_wait_server_push() await self.imap_client.close() await self.imap_client.logout() - except (AioImapException, asyncio.TimeoutError): + except (AioImapException, TimeoutError): if log_error: _LOGGER.debug("Error while cleaning up imap connection") finally: @@ -354,6 +362,23 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): """Close resources.""" await self._cleanup(log_error=True) + def _update_diagnostics(self, data: dict[str, Any]) -> None: + """Update the diagnostics.""" + self._diagnostics_data.update( + {key: value for key, value in data.items() if key in DIAGNOSTICS_ATTRIBUTES} + ) + custom: Any | None = data.get("custom") + self._diagnostics_data["custom_template_data_type"] = str(type(custom)) + self._diagnostics_data["custom_template_result_length"] = ( + None if custom is None else len(f"{custom}") + ) + self._diagnostics_data["event_time"] = dt_util.now().isoformat() + + @property + def diagnostics_data(self) -> dict[str, Any]: + """Return diagnostics info.""" + return self._diagnostics_data + class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator): """Class for imap client.""" @@ -376,7 +401,7 @@ class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator): except ( AioImapException, UpdateFailed, - asyncio.TimeoutError, + TimeoutError, ) as ex: await self._cleanup() self.async_set_update_error(ex) @@ -448,7 +473,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): except ( UpdateFailed, AioImapException, - asyncio.TimeoutError, + TimeoutError, ) as ex: await self._cleanup() self.async_set_update_error(ex) @@ -464,8 +489,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): async with asyncio.timeout(10): await idle - # From python 3.11 asyncio.TimeoutError is an alias of TimeoutError - except (AioImapException, asyncio.TimeoutError): + except (AioImapException, TimeoutError): _LOGGER.debug( "Lost %s (will attempt to reconnect after %s s)", self.config_entry.data[CONF_SERVER], diff --git a/homeassistant/components/imap/diagnostics.py b/homeassistant/components/imap/diagnostics.py new file mode 100644 index 00000000000..c7d5151ba49 --- /dev/null +++ b/homeassistant/components/imap/diagnostics.py @@ -0,0 +1,38 @@ +"""Diagnostics support for IMAP.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .coordinator import ImapDataUpdateCoordinator + +REDACT_CONFIG = {CONF_PASSWORD, CONF_USERNAME} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return _async_get_diagnostics(hass, entry) + + +@callback +def _async_get_diagnostics( + hass: HomeAssistant, + entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + redacted_config = async_redact_data(entry.data, REDACT_CONFIG) + coordinator: ImapDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + data = { + "config": redacted_config, + "event": coordinator.diagnostics_data, + } + + return data diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index cae73495438..0dba00ff416 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -42,6 +42,7 @@ class InComfortClimate(IncomfortChild, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, client, heater, room) -> None: """Initialize the climate device.""" diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index f906270b2f5..367af73810b 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -1,7 +1,6 @@ """Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -101,7 +100,7 @@ class IncomfortWaterHeater(IncomfortEntity, WaterHeaterEntity): try: await self._heater.update() - except (ClientResponseError, asyncio.TimeoutError) as err: + except (ClientResponseError, TimeoutError) as err: _LOGGER.warning("Update failed, message is: %s", err) else: diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index 74fb11491c0..22bd776e1c8 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -87,10 +87,13 @@ class InsteonClimateEntity(InsteonEntity, ClimateEntity): | ClimateEntityFeature.TARGET_HUMIDITY | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_hvac_modes = list(HVAC_MODES.values()) _attr_fan_modes = list(FAN_MODES.values()) _attr_min_humidity = 1 + _enable_turn_on_off_backwards_compatibility = False @property def temperature_unit(self) -> str: diff --git a/homeassistant/components/intellifire/climate.py b/homeassistant/components/intellifire/climate.py index 5d305db8feb..9fed9c08bb6 100644 --- a/homeassistant/components/intellifire/climate.py +++ b/homeassistant/components/intellifire/climate.py @@ -49,10 +49,15 @@ class IntellifireClimate(IntellifireEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_min_temp = 0 _attr_max_temp = 37 - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = 1.0 _attr_temperature_unit = UnitOfTemperature.CELSIUS last_temp = DEFAULT_THERMOSTAT_TEMP + _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 5756b78b4de..e960b5616cb 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -1,4 +1,5 @@ """The Intent integration.""" + from __future__ import annotations import logging @@ -155,16 +156,18 @@ class GetStateIntentHandler(intent.IntentHandler): slots = self.async_validate_slots(intent_obj.slots) # Entity name to match - name: str | None = slots.get("name", {}).get("value") + name_slot = slots.get("name", {}) + entity_name: str | None = name_slot.get("value") + entity_text: str | None = name_slot.get("text") # Look up area first to fail early - area_name = slots.get("area", {}).get("value") + area_slot = slots.get("area", {}) + area_id = area_slot.get("value") + area_name = area_slot.get("text") area: ar.AreaEntry | None = None - if area_name is not None: + if area_id is not None: areas = ar.async_get(hass) - area = areas.async_get_area(area_name) or areas.async_get_area_by_name( - area_name - ) + area = areas.async_get_area(area_id) if area is None: raise intent.IntentHandleError(f"No area named {area_name}") @@ -186,7 +189,7 @@ class GetStateIntentHandler(intent.IntentHandler): states = list( intent.async_match_states( hass, - name=name, + name=entity_name, area=area, domains=domains, device_classes=device_classes, @@ -197,13 +200,20 @@ class GetStateIntentHandler(intent.IntentHandler): _LOGGER.debug( "Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s, assistant=%s", len(states), - name, + entity_name, area, domains, device_classes, intent_obj.assistant, ) + if entity_name and (len(states) > 1): + # Multiple entities matched for the same name + raise intent.DuplicateNamesMatchedError( + name=entity_text or entity_name, + area=area_name or area_id, + ) + # Create response response = intent_obj.create_response() response.response_type = intent.IntentResponseType.QUERY_ANSWER diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index 285be2c9cea..64f52fae0a6 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -146,6 +146,7 @@ class IntesisAC(ClimateEntity): _attr_should_poll = False _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, ih_device_id, ih_device, controller): """Initialize the thermostat.""" @@ -175,10 +176,6 @@ class IntesisAC(ClimateEntity): self._power_consumption_heat = None self._power_consumption_cool = None - self._attr_supported_features |= ( - ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON - ) - # Setpoint support if controller.has_setpoint_control(ih_device_id): self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE @@ -208,6 +205,11 @@ class IntesisAC(ClimateEntity): self._attr_hvac_modes.extend(mode_list) self._attr_hvac_modes.append(HVACMode.OFF) + if len(self.hvac_modes) > 1: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + async def async_added_to_hass(self) -> None: """Subscribe to event updates.""" _LOGGER.debug("Added climate device with state: %s", repr(self._ih_device)) diff --git a/homeassistant/components/ipma/__init__.py b/homeassistant/components/ipma/__init__.py index 4cb8f921ba4..7668802c9e0 100644 --- a/homeassistant/components/ipma/__init__.py +++ b/homeassistant/components/ipma/__init__.py @@ -33,7 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: async with asyncio.timeout(30): location = await Location.get(api, float(latitude), float(longitude)) - except (IPMAException, asyncio.TimeoutError) as err: + except (IPMAException, TimeoutError) as err: raise ConfigEntryNotReady( f"Could not get location for ({latitude},{longitude})" ) from err diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index f9b93cbe954..866f44f0617 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -217,7 +217,7 @@ class IPMAWeather(WeatherEntity, IPMADevice): period: int, ) -> None: """Try to update weather forecast.""" - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): async with asyncio.timeout(10): await self._update_forecast(forecast_type, period, False) diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index 86ef3ce271f..55e5618d9d4 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -62,10 +62,8 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> CONF_LONGITUDE: lon, } unique_id = f"{lat}-{lon}" - config_entry.version = 1 - config_entry.minor_version = 2 hass.config_entries.async_update_entry( - config_entry, data=new, unique_id=unique_id + config_entry, data=new, unique_id=unique_id, version=1, minor_version=2 ) _LOGGER.debug("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index c611bf83050..0c5ea27a0b9 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -102,7 +102,7 @@ async def async_setup_entry( try: async with asyncio.timeout(60): await isy.initialize() - except asyncio.TimeoutError as err: + except TimeoutError as err: raise ConfigEntryNotReady( "Timed out initializing the ISY; device may be busy, trying again later:" f" {err}" diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index 3ac2fd18473..06b73978456 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -82,9 +82,12 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_target_temperature_step = 1.0 _attr_fan_modes = [FAN_AUTO, FAN_ON] + _enable_turn_on_off_backwards_compatibility = False def __init__(self, node: Node, device_info: DeviceInfo | None = None) -> None: """Initialize the ISY Thermostat entity.""" diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 75eb19b2978..e85b7ef4d56 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -144,12 +144,17 @@ class ControllerDevice(ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_target_temperature_step = 0.5 + _enable_turn_on_off_backwards_compatibility = False def __init__(self, controller: Controller) -> None: """Initialise ControllerDevice.""" self._controller = controller - self._attr_supported_features = ClimateEntityFeature.FAN_MODE + self._attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) # If mode RAS, or mode master with CtrlZone 13 then can set master temperature, # otherwise the unit determines which zone to use as target. See interface manual p. 8 diff --git a/homeassistant/components/izone/config_flow.py b/homeassistant/components/izone/config_flow.py index 8e6fe584456..d56fb93d4e6 100644 --- a/homeassistant/components/izone/config_flow.py +++ b/homeassistant/components/izone/config_flow.py @@ -25,7 +25,7 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: disco = await async_start_discovery_service(hass) - with suppress(asyncio.TimeoutError): + with suppress(TimeoutError): async with asyncio.timeout(TIMEOUT_DISCOVERY): await controller_ready.wait() diff --git a/homeassistant/components/kaiterra/api_data.py b/homeassistant/components/kaiterra/api_data.py index 09d470af1de..6ee73b8ace7 100644 --- a/homeassistant/components/kaiterra/api_data.py +++ b/homeassistant/components/kaiterra/api_data.py @@ -54,7 +54,7 @@ class KaiterraApiData: try: async with asyncio.timeout(10): data = await self._api.get_latest_sensor_readings(self._devices) - except (ClientResponseError, ClientConnectorError, asyncio.TimeoutError) as err: + except (ClientResponseError, ClientConnectorError, TimeoutError) as err: _LOGGER.debug("Couldn't fetch data from Kaiterra API: %s", err) self.data = {} async_dispatcher_send(self._hass, DISPATCHER_KAITERRA) diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index 207c9e353a1..6f33b11742a 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -75,11 +75,10 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> for mac, device in router.last_devices.items() if device.interface in new_tracked_interfaces } - for entity_entry in list(ent_reg.entities.values()): - if ( - entity_entry.config_entry_id == config_entry.entry_id - and entity_entry.domain == Platform.DEVICE_TRACKER - ): + for entity_entry in ent_reg.entities.get_entries_for_config_entry_id( + config_entry.entry_id + ): + if entity_entry.domain == Platform.DEVICE_TRACKER: mac = entity_entry.unique_id.partition("_")[0] if mac not in keep_devices: _LOGGER.debug("Removing entity %s", entity_entry.entity_id) diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py index c51d30431be..c9e81071ad7 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -43,11 +43,10 @@ async def async_setup_entry( registry = er.async_get(hass) # Restore devices that are not a part of active clients list. restored = [] - for entity_entry in registry.entities.values(): - if ( - entity_entry.config_entry_id == config_entry.entry_id - and entity_entry.domain == DEVICE_TRACKER_DOMAIN - ): + for entity_entry in registry.entities.get_entries_for_config_entry_id( + config_entry.entry_id + ): + if entity_entry.domain == DEVICE_TRACKER_DOMAIN: mac = entity_entry.unique_id.partition("_")[0] if mac not in tracked: tracked.add(mac) diff --git a/homeassistant/components/keymitt_ble/config_flow.py b/homeassistant/components/keymitt_ble/config_flow.py index e8176b152a6..5665dc27d17 100644 --- a/homeassistant/components/keymitt_ble/config_flow.py +++ b/homeassistant/components/keymitt_ble/config_flow.py @@ -138,6 +138,8 @@ class MicroBotConfigFlow(ConfigFlow, domain=DOMAIN): await self._client.connect(init=True) return self.async_show_form(step_id="link") + if not await self._client.is_connected(): + await self._client.connect(init=False) if not await self._client.is_connected(): errors["base"] = "linking" else: diff --git a/homeassistant/components/keymitt_ble/manifest.json b/homeassistant/components/keymitt_ble/manifest.json index 760cc67cbd5..5f92db05420 100644 --- a/homeassistant/components/keymitt_ble/manifest.json +++ b/homeassistant/components/keymitt_ble/manifest.json @@ -13,7 +13,8 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/keymitt_ble", + "integration_type": "hub", "iot_class": "assumed_state", "loggers": ["keymitt_ble"], - "requirements": ["PyMicroBot==0.0.9"] + "requirements": ["PyMicroBot==0.0.15"] } diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 8369892be85..228803097d6 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -27,10 +27,12 @@ DOMAIN = "kitchen_sink" COMPONENTS_WITH_DEMO_PLATFORM = [ + Platform.BUTTON, Platform.IMAGE, Platform.LAWN_MOWER, Platform.LOCK, Platform.SENSOR, + Platform.SWITCH, Platform.WEATHER, ] diff --git a/homeassistant/components/kitchen_sink/button.py b/homeassistant/components/kitchen_sink/button.py new file mode 100644 index 00000000000..1a8da80983f --- /dev/null +++ b/homeassistant/components/kitchen_sink/button.py @@ -0,0 +1,56 @@ +"""Demo platform that offers a fake button entity.""" +from __future__ import annotations + +from homeassistant.components import persistent_notification +from homeassistant.components.button import ButtonEntity +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 . import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the demo button platform.""" + async_add_entities( + [ + DemoButton( + unique_id="2_ch_power_strip", + device_name="2CH Power strip", + entity_name="Restart", + ), + ] + ) + + +class DemoButton(ButtonEntity): + """Representation of a demo button entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + device_name: str, + entity_name: str | None, + ) -> None: + """Initialize the Demo button entity.""" + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=device_name, + ) + self._attr_name = entity_name + + async def async_press(self) -> None: + """Send out a persistent notification.""" + persistent_notification.async_create( + self.hass, "Button pressed", title="Button" + ) + self.hass.bus.async_fire("demo_button_pressed") diff --git a/homeassistant/components/kitchen_sink/device.py b/homeassistant/components/kitchen_sink/device.py new file mode 100644 index 00000000000..295e7869ec4 --- /dev/null +++ b/homeassistant/components/kitchen_sink/device.py @@ -0,0 +1,23 @@ +"""Create device without entities.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import DOMAIN + + +def async_create_device( + hass: HomeAssistant, + config_entry_id: str, + device_name: str | None, + unique_id: str, +) -> dr.DeviceEntry: + """Create a device.""" + device_registry = dr.async_get(hass) + return device_registry.async_get_or_create( + config_entry_id=config_entry_id, + name=device_name, + identifiers={(DOMAIN, unique_id)}, + ) diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py index 4e1e3bd2010..a14c4a26e4e 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -11,9 +11,10 @@ from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType +from homeassistant.helpers.typing import UNDEFINED, StateType, UndefinedType from . import DOMAIN +from .device import async_create_device async def async_setup_entry( @@ -22,31 +23,63 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Everything but the Kitchen Sink config entry.""" + async_create_device( + hass, config_entry.entry_id, "2CH Power strip", "2_ch_power_strip" + ) + async_add_entities( [ DemoSensor( - "statistics_issue_1", - "Statistics issue 1", - 100, - None, - SensorStateClass.MEASUREMENT, - UnitOfPower.WATT, # Not a volume unit + device_unique_id="outlet_1", + unique_id="outlet_1_power", + device_name="Outlet 1", + entity_name=UNDEFINED, + state=50, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + unit_of_measurement=UnitOfPower.WATT, + via_device="2_ch_power_strip", ), DemoSensor( - "statistics_issue_2", - "Statistics issue 2", - 100, - None, - SensorStateClass.MEASUREMENT, - "dogs", # Can't be converted to cats + device_unique_id="outlet_2", + unique_id="outlet_2_power", + device_name="Outlet 2", + entity_name=UNDEFINED, + state=1500, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + unit_of_measurement=UnitOfPower.WATT, + via_device="2_ch_power_strip", ), DemoSensor( - "statistics_issue_3", - "Statistics issue 3", - 100, - None, - None, # Wrong state class - UnitOfPower.WATT, + device_unique_id="statistics_issues", + unique_id="statistics_issue_1", + device_name="Statistics issues", + entity_name="Issue 1", + state=100, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + unit_of_measurement=UnitOfPower.WATT, + ), + DemoSensor( + device_unique_id="statistics_issues", + unique_id="statistics_issue_2", + device_name="Statistics issues", + entity_name="Issue 2", + state=100, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + unit_of_measurement="dogs", + ), + DemoSensor( + device_unique_id="statistics_issues", + unique_id="statistics_issue_3", + device_name="Statistics issues", + entity_name="Issue 3", + state=100, + device_class=None, + state_class=None, + unit_of_measurement=UnitOfPower.WATT, ), ] ) @@ -55,26 +88,34 @@ async def async_setup_entry( class DemoSensor(SensorEntity): """Representation of a Demo sensor.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, + *, + device_unique_id: str, unique_id: str, - name: str, + device_name: str, + entity_name: str | None | UndefinedType, state: StateType, device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, unit_of_measurement: str | None, + via_device: str | None = None, ) -> None: """Initialize the sensor.""" self._attr_device_class = device_class - self._attr_name = name + if entity_name is not UNDEFINED: + self._attr_name = entity_name self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_value = state self._attr_state_class = state_class self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - name=name, + identifiers={(DOMAIN, device_unique_id)}, + name=device_name, ) + if via_device: + self._attr_device_info["via_device"] = (DOMAIN, via_device) diff --git a/homeassistant/components/kitchen_sink/switch.py b/homeassistant/components/kitchen_sink/switch.py new file mode 100644 index 00000000000..4329be8b9d7 --- /dev/null +++ b/homeassistant/components/kitchen_sink/switch.py @@ -0,0 +1,88 @@ +"""Demo platform that has some fake switches.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +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 . import DOMAIN +from .device import async_create_device + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the demo switch platform.""" + async_create_device( + hass, config_entry.entry_id, "2CH Power strip", "2_ch_power_strip" + ) + + async_add_entities( + [ + DemoSwitch( + unique_id="outlet_1", + device_name="Outlet 1", + entity_name=None, + state=False, + assumed=False, + via_device="2_ch_power_strip", + ), + DemoSwitch( + unique_id="outlet_2", + device_name="Outlet 2", + entity_name=None, + state=True, + assumed=False, + via_device="2_ch_power_strip", + ), + ] + ) + + +class DemoSwitch(SwitchEntity): + """Representation of a demo switch.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + *, + unique_id: str, + device_name: str, + entity_name: str | None, + state: bool, + assumed: bool, + translation_key: str | None = None, + device_class: SwitchDeviceClass | None = None, + via_device: str | None = None, + ) -> None: + """Initialize the Demo switch.""" + self._attr_assumed_state = assumed + self._attr_device_class = device_class + self._attr_translation_key = translation_key + self._attr_is_on = state + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=device_name, + ) + if via_device: + self._attr_device_info["via_device"] = (DOMAIN, via_device) + self._attr_name = entity_name + + def turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + self._attr_is_on = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + self._attr_is_on = False + self.schedule_update_ha_state() diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 4159a7a56a5..6a304f7de5f 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,8 +11,8 @@ "loggers": ["xknx", "xknxproject"], "quality_scale": "platinum", "requirements": [ - "xknx==2.11.2", - "xknxproject==3.4.0", + "xknx==2.12.0", + "xknxproject==3.6.0", "knx-frontend==2024.1.20.105944" ] } diff --git a/homeassistant/components/kostal_plenticore/config_flow.py b/homeassistant/components/kostal_plenticore/config_flow.py index ba8e762763d..8dd3a823570 100644 --- a/homeassistant/components/kostal_plenticore/config_flow.py +++ b/homeassistant/components/kostal_plenticore/config_flow.py @@ -1,5 +1,4 @@ """Config flow for Kostal Plenticore Solar Inverter integration.""" -import asyncio import logging from aiohttp.client_exceptions import ClientError @@ -57,7 +56,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except AuthenticationException as ex: errors[CONF_PASSWORD] = "invalid_auth" _LOGGER.error("Error response: %s", ex) - except (ClientError, asyncio.TimeoutError): + except (ClientError, TimeoutError): errors[CONF_HOST] = "cannot_connect" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index c3228e1d449..a04415a4f31 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -1,7 +1,6 @@ """Code to handle the Plenticore API.""" from __future__ import annotations -import asyncio from collections import defaultdict from collections.abc import Callable, Mapping from datetime import datetime, timedelta @@ -66,7 +65,7 @@ class Plenticore: "Authentication exception connecting to %s: %s", self.host, err ) return False - except (ClientError, asyncio.TimeoutError) as err: + except (ClientError, TimeoutError) as err: _LOGGER.error("Error connecting to %s", self.host) raise ConfigEntryNotReady from err else: diff --git a/homeassistant/components/lamarzocco/icons.json b/homeassistant/components/lamarzocco/icons.json index 70adfe95134..727d3c66009 100644 --- a/homeassistant/components/lamarzocco/icons.json +++ b/homeassistant/components/lamarzocco/icons.json @@ -25,9 +25,21 @@ "coffee_temp": { "default": "mdi:thermometer-water" }, + "dose": { + "default": "mdi:weight-kilogram" + }, "steam_temp": { "default": "mdi:thermometer-water" }, + "prebrew_off": { + "default": "mdi:water-off" + }, + "prebrew_on": { + "default": "mdi:water" + }, + "preinfusion_off": { + "default": "mdi:water" + }, "tea_water_duration": { "default": "mdi:timer-sand" } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 76632d4a5b8..05f937f48f6 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Any from lmcloud import LMCloud as LaMarzoccoClient -from lmcloud.const import LaMarzoccoModel +from lmcloud.const import KEYS_PER_MODEL, LaMarzoccoModel from homeassistant.components.number import ( NumberDeviceClass, @@ -16,6 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PRECISION_TENTHS, PRECISION_WHOLE, + EntityCategory, UnitOfTemperature, UnitOfTime, ) @@ -40,6 +41,19 @@ class LaMarzoccoNumberEntityDescription( ] +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoKeyNumberEntityDescription( + LaMarzoccoEntityDescription, + NumberEntityDescription, +): + """Description of an La Marzocco number entity with keys.""" + + native_value_fn: Callable[[LaMarzoccoClient, int], float | int] + set_value_fn: Callable[ + [LaMarzoccoClient, float | int, int], Coroutine[Any, Any, bool] + ] + + ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( LaMarzoccoNumberEntityDescription( key="coffee_temp", @@ -79,7 +93,7 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( set_value_fn=lambda coordinator, value: coordinator.lm.set_dose_hot_water( value=int(value) ), - native_value_fn=lambda lm: lm.current_status["dose_k5"], + native_value_fn=lambda lm: lm.current_status["dose_hot_water"], supported_fn=lambda coordinator: coordinator.lm.model_name in ( LaMarzoccoModel.GS3_AV, @@ -89,6 +103,103 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( ) +async def _set_prebrew_on( + lm: LaMarzoccoClient, + value: float, + key: int, +) -> bool: + return await lm.configure_prebrew( + on_time=int(value * 1000), + off_time=int(lm.current_status[f"prebrewing_toff_k{key}"] * 1000), + key=key, + ) + + +async def _set_prebrew_off( + lm: LaMarzoccoClient, + value: float, + key: int, +) -> bool: + return await lm.configure_prebrew( + on_time=int(lm.current_status[f"prebrewing_ton_k{key}"] * 1000), + off_time=int(value * 1000), + key=key, + ) + + +async def _set_preinfusion( + lm: LaMarzoccoClient, + value: float, + key: int, +) -> bool: + return await lm.configure_prebrew( + off_time=int(value * 1000), + key=key, + ) + + +KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( + LaMarzoccoKeyNumberEntityDescription( + key="prebrew_off", + translation_key="prebrew_off", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_step=PRECISION_TENTHS, + native_min_value=1, + native_max_value=10, + entity_category=EntityCategory.CONFIG, + set_value_fn=_set_prebrew_off, + native_value_fn=lambda lm, key: lm.current_status[f"prebrewing_ton_k{key}"], + available_fn=lambda lm: lm.current_status["enable_prebrewing"], + supported_fn=lambda coordinator: coordinator.lm.model_name + != LaMarzoccoModel.GS3_MP, + ), + LaMarzoccoKeyNumberEntityDescription( + key="prebrew_on", + translation_key="prebrew_on", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_step=PRECISION_TENTHS, + native_min_value=2, + native_max_value=10, + entity_category=EntityCategory.CONFIG, + set_value_fn=_set_prebrew_on, + native_value_fn=lambda lm, key: lm.current_status[f"prebrewing_toff_k{key}"], + available_fn=lambda lm: lm.current_status["enable_prebrewing"], + supported_fn=lambda coordinator: coordinator.lm.model_name + != LaMarzoccoModel.GS3_MP, + ), + LaMarzoccoKeyNumberEntityDescription( + key="preinfusion_off", + translation_key="preinfusion_off", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_step=PRECISION_TENTHS, + native_min_value=2, + native_max_value=29, + entity_category=EntityCategory.CONFIG, + set_value_fn=_set_preinfusion, + native_value_fn=lambda lm, key: lm.current_status[f"preinfusion_k{key}"], + available_fn=lambda lm: lm.current_status["enable_preinfusion"], + supported_fn=lambda coordinator: coordinator.lm.model_name + != LaMarzoccoModel.GS3_MP, + ), + LaMarzoccoKeyNumberEntityDescription( + key="dose", + translation_key="dose", + native_unit_of_measurement="ticks", + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=999, + entity_category=EntityCategory.CONFIG, + set_value_fn=lambda lm, ticks, key: lm.set_dose(key=key, value=int(ticks)), + native_value_fn=lambda lm, key: lm.current_status[f"dose_k{key}"], + supported_fn=lambda coordinator: coordinator.lm.model_name + == LaMarzoccoModel.GS3_AV, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -103,6 +214,17 @@ async def async_setup_entry( if description.supported_fn(coordinator) ) + entities: list[LaMarzoccoKeyNumberEntity] = [] + for description in KEY_ENTITIES: + if description.supported_fn(coordinator): + num_keys = KEYS_PER_MODEL[coordinator.lm.model_name] + for key in range(min(num_keys, 1), num_keys + 1): + entities.append( + LaMarzoccoKeyNumberEntity(coordinator, description, key) + ) + + async_add_entities(entities) + class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity): """La Marzocco number entity.""" @@ -118,3 +240,42 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity): """Set the value.""" await self.entity_description.set_value_fn(self.coordinator, value) self.async_write_ha_state() + + +class LaMarzoccoKeyNumberEntity(LaMarzoccoEntity, NumberEntity): + """Number representing espresso machine with key support.""" + + entity_description: LaMarzoccoKeyNumberEntityDescription + + def __init__( + self, + coordinator: LaMarzoccoUpdateCoordinator, + description: LaMarzoccoKeyNumberEntityDescription, + pyhsical_key: int, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, description) + + # Physical Key on the machine the entity represents. + if pyhsical_key == 0: + pyhsical_key = 1 + else: + self._attr_translation_key = f"{description.translation_key}_key" + self._attr_translation_placeholders = {"key": str(pyhsical_key)} + self._attr_unique_id = f"{super()._attr_unique_id}_key{pyhsical_key}" + self._attr_entity_registry_enabled_default = False + self.pyhsical_key = pyhsical_key + + @property + def native_value(self) -> float: + """Return the current value.""" + return self.entity_description.native_value_fn( + self.coordinator.lm, self.pyhsical_key + ) + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + await self.entity_description.set_value_fn( + self.coordinator.lm, value, self.pyhsical_key + ) + self.async_write_ha_state() diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index c46b965850c..ea5a5e184e1 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -62,6 +62,7 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( key="current_temp_coffee", translation_key="current_temp_coffee", native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, value_fn=lambda lm: lm.current_status.get("coffee_temp", 0), @@ -70,6 +71,7 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( key="current_temp_steam", translation_key="current_temp_steam", native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, value_fn=lambda lm: lm.current_status.get("steam_temp", 0), diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 7537405c6cd..4a375e0a17b 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -60,6 +60,27 @@ "coffee_temp": { "name": "Coffee target temperature" }, + "dose_key": { + "name": "Dose Key {key}" + }, + "prebrew_on": { + "name": "Prebrew on time" + }, + "prebrew_on_key": { + "name": "Prebrew on time Key {key}" + }, + "prebrew_off": { + "name": "Prebrew off time" + }, + "prebrew_off_key": { + "name": "Prebrew off time Key {key}" + }, + "preinfusion_off": { + "name": "Preinfusion time" + }, + "preinfusion_off_key": { + "name": "Preinfusion time Key {key}" + }, "steam_temp": { "name": "Steam target temperature" }, diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index 28317238bf9..101216cd0d4 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -49,7 +49,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Removing domain name and config entry id from entity unique id's, replacing it with device number if config_entry.version == 1: - config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, version=2) device_number = config_entry.data["device_number"] diff --git a/homeassistant/components/landisgyr_heat_meter/config_flow.py b/homeassistant/components/landisgyr_heat_meter/config_flow.py index 7d03ed2efaf..479e7107025 100644 --- a/homeassistant/components/landisgyr_heat_meter/config_flow.py +++ b/homeassistant/components/landisgyr_heat_meter/config_flow.py @@ -108,7 +108,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # validate and retrieve the model and device number for a unique id data = await self.hass.async_add_executor_job(heat_meter.read) - except (asyncio.TimeoutError, serial.SerialException) as err: + except (TimeoutError, serial.SerialException) as err: _LOGGER.warning("Failed read data from: %s. %s", port, err) raise CannotConnect(f"Error communicating with device: {err}") from err diff --git a/homeassistant/components/laundrify/coordinator.py b/homeassistant/components/laundrify/coordinator.py index 121d2cd913f..d67410c6aa3 100644 --- a/homeassistant/components/laundrify/coordinator.py +++ b/homeassistant/components/laundrify/coordinator.py @@ -34,7 +34,7 @@ class LaundrifyUpdateCoordinator(DataUpdateCoordinator[dict[str, LaundrifyDevice async def _async_update_data(self) -> dict[str, LaundrifyDevice]: """Fetch data from laundrify API.""" try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # Note: TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. async with asyncio.timeout(REQUEST_TIMEOUT): return {m["_id"]: m for m in await self.laundrify_api.get_machines()} diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 4f40bcd25cd..d1e92d54fb1 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -69,7 +69,7 @@ async def async_setup_entry( class LcnClimate(LcnEntity, ClimateEntity): """Representation of a LCN climate device.""" - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _enable_turn_on_off_backwards_compatibility = False def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType @@ -95,6 +95,11 @@ class LcnClimate(LcnEntity, ClimateEntity): self._attr_hvac_modes = [HVACMode.HEAT] if self.is_lockable: self._attr_hvac_modes.append(HVACMode.OFF) + self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + if len(self.hvac_modes) > 1: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 64a789f3a34..8cb0201033e 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -286,7 +286,8 @@ def purge_device_registry( # Find all devices that are referenced in the entity registry. references_entities = { - entry.device_id for entry in entity_registry.entities.values() + entry.device_id + for entry in entity_registry.entities.get_entries_for_config_entry_id(entry_id) } # Find device that references the host. diff --git a/homeassistant/components/led_ble/__init__.py b/homeassistant/components/led_ble/__init__.py index 70b77ba6787..27a273ed7b0 100644 --- a/homeassistant/components/led_ble/__init__.py +++ b/homeassistant/components/led_ble/__init__.py @@ -79,7 +79,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: async with asyncio.timeout(DEVICE_TIMEOUT): await startup_event.wait() - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise ConfigEntryNotReady( "Unable to communicate with the device; " f"Try moving the Bluetooth adapter closer to {led_ble.name}" diff --git a/homeassistant/components/lg_soundbar/config_flow.py b/homeassistant/components/lg_soundbar/config_flow.py index d2cb1749689..fde5c20ebd7 100644 --- a/homeassistant/components/lg_soundbar/config_flow.py +++ b/homeassistant/components/lg_soundbar/config_flow.py @@ -1,7 +1,6 @@ """Config flow to configure the LG Soundbar integration.""" import logging from queue import Empty, Full, Queue -import socket import temescal import voluptuous as vol @@ -60,7 +59,7 @@ def test_connect(host, port): details["uuid"] = uuid_q.get(timeout=QUEUE_TIMEOUT) except Empty: pass - except socket.timeout as err: + except TimeoutError as err: raise ConnectionError(f"Connection timeout with server: {host}:{port}") from err except OSError as err: raise ConnectionError(f"Cannot resolve hostname: {host}") from err diff --git a/homeassistant/components/lg_soundbar/media_player.py b/homeassistant/components/lg_soundbar/media_player.py index 54d9be78df9..cfd0ebbd7a7 100644 --- a/homeassistant/components/lg_soundbar/media_player.py +++ b/homeassistant/components/lg_soundbar/media_player.py @@ -90,7 +90,7 @@ class LGDevice(MediaPlayerEntity): def handle_event(self, response): """Handle responses from the speakers.""" - data = response["data"] if "data" in response else {} + data = response.get("data") or {} if response["msg"] == "EQ_VIEW_INFO": if "i_bass" in data: self._bass = data["i_bass"] diff --git a/homeassistant/components/lifx/config_flow.py b/homeassistant/components/lifx/config_flow.py index 22ac66e3bc9..b6fd67c0356 100644 --- a/homeassistant/components/lifx/config_flow.py +++ b/homeassistant/components/lifx/config_flow.py @@ -1,7 +1,6 @@ """Config flow flow LIFX.""" from __future__ import annotations -import asyncio import socket from typing import Any @@ -242,7 +241,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): DEFAULT_ATTEMPTS, OVERALL_TIMEOUT, ) - except asyncio.TimeoutError: + except TimeoutError: return None finally: connection.async_stop() diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index e668a7ad79a..18a8a24cb94 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -315,7 +315,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): """Get updated color information for all zones.""" try: await async_execute_lifx(self.device.get_extended_color_zones) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise HomeAssistantError( f"Timeout getting color zones from {self.name}" ) from ex diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index e04e8afb3df..74ed209742c 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -281,7 +281,7 @@ class LIFXLight(LIFXEntity, LightEntity): """Send a power change to the bulb.""" try: await self.coordinator.async_set_power(pwr, duration) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise HomeAssistantError(f"Timeout setting power for {self.name}") from ex async def set_color( @@ -294,7 +294,7 @@ class LIFXLight(LIFXEntity, LightEntity): merged_hsbk = merge_hsbk(self.bulb.color, hsbk) try: await self.coordinator.async_set_color(merged_hsbk, duration) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise HomeAssistantError(f"Timeout setting color for {self.name}") from ex async def get_color( @@ -303,7 +303,7 @@ class LIFXLight(LIFXEntity, LightEntity): """Send a get color message to the bulb.""" try: await self.coordinator.async_get_color() - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise HomeAssistantError( f"Timeout setting getting color for {self.name}" ) from ex @@ -429,7 +429,7 @@ class LIFXMultiZone(LIFXColor): await self.coordinator.async_set_color_zones( zone, zone, zone_hsbk, duration, apply ) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise HomeAssistantError( f"Timeout setting color zones for {self.name}" ) from ex @@ -444,7 +444,7 @@ class LIFXMultiZone(LIFXColor): """Send a get color zones message to the device.""" try: await self.coordinator.async_get_color_zones() - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise HomeAssistantError( f"Timeout getting color zones from {self.name}" ) from ex @@ -477,7 +477,7 @@ class LIFXExtendedMultiZone(LIFXMultiZone): await self.coordinator.async_set_extended_color_zones( color_zones, duration=duration ) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise HomeAssistantError( f"Timeout setting color zones on {self.name}" ) from ex diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index feaeba8da8f..5d41839f61d 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -202,7 +202,7 @@ async def async_multi_execute_lifx_with_retries( a response again. If we don't get a result after all attempts, we will raise an - asyncio.TimeoutError exception. + TimeoutError exception. """ loop = asyncio.get_running_loop() futures: list[asyncio.Future] = [loop.create_future() for _ in methods] @@ -236,8 +236,6 @@ async def async_multi_execute_lifx_with_retries( if failed: failed_methods = ", ".join(failed) - raise asyncio.TimeoutError( - f"{failed_methods} timed out after {attempts} attempts" - ) + raise TimeoutError(f"{failed_methods} timed out after {attempts} attempts") return results diff --git a/homeassistant/components/lifx_cloud/scene.py b/homeassistant/components/lifx_cloud/scene.py index bcf8ed1dc2c..61656741f82 100644 --- a/homeassistant/components/lifx_cloud/scene.py +++ b/homeassistant/components/lifx_cloud/scene.py @@ -50,7 +50,7 @@ async def async_setup_platform( async with asyncio.timeout(timeout): scenes_resp = await httpsession.get(url, headers=headers) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.exception("Error on %s", url) return @@ -92,5 +92,5 @@ class LifxCloudScene(Scene): async with asyncio.timeout(self._timeout): await httpsession.put(url, headers=self._headers) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.exception("Error on %s", url) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 4abe18daa21..28f34e2eec0 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -882,6 +882,8 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_supported_features: LightEntityFeature = LightEntityFeature(0) _attr_xy_color: tuple[float, float] | None = None + __color_mode_reported = False + @cached_property def brightness(self) -> int | None: """Return the brightness of this light between 0..255.""" @@ -897,7 +899,20 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the color mode of the light with backwards compatibility.""" if (color_mode := self.color_mode) is None: # Backwards compatibility for color_mode added in 2021.4 - # Add warning in 2024.3, remove in 2025.3 + # Warning added in 2024.3, break in 2025.3 + if not self.__color_mode_reported and self.__should_report_light_issue(): + self.__color_mode_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "%s (%s) does not report a color mode, this will stop working " + "in Home Assistant Core 2025.3, please %s" + ), + self.entity_id, + type(self), + report_issue, + ) + supported = self._light_internal_supported_color_modes if ColorMode.HS in supported and self.hs_color is not None: @@ -1068,8 +1083,8 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): effect: str | None, ) -> None: """Validate the color mode.""" - if color_mode is None: - # The light is turned off + if color_mode is None or color_mode == ColorMode.UNKNOWN: + # The light is turned off or in an unknown state return if not effect or effect == EFFECT_OFF: @@ -1077,13 +1092,22 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # color modes if color_mode in supported_color_modes: return - # Increase severity to warning in 2024.3, reject in 2025.3 - _LOGGER.debug( - "%s: set to unsupported color_mode: %s, supported_color_modes: %s", - self.entity_id, - color_mode, - supported_color_modes, - ) + # Warning added in 2024.3, reject in 2025.3 + if not self.__color_mode_reported and self.__should_report_light_issue(): + self.__color_mode_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "%s (%s) set to unsupported color mode %s, expected one of %s, " + "this will stop working in Home Assistant Core 2025.3, " + "please %s" + ), + self.entity_id, + type(self), + color_mode, + supported_color_modes, + report_issue, + ) return # When an effect is active, the color mode should indicate what adjustments are @@ -1097,15 +1121,50 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if color_mode in effect_color_modes: return - # Increase severity to warning in 2024.3, reject in 2025.3 - _LOGGER.debug( - "%s: set to unsupported color_mode: %s, supported for effect: %s", - self.entity_id, - color_mode, - effect_color_modes, - ) + # Warning added in 2024.3, reject in 2025.3 + if not self.__color_mode_reported and self.__should_report_light_issue(): + self.__color_mode_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "%s (%s) set to unsupported color mode %s when rendering an effect," + " expected one of %s, this will stop working in Home Assistant " + "Core 2025.3, please %s" + ), + self.entity_id, + type(self), + color_mode, + effect_color_modes, + report_issue, + ) return + def __validate_supported_color_modes( + self, + supported_color_modes: set[ColorMode] | set[str], + ) -> None: + """Validate the supported color modes.""" + if self.__color_mode_reported: + return + + try: + valid_supported_color_modes(supported_color_modes) + except vol.Error: + # Warning added in 2024.3, reject in 2025.3 + if not self.__color_mode_reported and self.__should_report_light_issue(): + self.__color_mode_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "%s (%s) sets invalid supported color modes %s, this will stop " + "working in Home Assistant Core 2025.3, please %s" + ), + self.entity_id, + type(self), + supported_color_modes, + report_issue, + ) + @final @property def state_attributes(self) -> dict[str, Any] | None: @@ -1137,7 +1196,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data[ATTR_BRIGHTNESS] = None elif supported_features_value & SUPPORT_BRIGHTNESS: # Backwards compatibility for ambiguous / incomplete states - # Add warning in 2024.3, remove in 2025.3 + # Warning is printed by supported_features_compat, remove in 2025.1 if _is_on: data[ATTR_BRIGHTNESS] = self.brightness else: @@ -1158,7 +1217,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): data[ATTR_COLOR_TEMP] = None elif supported_features_value & SUPPORT_COLOR_TEMP: # Backwards compatibility - # Add warning in 2024.3, remove in 2025.3 + # Warning is printed by supported_features_compat, remove in 2025.1 if _is_on: color_temp_kelvin = self.color_temp_kelvin data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin @@ -1191,10 +1250,23 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def _light_internal_supported_color_modes(self) -> set[ColorMode] | set[str]: """Calculate supported color modes with backwards compatibility.""" if (_supported_color_modes := self.supported_color_modes) is not None: + self.__validate_supported_color_modes(_supported_color_modes) return _supported_color_modes # Backwards compatibility for supported_color_modes added in 2021.4 - # Add warning in 2024.3, remove in 2025.3 + # Warning added in 2024.3, remove in 2025.3 + if not self.__color_mode_reported and self.__should_report_light_issue(): + self.__color_mode_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "%s (%s) does not set supported color modes, this will stop working" + " in Home Assistant Core 2025.3, please %s" + ), + self.entity_id, + type(self), + report_issue, + ) supported_features = self.supported_features_compat supported_features_value = supported_features.value supported_color_modes: set[ColorMode] = set() @@ -1251,3 +1323,10 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): report_issue, ) return new_features + + def __should_report_light_issue(self) -> bool: + """Return if light color mode issues should be reported.""" + if not self.platform: + return True + # philips_js and zha have known issues, we don't need users to open issues + return self.platform.platform_name not in {"philips_js", "zha"} diff --git a/homeassistant/components/lightwave/climate.py b/homeassistant/components/lightwave/climate.py index 60108aba024..5e89e4f8145 100644 --- a/homeassistant/components/lightwave/climate.py +++ b/homeassistant/components/lightwave/climate.py @@ -47,9 +47,14 @@ class LightwaveTrv(ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_min_temp = DEFAULT_MIN_TEMP _attr_max_temp = DEFAULT_MAX_TEMP - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = 0.5 _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, name, device_id, lwlink, serial): """Initialize LightwaveTrv entity.""" diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index 952363650d6..6990dabff1d 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -67,6 +67,7 @@ class LivisiClimate(LivisiEntity, ClimateEntity): _attr_hvac_mode = HVACMode.HEAT _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index 29d89a4c22f..af41374ec9b 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -9,7 +9,6 @@ from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import ulid_to_bytes_or_none from homeassistant.helpers.json import json_dumps -from homeassistant.util import dt as dt_util from .all import all_stmt from .devices import devices_stmt @@ -28,8 +27,8 @@ def statement_for_request( context_id: str | None = None, ) -> StatementLambdaElement: """Generate the logbook statement for a logbook request.""" - start_day = dt_util.utc_to_timestamp(start_day_dt) - end_day = dt_util.utc_to_timestamp(end_day_dt) + start_day = start_day_dt.timestamp() + end_day = end_day_dt.timestamp() # No entities: logbook sends everything for the timeframe # limited by the context_id and the yaml configured filter if not entity_ids and not device_ids: diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index 82124247adf..0b1b34ca375 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -184,8 +184,8 @@ def _generate_stream_message( """Generate a logbook stream message response.""" return { "events": events, - "start_time": dt_util.utc_to_timestamp(start_day), - "end_time": dt_util.utc_to_timestamp(end_day), + "start_time": start_day.timestamp(), + "end_time": end_day.timestamp(), } diff --git a/homeassistant/components/logi_circle/__init__.py b/homeassistant/components/logi_circle/__init__.py index a14cd60c993..abdae4001f3 100644 --- a/homeassistant/components/logi_circle/__init__.py +++ b/homeassistant/components/logi_circle/__init__.py @@ -170,7 +170,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: notification_id=NOTIFICATION_ID, ) return False - except asyncio.TimeoutError: + except TimeoutError: # The TimeoutError exception object returns nothing when casted to a # string, so we'll handle it separately. err = f"{_TIMEOUT}s timeout exceeded when connecting to Logi Circle API" diff --git a/homeassistant/components/logi_circle/config_flow.py b/homeassistant/components/logi_circle/config_flow.py index 9785940aca2..be22a9a5d30 100644 --- a/homeassistant/components/logi_circle/config_flow.py +++ b/homeassistant/components/logi_circle/config_flow.py @@ -162,7 +162,7 @@ class LogiCircleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except AuthorizationFailed: (self.hass.data[DATA_FLOW_IMPL][DOMAIN][EXTERNAL_ERRORS]) = "invalid_auth" return self.async_abort(reason="external_error") - except asyncio.TimeoutError: + except TimeoutError: ( self.hass.data[DATA_FLOW_IMPL][DOMAIN][EXTERNAL_ERRORS] ) = "authorize_url_timeout" diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 37156e9ca08..358ccc5ae37 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -101,7 +101,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: lookin_device = await lookin_protocol.get_info() devices = await lookin_protocol.get_devices() - except (asyncio.TimeoutError, aiohttp.ClientError, NoUsableService) as ex: + except (TimeoutError, aiohttp.ClientError, NoUsableService) as ex: raise ConfigEntryNotReady from ex if entry.unique_id != (found_uuid := lookin_device.id.upper()): diff --git a/homeassistant/components/lookin/climate.py b/homeassistant/components/lookin/climate.py index f09bedab201..1bee2d14295 100644 --- a/homeassistant/components/lookin/climate.py +++ b/homeassistant/components/lookin/climate.py @@ -97,6 +97,8 @@ class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON ) _attr_fan_modes: list[str] = LOOKIN_FAN_MODE_IDX_TO_HASS _attr_swing_modes: list[str] = LOOKIN_SWING_MODE_IDX_TO_HASS @@ -104,6 +106,7 @@ class ConditionerEntity(LookinCoordinatorEntity, ClimateEntity): _attr_min_temp = MIN_TEMP _attr_max_temp = MAX_TEMP _attr_target_temperature_step = PRECISION_WHOLE + _enable_turn_on_off_backwards_compatibility = False def __init__( self, diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index cc43baab1c8..e22987ba426 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -1,7 +1,6 @@ """The loqed integration.""" from __future__ import annotations -import asyncio import logging import re @@ -40,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), ) except ( - asyncio.TimeoutError, + TimeoutError, aiohttp.ClientError, ) as ex: raise ConfigEntryNotReady(f"Unable to connect to bridge at {host}") from ex diff --git a/homeassistant/components/lupusec/__init__.py b/homeassistant/components/lupusec/__init__.py index bf7c30845a3..f937c7edd10 100644 --- a/homeassistant/components/lupusec/__init__.py +++ b/homeassistant/components/lupusec/__init__.py @@ -1,4 +1,5 @@ """Support for Lupusec Home Security system.""" +from json import JSONDecodeError import logging import lupupy @@ -111,16 +112,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: lupusec_system = await hass.async_add_executor_job( lupupy.Lupusec, username, password, host ) - except LupusecException: _LOGGER.error("Failed to connect to Lupusec device at %s", host) return False - except Exception as ex: # pylint: disable=broad-except - _LOGGER.error( - "Unknown error while trying to connect to Lupusec device at %s: %s", - host, - ex, - ) + except JSONDecodeError: + _LOGGER.error("Failed to connect to Lupusec device at %s", host) return False hass.data.setdefault(DOMAIN, {})[entry.entry_id] = lupusec_system diff --git a/homeassistant/components/lupusec/alarm_control_panel.py b/homeassistant/components/lupusec/alarm_control_panel.py index 2e4ca5cab63..cd4e433bd5d 100644 --- a/homeassistant/components/lupusec/alarm_control_panel.py +++ b/homeassistant/components/lupusec/alarm_control_panel.py @@ -29,14 +29,14 @@ SCAN_INTERVAL = timedelta(seconds=2) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up an alarm control panel for a Lupusec device.""" data = hass.data[DOMAIN][config_entry.entry_id] - alarm_devices = [LupusecAlarm(data, data.get_alarm(), config_entry.entry_id)] + alarm = await hass.async_add_executor_job(data.get_alarm) - async_add_devices(alarm_devices) + async_add_entities([LupusecAlarm(data, alarm, config_entry.entry_id)]) class LupusecAlarm(LupusecDevice, AlarmControlPanelEntity): diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py index ecff9a6266d..5cf63579984 100644 --- a/homeassistant/components/lupusec/binary_sensor.py +++ b/homeassistant/components/lupusec/binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from functools import partial import logging import lupupy.constants as CONST @@ -25,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up a binary sensors for a Lupusec device.""" @@ -34,10 +35,12 @@ async def async_setup_entry( device_types = CONST.TYPE_OPENING + CONST.TYPE_SENSOR sensors = [] - for device in data.get_devices(generic_type=device_types): + partial_func = partial(data.get_devices, generic_type=device_types) + devices = await hass.async_add_executor_job(partial_func) + for device in devices: sensors.append(LupusecBinarySensor(device, config_entry.entry_id)) - async_add_devices(sensors) + async_add_entities(sensors) class LupusecBinarySensor(LupusecBaseSensor, BinarySensorEntity): diff --git a/homeassistant/components/lupusec/config_flow.py b/homeassistant/components/lupusec/config_flow.py index 64d53ce51f4..1fae687cbdb 100644 --- a/homeassistant/components/lupusec/config_flow.py +++ b/homeassistant/components/lupusec/config_flow.py @@ -1,5 +1,6 @@ """"Config flow for Lupusec integration.""" +from json import JSONDecodeError import logging from typing import Any @@ -50,6 +51,8 @@ class LupusecConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await test_host_connection(self.hass, host, username, password) except CannotConnect: errors["base"] = "cannot_connect" + except JSONDecodeError: + errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -80,6 +83,8 @@ class LupusecConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await test_host_connection(self.hass, host, username, password) except CannotConnect: return self.async_abort(reason="cannot_connect") + except JSONDecodeError: + return self.async_abort(reason="cannot_connect") except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") @@ -101,9 +106,9 @@ async def test_host_connection( try: await hass.async_add_executor_job(lupupy.Lupusec, username, password, host) - except lupupy.LupusecException: + except lupupy.LupusecException as ex: _LOGGER.error("Failed to connect to Lupusec device at %s", host) - raise CannotConnect + raise CannotConnect from ex class CannotConnect(HomeAssistantError): diff --git a/homeassistant/components/lupusec/strings.json b/homeassistant/components/lupusec/strings.json index 53f84c8b872..6fa59aaeb3d 100644 --- a/homeassistant/components/lupusec/strings.json +++ b/homeassistant/components/lupusec/strings.json @@ -21,7 +21,7 @@ "issues": { "deprecated_yaml_import_issue_cannot_connect": { "title": "The Lupus Electronics LUPUSEC YAML configuration import failed", - "description": "Configuring Lupus Electronics LUPUSEC using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to Lupus Electronics LUPUSEC works and restart Home Assistant to try again or remove the Lupus Electronics LUPUSEC YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + "description": "Configuring Lupus Electronics LUPUSEC using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to Lupus Electronics LUPUSEC works and restart Home Assistant to try again or remove the Lupus Electronics LUPUSEC YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." }, "deprecated_yaml_import_issue_unknown": { "title": "The Lupus Electronics LUPUSEC YAML configuration import failed", diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index a2b3796ef5b..e07c974f033 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +from functools import partial from typing import Any import lupupy.constants as CONST @@ -20,7 +21,7 @@ SCAN_INTERVAL = timedelta(seconds=2) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Lupusec switch devices.""" @@ -29,10 +30,12 @@ async def async_setup_entry( device_types = CONST.TYPE_SWITCH switches = [] - for device in data.get_devices(generic_type=device_types): + partial_func = partial(data.get_devices, generic_type=device_types) + devices = await hass.async_add_executor_job(partial_func) + for device in devices: switches.append(LupusecSwitch(device, config_entry.entry_id)) - async_add_devices(switches) + async_add_entities(switches) class LupusecSwitch(LupusecBaseSensor, SwitchEntity): diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index 6444aa306a2..73f1028bb72 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/lutron", "iot_class": "local_polling", "loggers": ["pylutron"], - "requirements": ["pylutron==0.2.8"] + "requirements": ["pylutron==0.2.12"] } diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 33cf6f21d6f..0dceada821e 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -171,7 +171,7 @@ async def async_setup_entry( return False timed_out = True - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): async with asyncio.timeout(BRIDGE_TIMEOUT): await bridge.connect() timed_out = False diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index 9b243a3ec98..21f7cbd9683 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -117,7 +117,7 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): assets = None try: assets = await async_pair(self.data[CONF_HOST]) - except (asyncio.TimeoutError, OSError): + except (TimeoutError, OSError): errors["base"] = "cannot_connect" if not errors: @@ -227,7 +227,7 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: async with asyncio.timeout(BRIDGE_TIMEOUT): await bridge.connect() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error( "Timeout while trying to connect to bridge at %s", self.data[CONF_HOST], diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index 90d9e407cb2..ecf9b50474d 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -173,6 +173,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): PRESET_TEMPORARY_HOLD, PRESET_VACATION_HOLD, ] + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -231,6 +232,11 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): self._attr_supported_features | ClimateEntityFeature.FAN_MODE ) + if len(self.hvac_modes) > 1: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + super().__init__( coordinator, location, diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 623d0f06295..9cb52fb161c 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -262,7 +262,7 @@ class MailboxMediaView(MailboxView): """Retrieve media.""" mailbox = self.get_mailbox(platform) - with suppress(asyncio.CancelledError, asyncio.TimeoutError): + with suppress(asyncio.CancelledError, TimeoutError): async with asyncio.timeout(10): try: stream = await mailbox.async_get_media(msgid) diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index 3a82e466888..aa89856074f 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -64,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: async with asyncio.timeout(CONNECT_TIMEOUT): await matter_client.connect() - except (CannotConnect, asyncio.TimeoutError) as err: + except (CannotConnect, TimeoutError) as err: raise ConfigEntryNotReady("Failed to connect to matter server") from err except InvalidServerVersion as err: if use_addon: @@ -109,7 +109,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: async with asyncio.timeout(LISTEN_READY_TIMEOUT): await init_ready.wait() - except asyncio.TimeoutError as err: + except TimeoutError as err: listen_task.cancel() raise ConfigEntryNotReady("Matter client not ready") from err diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 5690996841d..6d7d437a206 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -52,11 +52,27 @@ class MatterAdapter: async def setup_nodes(self) -> None: """Set up all existing nodes and subscribe to new nodes.""" + initialized_nodes: set[int] = set() for node in self.matter_client.get_nodes(): + if not node.available: + # ignore un-initialized nodes at startup + # catch them later when they become available. + continue + initialized_nodes.add(node.node_id) self._setup_node(node) def node_added_callback(event: EventType, node: MatterNode) -> None: """Handle node added event.""" + initialized_nodes.add(node.node_id) + self._setup_node(node) + + def node_updated_callback(event: EventType, node: MatterNode) -> None: + """Handle node updated event.""" + if node.node_id in initialized_nodes: + return + if not node.available: + return + initialized_nodes.add(node.node_id) self._setup_node(node) def endpoint_added_callback(event: EventType, data: dict[str, int]) -> None: @@ -116,6 +132,11 @@ class MatterAdapter: callback=node_added_callback, event_filter=EventType.NODE_ADDED ) ) + self.config_entry.async_on_unload( + self.matter_client.subscribe_events( + callback=node_updated_callback, event_filter=EventType.NODE_UPDATED + ) + ) def _setup_node(self, node: MatterNode) -> None: """Set up an node.""" diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 61535d990db..5c3f65d903c 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -129,6 +129,9 @@ class MatterEntity(Entity): async def async_update(self) -> None: """Call when the entity needs to be updated.""" + if not self._endpoint.node.available: + # skip poll when the node is not (yet) available + return # manually poll/refresh the primary value await self.matter_client.refresh_attribute( self._endpoint.node.node_id, diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 7e6f42f44b4..aa93cef9916 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -10,10 +10,12 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, + ATTR_TRANSITION, ATTR_XY_COLOR, ColorMode, LightEntity, LightEntityDescription, + LightEntityFeature, filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry @@ -38,6 +40,7 @@ COLOR_MODE_MAP = { clusters.ColorControl.Enums.ColorMode.kCurrentXAndCurrentY: ColorMode.XY, clusters.ColorControl.Enums.ColorMode.kColorTemperature: ColorMode.COLOR_TEMP, } +DEFAULT_TRANSITION = 0.2 async def async_setup_entry( @@ -58,7 +61,9 @@ class MatterLight(MatterEntity, LightEntity): _supports_color = False _supports_color_temperature = False - async def _set_xy_color(self, xy_color: tuple[float, float]) -> None: + async def _set_xy_color( + self, xy_color: tuple[float, float], transition: float = 0.0 + ) -> None: """Set xy color.""" matter_xy = convert_to_matter_xy(xy_color) @@ -67,8 +72,8 @@ class MatterLight(MatterEntity, LightEntity): clusters.ColorControl.Commands.MoveToColor( colorX=int(matter_xy[0]), colorY=int(matter_xy[1]), - # It's required in TLV. We don't implement transition time yet. - transitionTime=0, + # transition in matter is measured in tenths of a second + transitionTime=int(transition * 10), # allow setting the color while the light is off, # by setting the optionsMask to 1 (=ExecuteIfOff) optionsMask=1, @@ -76,7 +81,9 @@ class MatterLight(MatterEntity, LightEntity): ) ) - async def _set_hs_color(self, hs_color: tuple[float, float]) -> None: + async def _set_hs_color( + self, hs_color: tuple[float, float], transition: float = 0.0 + ) -> None: """Set hs color.""" matter_hs = convert_to_matter_hs(hs_color) @@ -85,8 +92,8 @@ class MatterLight(MatterEntity, LightEntity): clusters.ColorControl.Commands.MoveToHueAndSaturation( hue=int(matter_hs[0]), saturation=int(matter_hs[1]), - # It's required in TLV. We don't implement transition time yet. - transitionTime=0, + # transition in matter is measured in tenths of a second + transitionTime=int(transition * 10), # allow setting the color while the light is off, # by setting the optionsMask to 1 (=ExecuteIfOff) optionsMask=1, @@ -94,14 +101,14 @@ class MatterLight(MatterEntity, LightEntity): ) ) - async def _set_color_temp(self, color_temp: int) -> None: + async def _set_color_temp(self, color_temp: int, transition: float = 0.0) -> None: """Set color temperature.""" await self.send_device_command( clusters.ColorControl.Commands.MoveToColorTemperature( colorTemperatureMireds=color_temp, - # It's required in TLV. We don't implement transition time yet. - transitionTime=0, + # transition in matter is measured in tenths of a second + transitionTime=int(transition * 10), # allow setting the color while the light is off, # by setting the optionsMask to 1 (=ExecuteIfOff) optionsMask=1, @@ -109,7 +116,7 @@ class MatterLight(MatterEntity, LightEntity): ) ) - async def _set_brightness(self, brightness: int) -> None: + async def _set_brightness(self, brightness: int, transition: float = 0.0) -> None: """Set brightness.""" level_control = self._endpoint.get_cluster(clusters.LevelControl) @@ -127,8 +134,8 @@ class MatterLight(MatterEntity, LightEntity): await self.send_device_command( clusters.LevelControl.Commands.MoveToLevelWithOnOff( level=level, - # It's required in TLV. We don't implement transition time yet. - transitionTime=0, + # transition in matter is measured in tenths of a second + transitionTime=int(transition * 10), ) ) @@ -251,20 +258,21 @@ class MatterLight(MatterEntity, LightEntity): xy_color = kwargs.get(ATTR_XY_COLOR) color_temp = kwargs.get(ATTR_COLOR_TEMP) brightness = kwargs.get(ATTR_BRIGHTNESS) + transition = kwargs.get(ATTR_TRANSITION, DEFAULT_TRANSITION) if self.supported_color_modes is not None: if hs_color is not None and ColorMode.HS in self.supported_color_modes: - await self._set_hs_color(hs_color) + await self._set_hs_color(hs_color, transition) elif xy_color is not None and ColorMode.XY in self.supported_color_modes: - await self._set_xy_color(xy_color) + await self._set_xy_color(xy_color, transition) elif ( color_temp is not None and ColorMode.COLOR_TEMP in self.supported_color_modes ): - await self._set_color_temp(color_temp) + await self._set_color_temp(color_temp, transition) if brightness is not None and self._supports_brightness: - await self._set_brightness(brightness) + await self._set_brightness(brightness, transition) return await self.send_device_command( @@ -324,6 +332,9 @@ class MatterLight(MatterEntity, LightEntity): supported_color_modes = filter_supported_color_modes(supported_color_modes) self._attr_supported_color_modes = supported_color_modes + # flag support for transition as soon as we support setting brightness and/or color + if supported_color_modes != {ColorMode.ONOFF}: + self._attr_supported_features |= LightEntityFeature.TRANSITION LOGGER.debug( "Supported color modes: %s for %s", diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 4173e129895..801704c25c5 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==5.4.0"] + "requirements": ["python-matter-server==5.5.0"] } diff --git a/homeassistant/components/maxcube/__init__.py b/homeassistant/components/maxcube/__init__.py index f8899ea082f..41aed4be15c 100644 --- a/homeassistant/components/maxcube/__init__.py +++ b/homeassistant/components/maxcube/__init__.py @@ -1,6 +1,5 @@ """Support for the MAX! Cube LAN Gateway.""" import logging -from socket import timeout from threading import Lock import time @@ -65,7 +64,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: try: cube = MaxCube(host, port, now=now) hass.data[DATA_KEY][host] = MaxCubeHandle(cube, scan_interval) - except timeout as ex: + except TimeoutError as ex: _LOGGER.error("Unable to connect to Max!Cube gateway: %s", str(ex)) persistent_notification.create( hass, @@ -108,7 +107,7 @@ class MaxCubeHandle: try: self.cube.update() - except timeout: + except TimeoutError: _LOGGER.error("Max!Cube connection failed") return False diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py index f3d302fc209..42abed48724 100644 --- a/homeassistant/components/maxcube/climate.py +++ b/homeassistant/components/maxcube/climate.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -import socket from typing import Any from maxcube.device import ( @@ -152,7 +151,7 @@ class MaxCubeClimate(ClimateEntity): with self._cubehandle.mutex: try: self._cubehandle.cube.set_temperature_mode(self._device, temp, mode) - except (socket.timeout, OSError): + except (TimeoutError, OSError): _LOGGER.error("Setting HVAC mode failed") @property diff --git a/homeassistant/components/meater/__init__.py b/homeassistant/components/meater/__init__.py index 12fdb7f3a06..ee2307fbc84 100644 --- a/homeassistant/components/meater/__init__.py +++ b/homeassistant/components/meater/__init__.py @@ -47,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_data() -> dict[str, MeaterProbe]: """Fetch data from API endpoint.""" try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # Note: TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. async with asyncio.timeout(10): devices: list[MeaterProbe] = await meater_api.get_all_devices() diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 673f0a44374..ffb1d6d4a32 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1345,7 +1345,7 @@ async def async_fetch_image( """Retrieve an image.""" content, content_type = (None, None) websession = async_get_clientsession(hass) - with suppress(asyncio.TimeoutError): + with suppress(TimeoutError): async with asyncio.timeout(10): response = await websession.get(url) if response.status == HTTPStatus.OK: diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 2187cb5b8b8..2db3e79dfe9 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -22,7 +22,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] @@ -36,7 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if isinstance(ex, ClientResponseError) and ex.code == 401: raise ConfigEntryAuthFailed from ex raise ConfigEntryNotReady from ex - except (asyncio.TimeoutError, ClientConnectionError) as ex: + except (TimeoutError, ClientConnectionError) as ex: raise ConfigEntryNotReady from ex hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_devices}) @@ -123,11 +123,6 @@ class MelCloudDevice: via_device=(DOMAIN, f"{dev.mac}-{dev.serial}"), ) - @property - def daily_energy_consumed(self) -> float | None: - """Return energy consumed during the current day in kWh.""" - return self.device.daily_energy_consumed - async def mel_devices_setup( hass: HomeAssistant, token: str @@ -138,8 +133,8 @@ async def mel_devices_setup( all_devices = await get_devices( token, session, - conf_update_interval=timedelta(minutes=5), - device_set_debounce=timedelta(seconds=1), + conf_update_interval=timedelta(minutes=30), + device_set_debounce=timedelta(seconds=2), ) wrapped_devices: dict[str, list[MelCloudDevice]] = {} for device_type, devices in all_devices.items(): diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 9db44d5276c..f40aaa25cfd 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -60,7 +60,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if err.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): return self.async_abort(reason="invalid_auth") return self.async_abort(reason="cannot_connect") - except (asyncio.TimeoutError, ClientError): + except (TimeoutError, ClientError): return self.async_abort(reason="cannot_connect") return await self._create_entry(username, acquired_token) @@ -136,7 +136,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): else: errors["base"] = "cannot_connect" except ( - asyncio.TimeoutError, + TimeoutError, ClientError, ): errors["base"] = "cannot_connect" diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index 8be40b22d9c..0122c840373 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -1,10 +1,10 @@ { "domain": "melcloud", "name": "MELCloud", - "codeowners": ["@vilppuvuorinen"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/melcloud", "iot_class": "cloud_polling", "loggers": ["pymelcloud"], - "requirements": ["pymelcloud==2.5.8"] + "requirements": ["pymelcloud==2.5.9"] } diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index cf53fe42b77..d3d1f4976f6 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -58,16 +58,6 @@ ATA_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( value_fn=lambda x: x.device.total_energy_consumed, enabled=lambda x: x.device.has_energy_consumed_meter, ), - MelcloudSensorEntityDescription( - key="daily_energy", - translation_key="daily_energy", - icon="mdi:factory", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda x: x.device.daily_energy_consumed, - enabled=lambda x: True, - ), ) ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( MelcloudSensorEntityDescription( @@ -90,16 +80,6 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( value_fn=lambda x: x.device.tank_temperature, enabled=lambda x: True, ), - MelcloudSensorEntityDescription( - key="daily_energy", - translation_key="daily_energy", - icon="mdi:factory", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda x: x.device.daily_energy_consumed, - enabled=lambda x: True, - ), ) ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( MelcloudSensorEntityDescription( diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index 3abb30bf9ac..6a98b88e2d3 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -65,9 +65,6 @@ "room_temperature": { "name": "Room temperature" }, - "daily_energy": { - "name": "Daily energy consumed" - }, "outside_temperature": { "name": "Outside temperature" }, diff --git a/homeassistant/components/metoffice/__init__.py b/homeassistant/components/metoffice/__init__.py index e00215f6073..a658de9a024 100644 --- a/homeassistant/components/metoffice/__init__.py +++ b/homeassistant/components/metoffice/__init__.py @@ -4,9 +4,10 @@ from __future__ import annotations import asyncio import logging import re -import sys from typing import Any +import datapoint + from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, @@ -16,7 +17,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator @@ -34,9 +35,6 @@ from .const import ( from .data import MetOfficeData from .helpers import fetch_data, fetch_site -if sys.version_info < (3, 12): - import datapoint - _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR, Platform.WEATHER] @@ -44,10 +42,6 @@ PLATFORMS = [Platform.SENSOR, Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Met Office entry.""" - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "Met Office is not supported on Python 3.12. Please use Python 3.11." - ) latitude = entry.data[CONF_LATITUDE] longitude = entry.data[CONF_LONGITUDE] diff --git a/homeassistant/components/metoffice/data.py b/homeassistant/components/metoffice/data.py index 8512dd4c7a6..c6bb2b4c01b 100644 --- a/homeassistant/components/metoffice/data.py +++ b/homeassistant/components/metoffice/data.py @@ -2,12 +2,10 @@ from __future__ import annotations from dataclasses import dataclass -import sys -if sys.version_info < (3, 12): - from datapoint.Forecast import Forecast - from datapoint.Site import Site - from datapoint.Timestep import Timestep +from datapoint.Forecast import Forecast +from datapoint.Site import Site +from datapoint.Timestep import Timestep @dataclass diff --git a/homeassistant/components/metoffice/helpers.py b/homeassistant/components/metoffice/helpers.py index 389462d573a..5b698bf19da 100644 --- a/homeassistant/components/metoffice/helpers.py +++ b/homeassistant/components/metoffice/helpers.py @@ -2,7 +2,9 @@ from __future__ import annotations import logging -import sys + +import datapoint +from datapoint.Site import Site from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util.dt import utcnow @@ -10,11 +12,6 @@ from homeassistant.util.dt import utcnow from .const import MODE_3HOURLY from .data import MetOfficeData -if sys.version_info < (3, 12): - import datapoint - from datapoint.Site import Site - - _LOGGER = logging.getLogger(__name__) @@ -34,7 +31,7 @@ def fetch_site( def fetch_data(connection: datapoint.Manager, site: Site, mode: str) -> MetOfficeData: """Fetch weather and forecast from Datapoint API.""" try: - forecast = connection.get_forecast_for_site(site.id, mode) + forecast = connection.get_forecast_for_site(site.location_id, mode) except (ValueError, datapoint.exceptions.APIException) as err: _LOGGER.error("Check Met Office connection: %s", err.args) raise UpdateFailed from err diff --git a/homeassistant/components/metoffice/manifest.json b/homeassistant/components/metoffice/manifest.json index 401f2c9d265..17643d7e061 100644 --- a/homeassistant/components/metoffice/manifest.json +++ b/homeassistant/components/metoffice/manifest.json @@ -3,9 +3,8 @@ "name": "Met Office", "codeowners": ["@MrHarcombe", "@avee87"], "config_flow": true, - "disabled": "Integration library not compatible with Python 3.12", "documentation": "https://www.home-assistant.io/integrations/metoffice", "iot_class": "cloud_polling", "loggers": ["datapoint"], - "requirements": ["datapoint==0.9.8;python_version<'3.12'"] + "requirements": ["datapoint==0.9.9"] } diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 371c396a829..84a51a0d584 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -251,6 +251,6 @@ class MetOfficeCurrentSensor( return { ATTR_LAST_UPDATE: self.coordinator.data.now.date, ATTR_SENSOR_ID: self.entity_description.key, - ATTR_SITE_ID: self.coordinator.data.site.id, + ATTR_SITE_ID: self.coordinator.data.site.location_id, ATTR_SITE_NAME: self.coordinator.data.site.name, } diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index af0567f99a1..e3f722ae2be 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -334,7 +334,7 @@ class MicrosoftFace: except aiohttp.ClientError: _LOGGER.warning("Can't connect to microsoft face api") - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Timeout from microsoft face api %s", response.url) raise HomeAssistantError("Network error on microsoft face api.") diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 14fbb83b61b..8136334514f 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -34,11 +34,10 @@ async def async_setup_entry( registry = er.async_get(hass) # Restore clients that is not a part of active clients list. - for entity in registry.entities.values(): - if ( - entity.config_entry_id == config_entry.entry_id - and entity.domain == DEVICE_TRACKER - ): + for entity in registry.entities.get_entries_for_config_entry_id( + config_entry.entry_id + ): + if entity.domain == DEVICE_TRACKER: if ( entity.unique_id in coordinator.api.devices or entity.unique_id not in coordinator.api.all_devices diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 44d60d5dcb4..044a45fb9b5 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import timedelta import logging -import socket import ssl from typing import Any @@ -227,7 +226,7 @@ class MikrotikData: except ( librouteros.exceptions.ConnectionClosed, OSError, - socket.timeout, + TimeoutError, ) as api_error: _LOGGER.error("Mikrotik %s connection error %s", self._host, api_error) # try to reconnect @@ -330,7 +329,7 @@ def get_api(entry: dict[str, Any]) -> librouteros.Api: except ( librouteros.exceptions.LibRouterosError, OSError, - socket.timeout, + TimeoutError, ) as api_error: _LOGGER.error("Mikrotik %s error: %s", entry[CONF_HOST], api_error) if "invalid user name or password" in str(api_error): diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 2e7b22da833..a2e70b8f9c8 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -1,4 +1,5 @@ """Support for mill wifi-enabled home heaters.""" + from typing import Any import mill @@ -186,9 +187,14 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit _attr_max_temp = MAX_TEMP _attr_min_temp = MIN_TEMP _attr_name = None - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) _attr_target_temperature_step = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, coordinator: MillDataUpdateCoordinator) -> None: """Initialize the thermostat.""" diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index 0e2debda33e..6c854750baa 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -86,9 +86,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Migrate config entry. _LOGGER.debug("Migrating config entry. Resetting unique ID: %s", old_unique_id) - config_entry.unique_id = None - config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry) + hass.config_entries.async_update_entry(config_entry, unique_id=None, version=2) # Migrate device. await _async_migrate_device_identifiers(hass, config_entry, old_unique_id) @@ -142,8 +140,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> new_data[CONF_ADDRESS] = address del new_data[CONF_HOST] del new_data[CONF_PORT] - config_entry.version = 3 - hass.config_entries.async_update_entry(config_entry, data=new_data) + hass.config_entries.async_update_entry(config_entry, data=new_data, version=3) _LOGGER.debug("Migration to version 3 successful") diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index a2b2de4eda8..d424df620cf 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -149,7 +149,7 @@ class MjpegCamera(Camera): image = await response.read() return image - except asyncio.TimeoutError: + except TimeoutError: LOGGER.error("Timeout getting camera image from %s", self.name) except aiohttp.ClientError as err: @@ -169,7 +169,7 @@ class MjpegCamera(Camera): try: if self._still_image_url: # Fallback to MJPEG stream if still image URL is not available - with suppress(asyncio.TimeoutError, httpx.HTTPError): + with suppress(TimeoutError, httpx.HTTPError): return ( await client.get( self._still_image_url, auth=auth, timeout=TIMEOUT @@ -183,7 +183,7 @@ class MjpegCamera(Camera): stream.aiter_bytes(BUFFER_SIZE) ) - except asyncio.TimeoutError: + except TimeoutError: LOGGER.error("Timeout getting camera image from %s", self.name) except httpx.HTTPError as err: @@ -201,7 +201,7 @@ class MjpegCamera(Camera): response = web.StreamResponse(headers=stream.headers) await response.prepare(request) # Stream until we are done or client disconnects - with suppress(asyncio.TimeoutError, httpx.HTTPError): + with suppress(TimeoutError, httpx.HTTPError): async for chunk in stream.aiter_bytes(BUFFER_SIZE): if not self.hass.is_running: break diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index 164f21af15a..e6f7126b0b8 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -197,7 +197,7 @@ class MobileAppNotificationService(BaseNotificationService): else: _LOGGER.error(message) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Timeout sending notification to %s", push_url) except aiohttp.ClientError as err: _LOGGER.error("Error sending notification to %s: %r", push_url, err) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 0f674d4d0df..1151a5f1f01 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -186,7 +186,7 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend( ] ), vol.Optional(CONF_STRUCTURE): cv.string, - vol.Optional(CONF_SCALE, default=1): cv.positive_float, + vol.Optional(CONF_SCALE, default=1): vol.Coerce(float), vol.Optional(CONF_OFFSET, default=0): vol.Coerce(float), vol.Optional(CONF_PRECISION): cv.positive_int, vol.Optional( @@ -241,8 +241,8 @@ CLIMATE_SCHEMA = vol.All( { vol.Required(CONF_TARGET_TEMP): cv.positive_int, vol.Optional(CONF_TARGET_TEMP_WRITE_REGISTERS, default=False): cv.boolean, - vol.Optional(CONF_MAX_TEMP, default=35): cv.positive_float, - vol.Optional(CONF_MIN_TEMP, default=5): cv.positive_float, + vol.Optional(CONF_MAX_TEMP, default=35): vol.Coerce(float), + vol.Optional(CONF_MIN_TEMP, default=5): vol.Coerce(float), vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string, vol.Optional(CONF_HVAC_ONOFF_REGISTER): cv.positive_int, @@ -342,8 +342,8 @@ SENSOR_SCHEMA = vol.All( vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Exclusive(CONF_VIRTUAL_COUNT, "vir_sen_count"): cv.positive_int, vol.Exclusive(CONF_SLAVE_COUNT, "vir_sen_count"): cv.positive_int, - vol.Optional(CONF_MIN_VALUE): cv.positive_float, - vol.Optional(CONF_MAX_VALUE): cv.positive_float, + vol.Optional(CONF_MIN_VALUE): vol.Coerce(float), + vol.Optional(CONF_MAX_VALUE): vol.Coerce(float), vol.Optional(CONF_NAN_VALUE): nan_validator, vol.Optional(CONF_ZERO_SUPPRESS): cv.positive_float, } diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index cdc1e7a6986..ac11bab303d 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -199,6 +199,8 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): self._precision = config.get(CONF_PRECISION, 2) else: self._precision = config.get(CONF_PRECISION, 0) + if self._precision > 0 or self._scale != int(self._scale): + self._value_is_int = False def _swap_registers(self, registers: list[int], slave_count: int) -> list[int]: """Do swap as needed.""" diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 637478fffd4..d31323a27e9 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -364,7 +364,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): # Translate the value received if fan_mode is not None: - self._attr_fan_mode = self._fan_mode_mapping_from_modbus[int(fan_mode)] + self._attr_fan_mode = self._fan_mode_mapping_from_modbus.get( + int(fan_mode), self._attr_fan_mode + ) # Read the on/off register if defined. If the value in this # register is "OFF", it will take precedence over the value diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 194eb56757e..b90f5663643 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pymodbus"], "quality_scale": "gold", - "requirements": ["pymodbus==3.6.3"] + "requirements": ["pymodbus==3.6.4"] } diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 71631352d52..c8e7fc3765e 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -172,9 +172,7 @@ async def async_modbus_setup( slave = int(float(service.data[ATTR_SLAVE])) address = int(float(service.data[ATTR_ADDRESS])) value = service.data[ATTR_VALUE] - hub = hub_collect[ - service.data[ATTR_HUB] if ATTR_HUB in service.data else DEFAULT_HUB - ] + hub = hub_collect[service.data.get(ATTR_HUB, DEFAULT_HUB)] if isinstance(value, list): await hub.async_pb_call( slave, @@ -196,9 +194,7 @@ async def async_modbus_setup( slave = int(float(service.data[ATTR_SLAVE])) address = service.data[ATTR_ADDRESS] state = service.data[ATTR_STATE] - hub = hub_collect[ - service.data[ATTR_HUB] if ATTR_HUB in service.data else DEFAULT_HUB - ] + hub = hub_collect[service.data.get(ATTR_HUB, DEFAULT_HUB)] if isinstance(state, list): await hub.async_pb_call(slave, address, state, CALL_TYPE_WRITE_COILS) else: diff --git a/homeassistant/components/moehlenhoff_alpha2/config_flow.py b/homeassistant/components/moehlenhoff_alpha2/config_flow.py index d2d14f27552..a4bdfd71cce 100644 --- a/homeassistant/components/moehlenhoff_alpha2/config_flow.py +++ b/homeassistant/components/moehlenhoff_alpha2/config_flow.py @@ -1,5 +1,4 @@ """Alpha2 config flow.""" -import asyncio import logging from typing import Any @@ -27,7 +26,7 @@ async def validate_input(data: dict[str, Any]) -> dict[str, str]: base = Alpha2Base(data[CONF_HOST]) try: await base.update_data() - except (aiohttp.client_exceptions.ClientConnectorError, asyncio.TimeoutError): + except (aiohttp.client_exceptions.ClientConnectorError, TimeoutError): return {"error": "cannot_connect"} except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/mopeka/manifest.json b/homeassistant/components/mopeka/manifest.json index 69452bf1fec..82afd4d2057 100644 --- a/homeassistant/components/mopeka/manifest.json +++ b/homeassistant/components/mopeka/manifest.json @@ -8,12 +8,48 @@ "manufacturer_data_start": [3], "connectable": false }, + { + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 89, + "manufacturer_data_start": [4], + "connectable": false + }, + { + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 89, + "manufacturer_data_start": [5], + "connectable": false + }, + { + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 89, + "manufacturer_data_start": [6], + "connectable": false + }, { "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", "manufacturer_id": 89, "manufacturer_data_start": [8], "connectable": false }, + { + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 89, + "manufacturer_data_start": [9], + "connectable": false + }, + { + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 89, + "manufacturer_data_start": [10], + "connectable": false + }, + { + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 89, + "manufacturer_data_start": [11], + "connectable": false + }, { "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", "manufacturer_id": 89, @@ -27,5 +63,5 @@ "documentation": "https://www.home-assistant.io/integrations/mopeka", "integration_type": "device", "iot_class": "local_push", - "requirements": ["mopeka-iot-ble==0.5.0"] + "requirements": ["mopeka-iot-ble==0.7.0"] } diff --git a/homeassistant/components/motion_blinds/coordinator.py b/homeassistant/components/motion_blinds/coordinator.py index e8dc5494f25..57d67165320 100644 --- a/homeassistant/components/motion_blinds/coordinator.py +++ b/homeassistant/components/motion_blinds/coordinator.py @@ -2,7 +2,6 @@ import asyncio from datetime import timedelta import logging -from socket import timeout from typing import Any from motionblinds import DEVICE_TYPES_WIFI, ParseException @@ -50,7 +49,7 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): """Fetch data from gateway.""" try: self._gateway.Update() - except (timeout, ParseException): + except (TimeoutError, ParseException): # let the error be logged and handled by the motionblinds library return {ATTR_AVAILABLE: False} @@ -65,7 +64,7 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): blind.Update() else: blind.Update_trigger() - except (timeout, ParseException): + except (TimeoutError, ParseException): # let the error be logged and handled by the motionblinds library return {ATTR_AVAILABLE: False} diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 833d2640202..9dde08af5f0 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -51,6 +51,7 @@ POSITION_DEVICE_MAP = { BlindType.CurtainLeft: CoverDeviceClass.CURTAIN, BlindType.CurtainRight: CoverDeviceClass.CURTAIN, BlindType.SkylightBlind: CoverDeviceClass.SHADE, + BlindType.InsectScreen: CoverDeviceClass.SHADE, } TILT_DEVICE_MAP = { @@ -69,6 +70,7 @@ TILT_ONLY_DEVICE_MAP = { TDBU_DEVICE_MAP = { BlindType.TopDownBottomUp: CoverDeviceClass.SHADE, + BlindType.TriangleBlind: CoverDeviceClass.BLIND, } @@ -398,6 +400,7 @@ class MotionTDBUDevice(MotionPositionDevice): def __init__(self, coordinator, blind, device_class, motor): """Initialize the blind.""" super().__init__(coordinator, blind, device_class) + delattr(self, "_attr_name") self._motor = motor self._motor_key = motor[0] self._attr_translation_key = motor.lower() diff --git a/homeassistant/components/motion_blinds/gateway.py b/homeassistant/components/motion_blinds/gateway.py index ac18840ddeb..c0ddc9b4287 100644 --- a/homeassistant/components/motion_blinds/gateway.py +++ b/homeassistant/components/motion_blinds/gateway.py @@ -50,7 +50,7 @@ class ConnectMotionGateway: try: # update device info and get the connected sub devices await self._hass.async_add_executor_job(self.update_gateway) - except socket.timeout: + except TimeoutError: _LOGGER.error( "Timeout trying to connect to Motion Gateway with host %s", host ) diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index f9115cd8146..6f7b7dfae38 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.19"] + "requirements": ["motionblinds==0.6.20"] } diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index e71abe09069..dddcb0e00fd 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -1,5 +1,6 @@ """Support for Motion Blinds sensors.""" -from motionblinds import DEVICE_TYPES_WIFI, BlindType +from motionblinds import DEVICE_TYPES_WIFI +from motionblinds.motion_blinds import DEVICE_TYPE_TDBU from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry @@ -29,7 +30,7 @@ async def async_setup_entry( for blind in motion_gateway.device_list.values(): entities.append(MotionSignalStrengthSensor(coordinator, blind)) - if blind.type == BlindType.TopDownBottomUp: + if blind.device_type == DEVICE_TYPE_TDBU: entities.append(MotionTDBUBatterySensor(coordinator, blind, "Bottom")) entities.append(MotionTDBUBatterySensor(coordinator, blind, "Top")) elif blind.battery_voltage is not None and blind.battery_voltage > 0: diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index 9b3adb38e0c..0721afa9d3a 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -128,18 +128,16 @@ class MpdDevice(MediaPlayerEntity): try: async with asyncio.timeout(self._client.timeout + 5): await self._client.connect(self.server, self.port) - except asyncio.TimeoutError as error: + except TimeoutError as error: # TimeoutError has no message (which hinders logging further # down the line), so provide one. - raise asyncio.TimeoutError( - "Connection attempt timed out" - ) from error + raise TimeoutError("Connection attempt timed out") from error if self.password is not None: await self._client.password(self.password) self._is_available = True yield except ( - asyncio.TimeoutError, + TimeoutError, gaierror, mpd.ConnectionError, OSError, diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 593d5bbd202..4eadc0e98b3 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -7,7 +7,6 @@ from datetime import datetime import logging from typing import TYPE_CHECKING, Any, TypeVar, cast -import jinja2 import voluptuous as vol from homeassistant import config as conf_util @@ -27,7 +26,6 @@ from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ( ConfigValidationError, ServiceValidationError, - TemplateError, Unauthorized, ) from homeassistant.helpers import config_validation as cv, event as ev, template @@ -87,11 +85,13 @@ from .const import ( # noqa: F401 MQTT_DISCONNECTED, PLATFORMS, RELOADABLE_PLATFORMS, + TEMPLATE_ERRORS, ) from .models import ( # noqa: F401 MqttCommandTemplate, MqttData, MqttValueTemplate, + PayloadSentinel, PublishPayloadType, ReceiveMessage, ReceivePayloadType, @@ -325,7 +325,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: msg_topic_template, hass ).async_render(parse_result=False) msg_topic = valid_publish_topic(rendered_topic) - except (jinja2.TemplateError, TemplateError) as exc: + except TEMPLATE_ERRORS as exc: _LOGGER.error( ( "Unable to publish: rendering topic template of %s " @@ -352,7 +352,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: payload = MqttCommandTemplate( template.Template(payload_template), hass=hass ).async_render() - except (jinja2.TemplateError, TemplateError) as exc: + except TEMPLATE_ERRORS as exc: _LOGGER.error( ( "Unable to publish to %s: rendering payload template of " @@ -544,7 +544,7 @@ async def websocket_subscribe( ) # Perform UTF-8 decoding directly in callback routine - qos: int = msg["qos"] if "qos" in msg else DEFAULT_QOS + qos: int = msg.get("qos", DEFAULT_QOS) connection.subscriptions[msg["id"]] = await async_subscribe( hass, msg["topic"], forward_messages, encoding=None, qos=qos ) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 2f6c6dc648c..ace3cf9fd64 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -921,7 +921,7 @@ class MQTT: try: async with asyncio.timeout(TIMEOUT_ACK): await self._pending_operations[mid].wait() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning( "No ACK from MQTT server in %s seconds (mid: %s)", TIMEOUT_ACK, mid ) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 94311eeda61..4e85163767c 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -44,7 +44,6 @@ 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 from homeassistant.util.unit_conversion import TemperatureConverter @@ -77,7 +76,6 @@ from .const import ( CONF_TEMP_STATE_TEMPLATE, CONF_TEMP_STATE_TOPIC, DEFAULT_OPTIMISTIC, - DOMAIN, PAYLOAD_NONE, ) from .debug_info import log_messages @@ -98,13 +96,11 @@ from .util import 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 +# Support was 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" @@ -150,7 +146,6 @@ DEFAULT_INITIAL_TEMPERATURE = 21.0 MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset( { - climate.ATTR_AUX_HEAT, climate.ATTR_CURRENT_HUMIDITY, climate.ATTR_CURRENT_TEMPERATURE, climate.ATTR_FAN_MODE, @@ -174,13 +169,11 @@ MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset( ) VALUE_TEMPLATE_KEYS = ( - CONF_AUX_STATE_TEMPLATE, CONF_CURRENT_HUMIDITY_TEMPLATE, CONF_CURRENT_TEMP_TEMPLATE, CONF_FAN_MODE_STATE_TEMPLATE, CONF_HUMIDITY_STATE_TEMPLATE, CONF_MODE_STATE_TEMPLATE, - CONF_POWER_STATE_TEMPLATE, CONF_ACTION_TEMPLATE, CONF_PRESET_MODE_VALUE_TEMPLATE, CONF_SWING_MODE_STATE_TEMPLATE, @@ -204,8 +197,6 @@ COMMAND_TEMPLATE_KEYS = { TOPIC_KEYS = ( CONF_ACTION_TOPIC, - CONF_AUX_COMMAND_TOPIC, - CONF_AUX_STATE_TOPIC, CONF_CURRENT_HUMIDITY_TOPIC, CONF_CURRENT_TEMP_TOPIC, CONF_FAN_MODE_COMMAND_TOPIC, @@ -266,12 +257,6 @@ 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, vol.Optional(CONF_CURRENT_HUMIDITY_TEMPLATE): cv.template, vol.Optional(CONF_CURRENT_HUMIDITY_TOPIC): valid_subscribe_topic, vol.Optional(CONF_CURRENT_TEMP_TEMPLATE): cv.template, @@ -369,10 +354,10 @@ PLATFORM_SCHEMA_MODERN = vol.All( 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), + # Support was removed in HA Core 2024.3 + cv.removed(CONF_AUX_COMMAND_TOPIC), + cv.removed(CONF_AUX_STATE_TEMPLATE), + cv.removed(CONF_AUX_STATE_TOPIC), _PLATFORM_SCHEMA_BASE, valid_preset_mode_configuration, valid_humidity_range_configuration, @@ -603,7 +588,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): _attr_fan_mode: str | None = None _attr_hvac_mode: HVACMode | None = None - _attr_is_aux_heat: bool | None = None _attr_swing_mode: str | None = None _default_name = DEFAULT_NAME _entity_id_format = climate.ENTITY_ID_FORMAT @@ -662,11 +646,6 @@ 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 if self._feature_preset_mode: presets = [] @@ -736,32 +715,8 @@ 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]] = {} @@ -875,41 +830,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): topics, CONF_SWING_MODE_STATE_TOPIC, handle_swing_mode_received ) - @callback - def handle_onoff_mode_received( - msg: ReceiveMessage, template_name: str, attr: str - ) -> None: - """Handle receiving on/off mode via MQTT.""" - payload = self.render_template(msg, template_name) - payload_on: str = self._config[CONF_PAYLOAD_ON] - payload_off: str = self._config[CONF_PAYLOAD_OFF] - - if payload == "True": - payload = payload_on - elif payload == "False": - payload = payload_off - - if payload == payload_on: - setattr(self, attr, True) - elif payload == payload_off: - setattr(self, attr, False) - else: - _LOGGER.error("Invalid %s mode: %s", attr, payload) - - # 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) - @write_state_on_attr_change(self, {"_attr_is_aux_heat"}) - def handle_aux_mode_received(msg: ReceiveMessage) -> None: - """Handle receiving aux mode via MQTT.""" - handle_onoff_mode_received( - msg, CONF_AUX_STATE_TEMPLATE, "_attr_is_aux_heat" - ) - - self.add_subscription(topics, CONF_AUX_STATE_TOPIC, handle_aux_mode_received) - @callback @log_messages(self.hass, self.entity_id) @write_state_on_attr_change(self, {"_attr_preset_mode"}) @@ -1002,27 +922,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): self._attr_preset_mode = preset_mode self.async_write_ha_state() - # 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, - self._config[CONF_PAYLOAD_ON] if state else self._config[CONF_PAYLOAD_OFF], - ) - - if self._optimistic or self._topic[CONF_AUX_STATE_TOPIC] is None: - self._attr_is_aux_heat = state - self.async_write_ha_state() - - async def async_turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - await self._set_aux_heat(True) - - async def async_turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - await self._set_aux_heat(False) - async def async_turn_on(self) -> None: """Turn the entity on.""" if CONF_POWER_COMMAND_TOPIC in self._config: diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index fba2f13937e..7f97910961d 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -1,6 +1,9 @@ """Constants used by multiple MQTT modules.""" +import jinja2 + from homeassistant.const import CONF_PAYLOAD, Platform +from homeassistant.exceptions import TemplateError ATTR_DISCOVERY_HASH = "discovery_hash" ATTR_DISCOVERY_PAYLOAD = "discovery_payload" @@ -194,3 +197,5 @@ RELOADABLE_PLATFORMS = [ Platform.VALVE, Platform.WATER_HEATER, ] + +TEMPLATE_ERRORS = (jinja2.TemplateError, TemplateError, TypeError, ValueError) diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index 351eb422edc..c245b66fdb1 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -29,6 +29,7 @@ from .const import ( CONF_STATE_TOPIC, PAYLOAD_EMPTY_JSON, PAYLOAD_NONE, + TEMPLATE_ERRORS, ) from .debug_info import log_messages from .mixins import ( @@ -131,7 +132,10 @@ class MqttEvent(MqttEntity, EventEntity): return event_attributes: dict[str, Any] = {} event_type: str - payload = self._template(msg.payload, PayloadSentinel.DEFAULT) + try: + payload = self._template(msg.payload, PayloadSentinel.DEFAULT) + except TEMPLATE_ERRORS: + return if ( not payload or payload is PayloadSentinel.DEFAULT diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index 1f90f0fdb3d..e91a8c5c259 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -24,7 +24,7 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_ENCODING, CONF_QOS +from .const import CONF_ENCODING, CONF_QOS, TEMPLATE_ERRORS from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, @@ -188,10 +188,11 @@ class MqttImage(MqttEntity, ImageEntity): @log_messages(self.hass, self.entity_id) def image_from_url_request_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" - try: url = cv.url(self._url_template(msg.payload)) self._attr_image_url = url + except TEMPLATE_ERRORS: + return except vol.Invalid: _LOGGER.error( "Invalid image URL '%s' received at topic %s", diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 4c7837a7a2b..c4529d18451 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -94,6 +94,7 @@ from .const import ( DOMAIN, MQTT_CONNECTED, MQTT_DISCONNECTED, + TEMPLATE_ERRORS, ) from .debug_info import log_message, log_messages from .discovery import ( @@ -110,7 +111,6 @@ from .models import ( MqttValueTemplate, PublishPayloadType, ReceiveMessage, - ReceivePayloadType, ) from .subscription import ( EntitySubscription, @@ -480,7 +480,10 @@ def write_state_on_attr_change( attribute: getattr(entity, attribute, UNDEFINED) for attribute in attributes } - msg_callback(msg) + try: + msg_callback(msg) + except TEMPLATE_ERRORS: + return if not _attrs_have_changed(tracked_attrs): return @@ -527,8 +530,9 @@ class MqttAttributes(Entity): @log_messages(self.hass, self.entity_id) @write_state_on_attr_change(self, {"_attr_extra_state_attributes"}) def attributes_message_received(msg: ReceiveMessage) -> None: + """Update extra state attributes.""" + payload = attr_tpl(msg.payload) try: - payload = attr_tpl(msg.payload) json_dict = json_loads(payload) if isinstance(payload, str) else None if isinstance(json_dict, dict): filtered_dict = { @@ -636,7 +640,6 @@ class MqttAvailability(Entity): def availability_message_received(msg: ReceiveMessage) -> None: """Handle a new received MQTT availability message.""" topic = msg.topic - payload: ReceivePayloadType payload = self._avail_topics[topic][CONF_AVAILABILITY_TEMPLATE](msg.payload) if payload == self._avail_topics[topic][CONF_PAYLOAD_AVAILABLE]: self._available[topic] = True @@ -646,8 +649,7 @@ class MqttAvailability(Entity): self._available_latest = False self._available = { - topic: (self._available[topic] if topic in self._available else False) - for topic in self._avail_topics + topic: (self._available.get(topic, False)) for topic in self._avail_topics } topics: dict[str, dict[str, Any]] = { f"availability_{topic}": { diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 0d009cf356b..8b1f21c2775 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -29,6 +29,8 @@ if TYPE_CHECKING: from .discovery import MQTTDiscoveryPayload from .tag import MQTTTagScanner +from .const import TEMPLATE_ERRORS + class PayloadSentinel(StrEnum): """Sentinel for `async_render_with_possible_json_value`.""" @@ -247,7 +249,7 @@ class MqttValueTemplate: payload, variables=values ) ) - except Exception as exc: + except TEMPLATE_ERRORS as exc: _LOGGER.error( "%s: %s rendering template for entity '%s', template: '%s'", type(exc).__name__, @@ -255,7 +257,7 @@ class MqttValueTemplate: self._entity.entity_id if self._entity else "n/a", self._value_template.template, ) - raise exc + raise return rendered_payload _LOGGER.debug( @@ -274,18 +276,18 @@ class MqttValueTemplate: payload, default, variables=values ) ) - except Exception as ex: + except TEMPLATE_ERRORS as exc: _LOGGER.error( "%s: %s rendering template for entity '%s', template: " "'%s', default value: %s and payload: %s", - type(ex).__name__, - ex, + type(exc).__name__, + exc, self._entity.entity_id if self._entity else "n/a", self._value_template.template, default, payload, ) - raise ex + raise return rendered_payload diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index ce892e97026..2c3a87a515b 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -4,10 +4,6 @@ "title": "MQTT vacuum entities with deprecated `schema` config settings found in your configuration.yaml", "description": "The `schema` setting for MQTT vacuum entities is deprecated and should be removed. Please adjust your configuration.yaml and restart Home Assistant to fix this issue." }, - "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 deprecated config options from your configuration and restart Home Assistant to fix this issue." - }, "invalid_platform_config": { "title": "Invalid config found for mqtt {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index 80a717b1f37..0eda584e95a 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -15,7 +15,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import ATTR_DISCOVERY_HASH, CONF_QOS, CONF_TOPIC +from .const import ATTR_DISCOVERY_HASH, CONF_QOS, CONF_TOPIC, TEMPLATE_ERRORS from .discovery import MQTTDiscoveryPayload from .mixins import ( MQTT_ENTITY_DEVICE_INFO_SCHEMA, @@ -136,7 +136,10 @@ class MQTTTagScanner(MqttDiscoveryDeviceUpdate): """Subscribe to MQTT topics.""" async def tag_scanned(msg: ReceiveMessage) -> None: - tag_id = str(self._value_template(msg.payload, "")).strip() + try: + tag_id = str(self._value_template(msg.payload, "")).strip() + except TEMPLATE_ERRORS: + return if not tag_id: # No output from template, ignore return diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index f478ad712d7..fb47bbfc667 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -74,7 +74,7 @@ async def async_wait_for_mqtt_client(hass: HomeAssistant) -> bool: async with asyncio.timeout(AVAILABILITY_TIMEOUT): # Await the client setup or an error state was received return await state_reached_future - except asyncio.TimeoutError: + except TimeoutError: return False diff --git a/homeassistant/components/mutesync/config_flow.py b/homeassistant/components/mutesync/config_flow.py index e06c0b07c87..21bbcfe69bb 100644 --- a/homeassistant/components/mutesync/config_flow.py +++ b/homeassistant/components/mutesync/config_flow.py @@ -32,7 +32,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, if error.status == 403: raise InvalidAuth from error raise CannotConnect from error - except (aiohttp.ClientError, asyncio.TimeoutError) as error: + except (aiohttp.ClientError, TimeoutError) as error: raise CannotConnect from error return token diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index 0818d68de2b..28cacbe7762 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -109,7 +109,7 @@ async def try_connect( async with asyncio.timeout(GATEWAY_READY_TIMEOUT): await gateway_ready.wait() return True - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.info("Try gateway connect failed with timeout") return False finally: @@ -301,7 +301,7 @@ async def _gw_start( try: async with asyncio.timeout(GATEWAY_READY_TIMEOUT): await gateway_ready.wait() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning( "Gateway %s not connected after %s secs so continuing with setup", entry.data[CONF_DEVICE], diff --git a/homeassistant/components/myuplink/__init__.py b/homeassistant/components/myuplink/__init__.py index 15ae1eb75c2..ec98fdf47c0 100644 --- a/homeassistant/components/myuplink/__init__.py +++ b/homeassistant/components/myuplink/__init__.py @@ -1,7 +1,7 @@ """The myUplink integration.""" from __future__ import annotations -from myuplink.api import MyUplinkAPI +from myuplink import MyUplinkAPI from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -16,7 +16,11 @@ from .api import AsyncConfigEntryAuth from .const import DOMAIN from .coordinator import MyUplinkDataCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.UPDATE, +] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/myuplink/api.py b/homeassistant/components/myuplink/api.py index 5d0fcaf521a..1b74d41bc97 100644 --- a/homeassistant/components/myuplink/api.py +++ b/homeassistant/components/myuplink/api.py @@ -11,7 +11,7 @@ from homeassistant.helpers import config_entry_oauth2_flow from .const import API_ENDPOINT -class AsyncConfigEntryAuth(AbstractAuth): # type: ignore[misc] +class AsyncConfigEntryAuth(AbstractAuth): """Provide myUplink authentication tied to an OAuth2 based config entry.""" def __init__( diff --git a/homeassistant/components/myuplink/binary_sensor.py b/homeassistant/components/myuplink/binary_sensor.py new file mode 100644 index 00000000000..b5ade88a002 --- /dev/null +++ b/homeassistant/components/myuplink/binary_sensor.py @@ -0,0 +1,100 @@ +"""Binary sensors for myUplink.""" + +from myuplink import DevicePoint + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import MyUplinkDataCoordinator +from .const import DOMAIN +from .entity import MyUplinkEntity +from .helpers import find_matching_platform + +CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, BinarySensorEntityDescription]] = { + "NIBEF": { + "43161": BinarySensorEntityDescription( + key="elect_add", + icon="mdi:electric-switch", + ), + }, +} + + +def get_description(device_point: DevicePoint) -> BinarySensorEntityDescription | None: + """Get description for a device point. + + Priorities: + 1. Category specific prefix e.g "NIBEF" + 2. Default to None + """ + prefix, _, _ = device_point.category.partition(" ") + description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get( + device_point.parameter_id + ) + + return description + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up myUplink binary_sensor.""" + entities: list[BinarySensorEntity] = [] + coordinator: MyUplinkDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + # Setup device point sensors + for device_id, point_data in coordinator.data.points.items(): + for point_id, device_point in point_data.items(): + if find_matching_platform(device_point) == Platform.BINARY_SENSOR: + description = get_description(device_point) + + entities.append( + MyUplinkDevicePointBinarySensor( + coordinator=coordinator, + device_id=device_id, + device_point=device_point, + entity_description=description, + unique_id_suffix=point_id, + ) + ) + async_add_entities(entities) + + +class MyUplinkDevicePointBinarySensor(MyUplinkEntity, BinarySensorEntity): + """Representation of a myUplink device point binary sensor.""" + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + device_id: str, + device_point: DevicePoint, + entity_description: BinarySensorEntityDescription | None, + unique_id_suffix: str, + ) -> None: + """Initialize the binary_sensor.""" + super().__init__( + coordinator=coordinator, + device_id=device_id, + unique_id_suffix=unique_id_suffix, + ) + + # Internal properties + self.point_id = device_point.parameter_id + self._attr_name = device_point.parameter_name + + if entity_description is not None: + self.entity_description = entity_description + + @property + def is_on(self) -> bool: + """Binary sensor state value.""" + device_point = self.coordinator.data.points[self.device_id][self.point_id] + return int(device_point.value) != 0 diff --git a/homeassistant/components/myuplink/coordinator.py b/homeassistant/components/myuplink/coordinator.py index 4cd66adab2b..03a902fc4bb 100644 --- a/homeassistant/components/myuplink/coordinator.py +++ b/homeassistant/components/myuplink/coordinator.py @@ -4,8 +4,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta import logging -from myuplink.api import MyUplinkAPI -from myuplink.models import Device, DevicePoint, System +from myuplink import Device, DevicePoint, MyUplinkAPI, System from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator diff --git a/homeassistant/components/myuplink/diagnostics.py b/homeassistant/components/myuplink/diagnostics.py new file mode 100644 index 00000000000..55cbb07c0d0 --- /dev/null +++ b/homeassistant/components/myuplink/diagnostics.py @@ -0,0 +1,46 @@ +"""Diagnostics support for myUplink.""" +from __future__ import annotations + +from typing import Any + +from myuplink import MyUplinkAPI + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = {"access_token", "refresh_token", "serialNumber"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry. + + Pick up fresh data from API and dump it. + """ + api: MyUplinkAPI = hass.data[DOMAIN][config_entry.entry_id].api + myuplink_data = {} + myuplink_data["my_systems"] = await api.async_get_systems_json() + myuplink_data["my_systems"]["devices"] = [] + for system in myuplink_data["my_systems"]["systems"]: + for device in system["devices"]: + device_data = await api.async_get_device_json(device["id"]) + device_points = await api.async_get_device_points_json(device["id"]) + myuplink_data["my_systems"]["devices"].append( + { + system["systemId"]: { + "device_data": device_data, + "points": device_points, + } + } + ) + + diagnostics_data = { + "config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT), + "myuplink_data": async_redact_data(myuplink_data, TO_REDACT), + } + + return diagnostics_data diff --git a/homeassistant/components/myuplink/helpers.py b/homeassistant/components/myuplink/helpers.py new file mode 100644 index 00000000000..86fbab52cae --- /dev/null +++ b/homeassistant/components/myuplink/helpers.py @@ -0,0 +1,21 @@ +"""Helper collection for myuplink.""" + +from myuplink import DevicePoint + +from homeassistant.const import Platform + + +def find_matching_platform(device_point: DevicePoint) -> Platform: + """Find entity platform for a DevicePoint.""" + if ( + len(device_point.enum_values) == 2 + and device_point.enum_values[0]["value"] == "0" + and device_point.enum_values[1]["value"] == "1" + ): + if device_point.writable: + # Change to Platform.SWITCH when platform is implemented + # return Platform.SWITCH + return Platform.SENSOR + return Platform.BINARY_SENSOR + + return Platform.SENSOR diff --git a/homeassistant/components/myuplink/manifest.json b/homeassistant/components/myuplink/manifest.json index 303af547335..0a1d005ad08 100644 --- a/homeassistant/components/myuplink/manifest.json +++ b/homeassistant/components/myuplink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/myuplink", "iot_class": "cloud_polling", - "requirements": ["myuplink==0.0.9"] + "requirements": ["myuplink==0.2.1"] } diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py index 31cb6715e0c..752fab13448 100644 --- a/homeassistant/components/myuplink/sensor.py +++ b/homeassistant/components/myuplink/sensor.py @@ -1,6 +1,6 @@ """Sensor for myUplink.""" -from myuplink.models import DevicePoint +from myuplink import DevicePoint from homeassistant.components.sensor import ( SensorDeviceClass, @@ -9,7 +9,17 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTemperature +from homeassistant.const import ( + Platform, + UnitOfElectricCurrent, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfPressure, + UnitOfTemperature, + UnitOfTime, + UnitOfVolumeFlowRate, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -17,16 +27,120 @@ from homeassistant.helpers.typing import StateType from . import MyUplinkDataCoordinator from .const import DOMAIN from .entity import MyUplinkEntity +from .helpers import find_matching_platform -DEVICE_POINT_DESCRIPTIONS = { +DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, SensorEntityDescription] = { "°C": SensorEntityDescription( key="celsius", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), + "°F": SensorEntityDescription( + key="fahrenheit", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + ), + "A": SensorEntityDescription( + key="ampere", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + ), + "bar": SensorEntityDescription( + key="pressure", + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + ), + "h": SensorEntityDescription( + key="hours", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.HOURS, + suggested_display_precision=1, + ), + "Hz": SensorEntityDescription( + key="hertz", + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfFrequency.HERTZ, + ), + "kW": SensorEntityDescription( + key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + ), + "kWh": SensorEntityDescription( + key="energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ), + "m3/h": SensorEntityDescription( + key="airflow", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + icon="mdi:weather-windy", + ), + "s": SensorEntityDescription( + key="seconds", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_display_precision=0, + ), } +MARKER_FOR_UNKNOWN_VALUE = -32768 + +CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, SensorEntityDescription]] = { + "NIBEF": { + "43108": SensorEntityDescription( + key="fan_mode", + icon="mdi:fan", + ), + "43427": SensorEntityDescription( + key="status_compressor", + device_class=SensorDeviceClass.ENUM, + icon="mdi:heat-pump-outline", + ), + "49993": SensorEntityDescription( + key="elect_add", + device_class=SensorDeviceClass.ENUM, + icon="mdi:heat-wave", + ), + "49994": SensorEntityDescription( + key="priority", + device_class=SensorDeviceClass.ENUM, + icon="mdi:priority-high", + ), + }, + "NIBE": {}, +} + + +def get_description(device_point: DevicePoint) -> SensorEntityDescription | None: + """Get description for a device point. + + Priorities: + 1. Category specific prefix e.g "NIBEF" + 2. Global parameter_unit e.g. "°C" + 3. Default to None + """ + description = None + prefix, _, _ = device_point.category.partition(" ") + description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get( + device_point.parameter_id + ) + if description is None: + description = DEVICE_POINT_UNIT_DESCRIPTIONS.get(device_point.parameter_unit) + + return description + async def async_setup_entry( hass: HomeAssistant, @@ -34,23 +148,40 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up myUplink sensor.""" + entities: list[SensorEntity] = [] coordinator: MyUplinkDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] # Setup device point sensors for device_id, point_data in coordinator.data.points.items(): for point_id, device_point in point_data.items(): - entities.append( - MyUplinkDevicePointSensor( - coordinator=coordinator, - device_id=device_id, - device_point=device_point, - entity_description=DEVICE_POINT_DESCRIPTIONS.get( - device_point.parameter_unit - ), - unique_id_suffix=point_id, + if find_matching_platform(device_point) == Platform.SENSOR: + description = get_description(device_point) + entity_class = MyUplinkDevicePointSensor + if ( + description is not None + and description.device_class == SensorDeviceClass.ENUM + ): + entities.append( + MyUplinkEnumRawSensor( + coordinator=coordinator, + device_id=device_id, + device_point=device_point, + entity_description=description, + unique_id_suffix=f"{point_id}-raw", + ) + ) + entity_class = MyUplinkEnumSensor + + entities.append( + entity_class( + coordinator=coordinator, + device_id=device_id, + device_point=device_point, + entity_description=description, + unique_id_suffix=point_id, + ) ) - ) async_add_entities(entities) @@ -75,7 +206,7 @@ class MyUplinkDevicePointSensor(MyUplinkEntity, SensorEntity): # Internal properties self.point_id = device_point.parameter_id - self._attr_name = device_point.parameter_name + self._attr_name = device_point.parameter_name.replace("\u002d", "") if entity_description is not None: self.entity_description = entity_description @@ -86,4 +217,64 @@ class MyUplinkDevicePointSensor(MyUplinkEntity, SensorEntity): def native_value(self) -> StateType: """Sensor state value.""" device_point = self.coordinator.data.points[self.device_id][self.point_id] + if device_point.value == MARKER_FOR_UNKNOWN_VALUE: + return None return device_point.value # type: ignore[no-any-return] + + +class MyUplinkEnumSensor(MyUplinkDevicePointSensor): + """Representation of a myUplink device point sensor for ENUM device_class.""" + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + device_id: str, + device_point: DevicePoint, + entity_description: SensorEntityDescription | None, + unique_id_suffix: str, + ) -> None: + """Initialize the sensor.""" + super().__init__( + coordinator=coordinator, + device_id=device_id, + device_point=device_point, + entity_description=entity_description, + unique_id_suffix=unique_id_suffix, + ) + + self._attr_options = [x["text"].capitalize() for x in device_point.enum_values] + self.options_map = { + x["value"]: x["text"].capitalize() for x in device_point.enum_values + } + + @property + def native_value(self) -> str: + """Sensor state value for enum sensor.""" + device_point = self.coordinator.data.points[self.device_id][self.point_id] + return self.options_map[str(int(device_point.value))] # type: ignore[no-any-return] + + +class MyUplinkEnumRawSensor(MyUplinkDevicePointSensor): + """Representation of a myUplink device point sensor for raw value from ENUM device_class.""" + + _attr_entity_registry_enabled_default = False + _attr_device_class = None + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + device_id: str, + device_point: DevicePoint, + entity_description: SensorEntityDescription | None, + unique_id_suffix: str, + ) -> None: + """Initialize the sensor.""" + super().__init__( + coordinator=coordinator, + device_id=device_id, + device_point=device_point, + entity_description=entity_description, + unique_id_suffix=unique_id_suffix, + ) + + self._attr_name = f"{device_point.parameter_name} raw" diff --git a/homeassistant/components/myuplink/update.py b/homeassistant/components/myuplink/update.py new file mode 100644 index 00000000000..2b779e83386 --- /dev/null +++ b/homeassistant/components/myuplink/update.py @@ -0,0 +1,72 @@ +"""Update entity for myUplink.""" + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import MyUplinkDataCoordinator +from .const import DOMAIN +from .entity import MyUplinkEntity + +UPDATE_DESCRIPTION = UpdateEntityDescription( + key="update", + device_class=UpdateDeviceClass.FIRMWARE, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up update entity.""" + entities: list[UpdateEntity] = [] + coordinator: MyUplinkDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + # Setup update entities + for device_id in coordinator.data.devices: + entities.append( + MyUplinkDeviceUpdate( + coordinator=coordinator, + device_id=device_id, + entity_description=UPDATE_DESCRIPTION, + unique_id_suffix="upd", + ) + ) + + async_add_entities(entities) + + +class MyUplinkDeviceUpdate(MyUplinkEntity, UpdateEntity): + """Representation of a myUplink device update entity.""" + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + device_id: str, + entity_description: UpdateEntityDescription, + unique_id_suffix: str, + ) -> None: + """Initialize the update entity.""" + super().__init__( + coordinator=coordinator, + device_id=device_id, + unique_id_suffix=unique_id_suffix, + ) + + self.entity_description = entity_description + + @property + def installed_version(self) -> str | None: + """Return installed_version.""" + return self.coordinator.data.devices[self.device_id].firmwareCurrent + + @property + def latest_version(self) -> str | None: + """Return latest_version.""" + return self.coordinator.data.devices[self.device_id].firmwareDesired diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 28f9c282a73..9df1b93a4d7 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -49,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options = ConnectionOptions(host=host, username=username, password=password) try: nam = await NettigoAirMonitor.create(websession, options) - except (ApiError, ClientError, ClientConnectorError, asyncio.TimeoutError) as err: + except (ApiError, ClientError, ClientConnectorError, TimeoutError) as err: raise ConfigEntryNotReady from err try: diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index 7eee84a66a4..8f44c28df3a 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -92,7 +92,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: config = await async_get_config(self.hass, self.host) - except (ApiError, ClientConnectorError, asyncio.TimeoutError): + except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" except CannotGetMacError: return self.async_abort(reason="device_unsupported") @@ -128,7 +128,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await async_check_credentials(self.hass, self.host, user_input) except AuthFailedError: errors["base"] = "invalid_auth" - except (ApiError, ClientConnectorError, asyncio.TimeoutError): + except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") @@ -155,7 +155,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: self._config = await async_get_config(self.hass, self.host) - except (ApiError, ClientConnectorError, asyncio.TimeoutError): + except (ApiError, ClientConnectorError, TimeoutError): return self.async_abort(reason="cannot_connect") except CannotGetMacError: return self.async_abort(reason="device_unsupported") @@ -209,7 +209,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ApiError, AuthFailedError, ClientConnectorError, - asyncio.TimeoutError, + TimeoutError, ): return self.async_abort(reason="reauth_unsuccessful") diff --git a/homeassistant/components/nam/icons.json b/homeassistant/components/nam/icons.json new file mode 100644 index 00000000000..5e55bf145e5 --- /dev/null +++ b/homeassistant/components/nam/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "pmsx003_caqi": { + "default": "mdi:air-filter" + }, + "pmsx003_caqi_level": { + "default": "mdi:air-filter" + }, + "sds011_caqi": { + "default": "mdi:air-filter" + }, + "sds011_caqi_level": { + "default": "mdi:air-filter" + }, + "sps30_caqi": { + "default": "mdi:air-filter" + }, + "sps30_caqi_level": { + "default": "mdi:air-filter" + }, + "sps30_pm4": { + "default": "mdi:molecule" + } + } + } +} diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 5b3c6517f64..cd1543affa2 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -180,13 +180,11 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( NAMSensorEntityDescription( key=ATTR_PMSX003_CAQI, translation_key="pmsx003_caqi", - icon="mdi:air-filter", value=lambda sensors: sensors.pms_caqi, ), NAMSensorEntityDescription( key=ATTR_PMSX003_CAQI_LEVEL, translation_key="pmsx003_caqi_level", - icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, options=["very_low", "low", "medium", "high", "very_high"], value=lambda sensors: sensors.pms_caqi_level, @@ -221,13 +219,11 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( NAMSensorEntityDescription( key=ATTR_SDS011_CAQI, translation_key="sds011_caqi", - icon="mdi:air-filter", value=lambda sensors: sensors.sds011_caqi, ), NAMSensorEntityDescription( key=ATTR_SDS011_CAQI_LEVEL, translation_key="sds011_caqi_level", - icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, options=["very_low", "low", "medium", "high", "very_high"], value=lambda sensors: sensors.sds011_caqi_level, @@ -271,13 +267,11 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( NAMSensorEntityDescription( key=ATTR_SPS30_CAQI, translation_key="sps30_caqi", - icon="mdi:air-filter", value=lambda sensors: sensors.sps30_caqi, ), NAMSensorEntityDescription( key=ATTR_SPS30_CAQI_LEVEL, translation_key="sps30_caqi_level", - icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, options=["very_low", "low", "medium", "high", "very_high"], value=lambda sensors: sensors.sps30_caqi_level, @@ -314,7 +308,6 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( translation_key="sps30_pm4", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - icon="mdi:molecule", state_class=SensorStateClass.MEASUREMENT, value=lambda sensors: sensors.sps30_p4, ), diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index bfc77a09548..42d4ced6792 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -1,7 +1,6 @@ """The Netatmo data handler.""" from __future__ import annotations -import asyncio from collections import deque from dataclasses import dataclass from datetime import datetime, timedelta @@ -239,7 +238,7 @@ class NetatmoDataHandler: _LOGGER.debug(err) has_error = True - except (asyncio.TimeoutError, aiohttp.ClientConnectorError) as err: + except (TimeoutError, aiohttp.ClientConnectorError) as err: _LOGGER.debug(err) return True diff --git a/homeassistant/components/nexia/__init__.py b/homeassistant/components/nexia/__init__.py index 0644de58ee7..f1954eb50b8 100644 --- a/homeassistant/components/nexia/__init__.py +++ b/homeassistant/components/nexia/__init__.py @@ -1,5 +1,4 @@ """Support for Nexia / Trane XL Thermostats.""" -import asyncio import logging import aiohttp @@ -45,7 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await nexia_home.login() - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise ConfigEntryNotReady( f"Timed out trying to connect to Nexia service: {ex}" ) from ex diff --git a/homeassistant/components/nexia/config_flow.py b/homeassistant/components/nexia/config_flow.py index de5640beef7..46dc1454a2a 100644 --- a/homeassistant/components/nexia/config_flow.py +++ b/homeassistant/components/nexia/config_flow.py @@ -1,5 +1,4 @@ """Config flow for Nexia integration.""" -import asyncio import logging import aiohttp @@ -57,7 +56,7 @@ async def validate_input(hass: core.HomeAssistant, data): ) try: await nexia_home.login() - except asyncio.TimeoutError as ex: + except TimeoutError as ex: _LOGGER.error("Unable to connect to Nexia service: %s", ex) raise CannotConnect from ex except aiohttp.ClientResponseError as http_ex: diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index ca59c7d0e3a..af972fb7509 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -163,7 +163,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: async with asyncio.timeout(10): nextdns = await NextDns.create(websession, api_key) - except (ApiError, ClientConnectorError, asyncio.TimeoutError) as err: + except (ApiError, ClientConnectorError, TimeoutError) as err: raise ConfigEntryNotReady from err tasks = [] diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py index c502f788a86..b0a1d936752 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -43,7 +43,7 @@ class NextDnsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) except InvalidApiKeyError: errors["base"] = "invalid_api_key" - except (ApiError, ClientConnectorError, asyncio.TimeoutError): + except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except errors["base"] = "unknown" diff --git a/homeassistant/components/nextdns/icons.json b/homeassistant/components/nextdns/icons.json new file mode 100644 index 00000000000..b62629d3dc9 --- /dev/null +++ b/homeassistant/components/nextdns/icons.json @@ -0,0 +1,266 @@ +{ + "entity": { + "sensor": { + "all_queries": { + "default": "mdi:dns" + }, + "blocked_queries": { + "default": "mdi:dns" + }, + "blocked_queries_ratio": { + "default": "mdi:dns" + }, + "doh_queries": { + "default": "mdi:dns" + }, + "doh_queries_ratio": { + "default": "mdi:dns" + }, + "doh3_queries": { + "default": "mdi:dns" + }, + "doh3_queries_ratio": { + "default": "mdi:dns" + }, + "doq_queries": { + "default": "mdi:dns" + }, + "doq_queries_ratio": { + "default": "mdi:dns" + }, + "dot_queries": { + "default": "mdi:dns" + }, + "dot_queries_ratio": { + "default": "mdi:dns" + }, + "encrypted_queries": { + "default": "mdi:lock" + }, + "encrypted_queries_ratio": { + "default": "mdi:lock" + }, + "ipv4_queries": { + "default": "mdi:ip" + }, + "ipv6_queries": { + "default": "mdi:ip" + }, + "ipv6_queries_ratio": { + "default": "mdi:ip" + }, + "relayed_queries": { + "default": "mdi:dns" + }, + "not_validated_queries": { + "default": "mdi:lock-alert" + }, + "tcp_queries": { + "default": "mdi:dns" + }, + "tcp_queries_ratio": { + "default": "mdi:dns" + }, + "udp_queries": { + "default": "mdi:dns" + }, + "udp_queries_ratio": { + "default": "mdi:dns" + }, + "unencrypted_queries": { + "default": "mdi:lock-open" + }, + "validated_queries": { + "default": "mdi:lock-check" + }, + "validated_queries_ratio": { + "default": "mdi:lock-check" + } + }, + "switch": { + "block_page": { + "default": "mdi:web-cancel" + }, + "cache_boost": { + "default": "mdi:memory" + }, + "cname_flattening": { + "default": "mdi:tournament" + }, + "anonymized_ecs": { + "default": "mdi:incognito" + }, + "logs": { + "default": "mdi:file-document-outline" + }, + "web3": { + "default": "mdi:web" + }, + "dns_rebinding_protection": { + "default": "mdi:dns" + }, + "google_safe_browsing": { + "default": "mdi:google" + }, + "typosquatting_protection": { + "default": "mdi:keyboard-outline" + }, + "safesearch": { + "default": "mdi:search-web" + }, + "youtube_restricted_mode": { + "default": "mdi:youtube" + }, + "block_9gag": { + "default": "mdi:file-gif-box" + }, + "block_amazon": { + "default": "mdi:cart-outline" + }, + "block_bereal": { + "default": "mdi:alpha-b-box" + }, + "block_blizzard": { + "default": "mdi:sword-cross" + }, + "block_chatgpt": { + "default": "mdi:chat-processing-outline" + }, + "block_dailymotion": { + "default": "mdi:movie-search-outline" + }, + "block_discord": { + "default": "mdi:message-text" + }, + "block_disneyplus": { + "default": "mdi:movie-search-outline" + }, + "block_ebay": { + "default": "mdi:basket-outline" + }, + "block_facebook": { + "default": "mdi:facebook" + }, + "block_fortnite": { + "default": "mdi:tank" + }, + "block_google_chat": { + "default": "mdi:forum" + }, + "block_hbomax": { + "default": "mdi:movie-search-outline" + }, + "block_hulu": { + "default": "mdi:hulu" + }, + "block_imgur": { + "default": "mdi:camera-image" + }, + "block_instagram": { + "default": "mdi:instagram" + }, + "block_leagueoflegends": { + "default": "mdi:sword" + }, + "block_mastodon": { + "default": "mdi:mastodon" + }, + "block_messenger": { + "default": "mdi:facebook-messenger" + }, + "block_minecraft": { + "default": "mdi:minecraft" + }, + "block_netflix": { + "default": "mdi:netflix" + }, + "block_pinterest": { + "default": "mdi:pinterest" + }, + "block_playstation_network": { + "default": "mdi:sony-playstation" + }, + "block_primevideo": { + "default": "mdi:filmstrip" + }, + "block_reddit": { + "default": "mdi:reddit" + }, + "block_roblox": { + "default": "mdi:robot" + }, + "block_signal": { + "default": "mdi:chat-outline" + }, + "block_skype": { + "default": "mdi:skype" + }, + "block_snapchat": { + "default": "mdi:snapchat" + }, + "block_spotify": { + "default": "mdi:spotify" + }, + "block_steam": { + "default": "mdi:steam" + }, + "block_telegram": { + "default": "mdi:send-outline" + }, + "block_tiktok": { + "default": "mdi:music-note" + }, + "block_tinder": { + "default": "mdi:fire" + }, + "block_tumblr": { + "default": "mdi:image-outline" + }, + "block_twitch": { + "default": "mdi:twitch" + }, + "block_twitter": { + "default": "mdi:twitter" + }, + "block_vimeo": { + "default": "mdi:vimeo" + }, + "block_vk": { + "default": "mdi:power-socket-eu" + }, + "block_whatsapp": { + "default": "mdi:whatsapp" + }, + "block_xboxlive": { + "default": "mdi:microsoft-xbox" + }, + "block_youtube": { + "default": "mdi:youtube" + }, + "block_zoom": { + "default": "mdi:video" + }, + "block_dating": { + "default": "mdi:candelabra" + }, + "block_gambling": { + "default": "mdi:slot-machine" + }, + "block_online_gaming": { + "default": "mdi:gamepad-variant" + }, + "block_piracy": { + "default": "mdi:pirate" + }, + "block_porn": { + "default": "mdi:movie-off" + }, + "block_social_networks": { + "default": "mdi:facebook" + }, + "block_video_streaming": { + "default": "mdi:video-wireless-outline" + } + } + } +} diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index c501142697e..b6864fea50a 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -59,7 +59,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( key="all_queries", coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, - icon="mdi:dns", translation_key="all_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -69,7 +68,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( key="blocked_queries", coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, - icon="mdi:dns", translation_key="blocked_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -79,7 +77,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( key="relayed_queries", coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, - icon="mdi:dns", translation_key="relayed_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -89,7 +86,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( key="blocked_queries_ratio", coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, - icon="mdi:dns", translation_key="blocked_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -100,7 +96,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="doh_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -111,7 +106,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="doh3_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -122,7 +116,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="dot_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -133,7 +126,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="doq_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -144,7 +136,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="tcp_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -155,7 +146,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="udp_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -165,7 +155,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( key="doh_queries_ratio", coordinator_type=ATTR_PROTOCOLS, entity_registry_enabled_default=False, - icon="mdi:dns", entity_category=EntityCategory.DIAGNOSTIC, translation_key="doh_queries_ratio", native_unit_of_measurement=PERCENTAGE, @@ -176,7 +165,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( key="doh3_queries_ratio", coordinator_type=ATTR_PROTOCOLS, entity_registry_enabled_default=False, - icon="mdi:dns", entity_category=EntityCategory.DIAGNOSTIC, translation_key="doh3_queries_ratio", native_unit_of_measurement=PERCENTAGE, @@ -188,7 +176,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="dot_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -198,7 +185,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( key="doq_queries_ratio", coordinator_type=ATTR_PROTOCOLS, entity_registry_enabled_default=False, - icon="mdi:dns", entity_category=EntityCategory.DIAGNOSTIC, translation_key="doq_queries_ratio", native_unit_of_measurement=PERCENTAGE, @@ -210,7 +196,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="tcp_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -221,7 +206,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="udp_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -232,7 +216,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_ENCRYPTION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:lock", translation_key="encrypted_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -243,7 +226,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_ENCRYPTION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:lock-open", translation_key="unencrypted_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -254,7 +236,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_ENCRYPTION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:lock", translation_key="encrypted_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -265,7 +246,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_IP_VERSIONS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:ip", translation_key="ipv4_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -276,7 +256,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_IP_VERSIONS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:ip", translation_key="ipv6_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -287,7 +266,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_IP_VERSIONS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:ip", translation_key="ipv6_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -298,7 +276,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_DNSSEC, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:lock-check", translation_key="validated_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -309,7 +286,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_DNSSEC, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:lock-alert", translation_key="not_validated_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -320,7 +296,6 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( coordinator_type=ATTR_DNSSEC, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:lock-check", translation_key="validated_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index 177b4970a93..a01b8a8c3c3 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -1,7 +1,6 @@ """Support for the NextDNS service.""" from __future__ import annotations -import asyncio from collections.abc import Callable from dataclasses import dataclass from typing import Any, Generic @@ -43,42 +42,36 @@ SWITCHES = ( key="block_page", translation_key="block_page", entity_category=EntityCategory.CONFIG, - icon="mdi:web-cancel", state=lambda data: data.block_page, ), NextDnsSwitchEntityDescription[Settings]( key="cache_boost", translation_key="cache_boost", entity_category=EntityCategory.CONFIG, - icon="mdi:memory", state=lambda data: data.cache_boost, ), NextDnsSwitchEntityDescription[Settings]( key="cname_flattening", translation_key="cname_flattening", entity_category=EntityCategory.CONFIG, - icon="mdi:tournament", state=lambda data: data.cname_flattening, ), NextDnsSwitchEntityDescription[Settings]( key="anonymized_ecs", translation_key="anonymized_ecs", entity_category=EntityCategory.CONFIG, - icon="mdi:incognito", state=lambda data: data.anonymized_ecs, ), NextDnsSwitchEntityDescription[Settings]( key="logs", translation_key="logs", entity_category=EntityCategory.CONFIG, - icon="mdi:file-document-outline", state=lambda data: data.logs, ), NextDnsSwitchEntityDescription[Settings]( key="web3", translation_key="web3", entity_category=EntityCategory.CONFIG, - icon="mdi:web", state=lambda data: data.web3, ), NextDnsSwitchEntityDescription[Settings]( @@ -139,14 +132,12 @@ SWITCHES = ( key="dns_rebinding_protection", translation_key="dns_rebinding_protection", entity_category=EntityCategory.CONFIG, - icon="mdi:dns", state=lambda data: data.dns_rebinding_protection, ), NextDnsSwitchEntityDescription[Settings]( key="google_safe_browsing", translation_key="google_safe_browsing", entity_category=EntityCategory.CONFIG, - icon="mdi:google", state=lambda data: data.google_safe_browsing, ), NextDnsSwitchEntityDescription[Settings]( @@ -165,7 +156,6 @@ SWITCHES = ( key="typosquatting_protection", translation_key="typosquatting_protection", entity_category=EntityCategory.CONFIG, - icon="mdi:keyboard-outline", state=lambda data: data.typosquatting_protection, ), NextDnsSwitchEntityDescription[Settings]( @@ -178,14 +168,12 @@ SWITCHES = ( key="safesearch", translation_key="safesearch", entity_category=EntityCategory.CONFIG, - icon="mdi:search-web", state=lambda data: data.safesearch, ), NextDnsSwitchEntityDescription[Settings]( key="youtube_restricted_mode", translation_key="youtube_restricted_mode", entity_category=EntityCategory.CONFIG, - icon="mdi:youtube", state=lambda data: data.youtube_restricted_mode, ), NextDnsSwitchEntityDescription[Settings]( @@ -193,7 +181,6 @@ SWITCHES = ( translation_key="block_9gag", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:file-gif-box", state=lambda data: data.block_9gag, ), NextDnsSwitchEntityDescription[Settings]( @@ -201,7 +188,6 @@ SWITCHES = ( translation_key="block_amazon", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:cart-outline", state=lambda data: data.block_amazon, ), NextDnsSwitchEntityDescription[Settings]( @@ -209,7 +195,6 @@ SWITCHES = ( translation_key="block_bereal", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:alpha-b-box", state=lambda data: data.block_bereal, ), NextDnsSwitchEntityDescription[Settings]( @@ -217,7 +202,6 @@ SWITCHES = ( translation_key="block_blizzard", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:sword-cross", state=lambda data: data.block_blizzard, ), NextDnsSwitchEntityDescription[Settings]( @@ -225,7 +209,6 @@ SWITCHES = ( translation_key="block_chatgpt", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:chat-processing-outline", state=lambda data: data.block_chatgpt, ), NextDnsSwitchEntityDescription[Settings]( @@ -233,7 +216,6 @@ SWITCHES = ( translation_key="block_dailymotion", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:movie-search-outline", state=lambda data: data.block_dailymotion, ), NextDnsSwitchEntityDescription[Settings]( @@ -241,7 +223,6 @@ SWITCHES = ( translation_key="block_discord", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:message-text", state=lambda data: data.block_discord, ), NextDnsSwitchEntityDescription[Settings]( @@ -249,7 +230,6 @@ SWITCHES = ( translation_key="block_disneyplus", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:movie-search-outline", state=lambda data: data.block_disneyplus, ), NextDnsSwitchEntityDescription[Settings]( @@ -257,7 +237,6 @@ SWITCHES = ( translation_key="block_ebay", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:basket-outline", state=lambda data: data.block_ebay, ), NextDnsSwitchEntityDescription[Settings]( @@ -265,7 +244,6 @@ SWITCHES = ( translation_key="block_facebook", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:facebook", state=lambda data: data.block_facebook, ), NextDnsSwitchEntityDescription[Settings]( @@ -273,7 +251,6 @@ SWITCHES = ( translation_key="block_fortnite", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:tank", state=lambda data: data.block_fortnite, ), NextDnsSwitchEntityDescription[Settings]( @@ -281,7 +258,6 @@ SWITCHES = ( translation_key="block_google_chat", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:forum", state=lambda data: data.block_google_chat, ), NextDnsSwitchEntityDescription[Settings]( @@ -289,7 +265,6 @@ SWITCHES = ( translation_key="block_hbomax", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:movie-search-outline", state=lambda data: data.block_hbomax, ), NextDnsSwitchEntityDescription[Settings]( @@ -297,7 +272,6 @@ SWITCHES = ( name="Block Hulu", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:hulu", state=lambda data: data.block_hulu, ), NextDnsSwitchEntityDescription[Settings]( @@ -305,7 +279,6 @@ SWITCHES = ( translation_key="block_imgur", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:camera-image", state=lambda data: data.block_imgur, ), NextDnsSwitchEntityDescription[Settings]( @@ -313,7 +286,6 @@ SWITCHES = ( translation_key="block_instagram", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:instagram", state=lambda data: data.block_instagram, ), NextDnsSwitchEntityDescription[Settings]( @@ -321,7 +293,6 @@ SWITCHES = ( translation_key="block_leagueoflegends", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:sword", state=lambda data: data.block_leagueoflegends, ), NextDnsSwitchEntityDescription[Settings]( @@ -329,7 +300,6 @@ SWITCHES = ( translation_key="block_mastodon", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:mastodon", state=lambda data: data.block_mastodon, ), NextDnsSwitchEntityDescription[Settings]( @@ -337,7 +307,6 @@ SWITCHES = ( translation_key="block_messenger", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:message-text", state=lambda data: data.block_messenger, ), NextDnsSwitchEntityDescription[Settings]( @@ -345,7 +314,6 @@ SWITCHES = ( translation_key="block_minecraft", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:minecraft", state=lambda data: data.block_minecraft, ), NextDnsSwitchEntityDescription[Settings]( @@ -353,7 +321,6 @@ SWITCHES = ( translation_key="block_netflix", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:netflix", state=lambda data: data.block_netflix, ), NextDnsSwitchEntityDescription[Settings]( @@ -361,7 +328,6 @@ SWITCHES = ( translation_key="block_pinterest", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:pinterest", state=lambda data: data.block_pinterest, ), NextDnsSwitchEntityDescription[Settings]( @@ -369,7 +335,6 @@ SWITCHES = ( translation_key="block_playstation_network", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:sony-playstation", state=lambda data: data.block_playstation_network, ), NextDnsSwitchEntityDescription[Settings]( @@ -377,7 +342,6 @@ SWITCHES = ( translation_key="block_primevideo", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:filmstrip", state=lambda data: data.block_primevideo, ), NextDnsSwitchEntityDescription[Settings]( @@ -385,7 +349,6 @@ SWITCHES = ( translation_key="block_reddit", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:reddit", state=lambda data: data.block_reddit, ), NextDnsSwitchEntityDescription[Settings]( @@ -393,7 +356,6 @@ SWITCHES = ( translation_key="block_roblox", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:robot", state=lambda data: data.block_roblox, ), NextDnsSwitchEntityDescription[Settings]( @@ -401,7 +363,6 @@ SWITCHES = ( translation_key="block_signal", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:chat-outline", state=lambda data: data.block_signal, ), NextDnsSwitchEntityDescription[Settings]( @@ -409,7 +370,6 @@ SWITCHES = ( translation_key="block_skype", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:skype", state=lambda data: data.block_skype, ), NextDnsSwitchEntityDescription[Settings]( @@ -417,7 +377,6 @@ SWITCHES = ( translation_key="block_snapchat", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:snapchat", state=lambda data: data.block_snapchat, ), NextDnsSwitchEntityDescription[Settings]( @@ -425,7 +384,6 @@ SWITCHES = ( translation_key="block_spotify", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:spotify", state=lambda data: data.block_spotify, ), NextDnsSwitchEntityDescription[Settings]( @@ -433,7 +391,6 @@ SWITCHES = ( translation_key="block_steam", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:steam", state=lambda data: data.block_steam, ), NextDnsSwitchEntityDescription[Settings]( @@ -441,7 +398,6 @@ SWITCHES = ( translation_key="block_telegram", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:send-outline", state=lambda data: data.block_telegram, ), NextDnsSwitchEntityDescription[Settings]( @@ -449,7 +405,6 @@ SWITCHES = ( translation_key="block_tiktok", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:music-note", state=lambda data: data.block_tiktok, ), NextDnsSwitchEntityDescription[Settings]( @@ -457,7 +412,6 @@ SWITCHES = ( translation_key="block_tinder", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:fire", state=lambda data: data.block_tinder, ), NextDnsSwitchEntityDescription[Settings]( @@ -465,7 +419,6 @@ SWITCHES = ( translation_key="block_tumblr", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:image-outline", state=lambda data: data.block_tumblr, ), NextDnsSwitchEntityDescription[Settings]( @@ -473,7 +426,6 @@ SWITCHES = ( translation_key="block_twitch", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:twitch", state=lambda data: data.block_twitch, ), NextDnsSwitchEntityDescription[Settings]( @@ -481,7 +433,6 @@ SWITCHES = ( translation_key="block_twitter", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:twitter", state=lambda data: data.block_twitter, ), NextDnsSwitchEntityDescription[Settings]( @@ -489,7 +440,6 @@ SWITCHES = ( translation_key="block_vimeo", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:vimeo", state=lambda data: data.block_vimeo, ), NextDnsSwitchEntityDescription[Settings]( @@ -497,7 +447,6 @@ SWITCHES = ( translation_key="block_vk", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:power-socket-eu", state=lambda data: data.block_vk, ), NextDnsSwitchEntityDescription[Settings]( @@ -505,7 +454,6 @@ SWITCHES = ( translation_key="block_whatsapp", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:whatsapp", state=lambda data: data.block_whatsapp, ), NextDnsSwitchEntityDescription[Settings]( @@ -513,7 +461,6 @@ SWITCHES = ( translation_key="block_xboxlive", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:microsoft-xbox", state=lambda data: data.block_xboxlive, ), NextDnsSwitchEntityDescription[Settings]( @@ -521,7 +468,6 @@ SWITCHES = ( translation_key="block_youtube", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:youtube", state=lambda data: data.block_youtube, ), NextDnsSwitchEntityDescription[Settings]( @@ -529,7 +475,6 @@ SWITCHES = ( translation_key="block_zoom", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:video", state=lambda data: data.block_zoom, ), NextDnsSwitchEntityDescription[Settings]( @@ -537,7 +482,6 @@ SWITCHES = ( translation_key="block_dating", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:candelabra", state=lambda data: data.block_dating, ), NextDnsSwitchEntityDescription[Settings]( @@ -545,7 +489,6 @@ SWITCHES = ( translation_key="block_gambling", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:slot-machine", state=lambda data: data.block_gambling, ), NextDnsSwitchEntityDescription[Settings]( @@ -553,7 +496,6 @@ SWITCHES = ( translation_key="block_online_gaming", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:gamepad-variant", state=lambda data: data.block_online_gaming, ), NextDnsSwitchEntityDescription[Settings]( @@ -561,7 +503,6 @@ SWITCHES = ( translation_key="block_piracy", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:pirate", state=lambda data: data.block_piracy, ), NextDnsSwitchEntityDescription[Settings]( @@ -569,7 +510,6 @@ SWITCHES = ( translation_key="block_porn", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:movie-off", state=lambda data: data.block_porn, ), NextDnsSwitchEntityDescription[Settings]( @@ -577,7 +517,6 @@ SWITCHES = ( translation_key="block_social_networks", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:facebook", state=lambda data: data.block_social_networks, ), NextDnsSwitchEntityDescription[Settings]( @@ -585,7 +524,6 @@ SWITCHES = ( translation_key="block_video_streaming", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:video-wireless-outline", state=lambda data: data.block_video_streaming, ), ) @@ -647,7 +585,7 @@ class NextDnsSwitch(CoordinatorEntity[NextDnsSettingsUpdateCoordinator], SwitchE except ( ApiError, ClientConnectorError, - asyncio.TimeoutError, + TimeoutError, ClientError, ) as err: raise HomeAssistantError( diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index c5c94145e4b..970f53837ea 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.7.0"] + "requirements": ["nibe==2.8.0"] } diff --git a/homeassistant/components/nightscout/__init__.py b/homeassistant/components/nightscout/__init__.py index 88f12ffa4bc..798fcf1ec9d 100644 --- a/homeassistant/components/nightscout/__init__.py +++ b/homeassistant/components/nightscout/__init__.py @@ -1,5 +1,4 @@ """The Nightscout integration.""" -from asyncio import TimeoutError as AsyncIOTimeoutError from aiohttp import ClientError from py_nightscout import Api as NightscoutAPI @@ -26,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api = NightscoutAPI(server_url, session=session, api_secret=api_key) try: status = await api.get_server_status() - except (ClientError, AsyncIOTimeoutError, OSError) as error: + except (ClientError, TimeoutError, OSError) as error: raise ConfigEntryNotReady from error hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/nightscout/config_flow.py b/homeassistant/components/nightscout/config_flow.py index 98e075ba3c9..6249979c83d 100644 --- a/homeassistant/components/nightscout/config_flow.py +++ b/homeassistant/components/nightscout/config_flow.py @@ -1,5 +1,4 @@ """Config flow for Nightscout integration.""" -from asyncio import TimeoutError as AsyncIOTimeoutError import logging from typing import Any @@ -30,7 +29,7 @@ async def _validate_input(data: dict[str, Any]) -> dict[str, str]: await api.get_sgvs() except ClientResponseError as error: raise InputValidationError("invalid_auth") from error - except (ClientError, AsyncIOTimeoutError, OSError) as error: + except (ClientError, TimeoutError, OSError) as error: raise InputValidationError("cannot_connect") from error # Return info to be stored in the config entry. diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py index 851610ee374..bdc46e75cb8 100644 --- a/homeassistant/components/nightscout/sensor.py +++ b/homeassistant/components/nightscout/sensor.py @@ -1,7 +1,6 @@ """Support for Nightscout sensors.""" from __future__ import annotations -from asyncio import TimeoutError as AsyncIOTimeoutError from datetime import timedelta import logging from typing import Any @@ -51,7 +50,7 @@ class NightscoutSensor(SensorEntity): """Fetch the latest data from Nightscout REST API and update the state.""" try: values = await self.api.get_sgvs() - except (ClientError, AsyncIOTimeoutError, OSError) as error: + except (ClientError, TimeoutError, OSError) as error: _LOGGER.error("Error fetching data. Failed with %s", error) self._attr_available = False return diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 726b3fa3db8..7fade3868dc 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -191,8 +191,9 @@ class NmapDeviceScanner: registry = er.async_get(self._hass) self._known_mac_addresses = { entry.unique_id: entry.original_name - for entry in registry.entities.values() - if entry.config_entry_id == self._entry_id + for entry in registry.entities.get_entries_for_config_entry_id( + self._entry_id + ) } @property @@ -227,7 +228,7 @@ class NmapDeviceScanner: ) ) self._mac_vendor_lookup = AsyncMacLookup() - with contextlib.suppress((asyncio.TimeoutError, aiohttp.ClientError)): + with contextlib.suppress((TimeoutError, aiohttp.ClientError)): # We don't care if this fails since it only # improves the data when we don't have it from nmap await self._mac_vendor_lookup.load_vendors() diff --git a/homeassistant/components/no_ip/__init__.py b/homeassistant/components/no_ip/__init__.py index e91b5cec92d..8ab277c3def 100644 --- a/homeassistant/components/no_ip/__init__.py +++ b/homeassistant/components/no_ip/__init__.py @@ -114,7 +114,7 @@ async def _update_no_ip( except aiohttp.ClientError: _LOGGER.warning("Can't connect to NO-IP API") - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Timeout from NO-IP API for domain: %s", domain) return False diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 406acd6aabd..3ed2c7bdb93 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -4,29 +4,20 @@ from __future__ import annotations import asyncio from dataclasses import dataclass, field from datetime import timedelta -import logging -import traceback from typing import Any from uuid import UUID -from aionotion import async_get_client -from aionotion.bridge.models import Bridge, BridgeAllResponse +from aionotion.bridge.models import Bridge from aionotion.errors import InvalidCredentialsError, NotionError -from aionotion.sensor.models import ( - Listener, - ListenerAllResponse, - ListenerKind, - Sensor, - SensorAllResponse, -) -from aionotion.user.models import UserPreferences, UserPreferencesResponse +from aionotion.listener.models import Listener, ListenerKind +from aionotion.sensor.models import Sensor +from aionotion.user.models import UserPreferences from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( - aiohttp_client, config_validation as cv, device_registry as dr, entity_registry as er, @@ -40,6 +31,8 @@ from homeassistant.helpers.update_coordinator import ( ) from .const import ( + CONF_REFRESH_TOKEN, + CONF_USER_UUID, DOMAIN, LOGGER, SENSOR_BATTERY, @@ -53,6 +46,7 @@ from .const import ( SENSOR_TEMPERATURE, SENSOR_WINDOW_HINGED, ) +from .util import async_get_client_with_credentials, async_get_client_with_refresh_token PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -112,88 +106,118 @@ class NotionData: # Define a user preferences response object: user_preferences: UserPreferences | None = field(default=None) - def update_data_from_response( - self, - response: BridgeAllResponse - | ListenerAllResponse - | SensorAllResponse - | UserPreferencesResponse, - ) -> None: - """Update data from an aionotion response.""" - if isinstance(response, BridgeAllResponse): - for bridge in response.bridges: - # If a new bridge is discovered, register it: - if bridge.id not in self.bridges: - _async_register_new_bridge(self.hass, self.entry, bridge) - self.bridges[bridge.id] = bridge - elif isinstance(response, ListenerAllResponse): - self.listeners = {listener.id: listener for listener in response.listeners} - elif isinstance(response, SensorAllResponse): - self.sensors = {sensor.uuid: sensor for sensor in response.sensors} - elif isinstance(response, UserPreferencesResponse): - self.user_preferences = response.user_preferences + def update_bridges(self, bridges: list[Bridge]) -> None: + """Update the bridges.""" + for bridge in bridges: + # If a new bridge is discovered, register it: + if bridge.id not in self.bridges: + _async_register_new_bridge(self.hass, self.entry, bridge) + self.bridges[bridge.id] = bridge + + def update_listeners(self, listeners: list[Listener]) -> None: + """Update the listeners.""" + self.listeners = {listener.id: listener for listener in listeners} + + def update_sensors(self, sensors: list[Sensor]) -> None: + """Update the sensors.""" + self.sensors = {sensor.uuid: sensor for sensor in sensors} + + def update_user_preferences(self, user_preferences: UserPreferences) -> None: + """Update the user preferences.""" + self.user_preferences = user_preferences def asdict(self) -> dict[str, Any]: """Represent this dataclass (and its Pydantic contents) as a dict.""" data: dict[str, Any] = { - DATA_BRIDGES: [bridge.dict() for bridge in self.bridges.values()], - DATA_LISTENERS: [listener.dict() for listener in self.listeners.values()], - DATA_SENSORS: [sensor.dict() for sensor in self.sensors.values()], + DATA_BRIDGES: [item.to_dict() for item in self.bridges.values()], + DATA_LISTENERS: [item.to_dict() for item in self.listeners.values()], + DATA_SENSORS: [item.to_dict() for item in self.sensors.values()], } if self.user_preferences: - data[DATA_USER_PREFERENCES] = self.user_preferences.dict() + data[DATA_USER_PREFERENCES] = self.user_preferences.to_dict() return data async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Notion as a config entry.""" - if not entry.unique_id: - hass.config_entries.async_update_entry( - entry, unique_id=entry.data[CONF_USERNAME] - ) + entry_updates: dict[str, Any] = {"data": {**entry.data}} - session = aiohttp_client.async_get_clientsession(hass) + if not entry.unique_id: + entry_updates["unique_id"] = entry.data[CONF_USERNAME] try: - client = await async_get_client( - entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session - ) + if password := entry_updates["data"].pop(CONF_PASSWORD, None): + # If a password exists in the config entry data, use it to get a new client + # (and pop it from the new entry data): + client = await async_get_client_with_credentials( + hass, entry.data[CONF_USERNAME], password + ) + else: + # If a password doesn't exist in the config entry data, we can safely assume + # that a refresh token and user UUID do, so we use them to get the client: + client = await async_get_client_with_refresh_token( + hass, + entry.data[CONF_USER_UUID], + entry.data[CONF_REFRESH_TOKEN], + ) except InvalidCredentialsError as err: - raise ConfigEntryAuthFailed("Invalid username and/or password") from err + raise ConfigEntryAuthFailed("Invalid credentials") from err except NotionError as err: raise ConfigEntryNotReady("Config entry failed to load") from err + # Always update the config entry with the latest refresh token and user UUID: + entry_updates["data"][CONF_REFRESH_TOKEN] = client.refresh_token + entry_updates["data"][CONF_USER_UUID] = client.user_uuid + + @callback + def async_save_refresh_token(refresh_token: str) -> None: + """Save a refresh token to the config entry data.""" + LOGGER.debug("Saving new refresh token to HASS storage") + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_REFRESH_TOKEN: refresh_token} + ) + + # Create a callback to save the refresh token when it changes: + entry.async_on_unload(client.add_refresh_token_callback(async_save_refresh_token)) + + hass.config_entries.async_update_entry(entry, **entry_updates) + async def async_update() -> NotionData: """Get the latest data from the Notion API.""" data = NotionData(hass=hass, entry=entry) - tasks = { - DATA_BRIDGES: client.bridge.async_all(), - DATA_LISTENERS: client.sensor.async_listeners(), - DATA_SENSORS: client.sensor.async_all(), - DATA_USER_PREFERENCES: client.user.async_preferences(), - } - results = await asyncio.gather(*tasks.values(), return_exceptions=True) - for attr, result in zip(tasks, results): + try: + async with asyncio.TaskGroup() as tg: + bridges = tg.create_task(client.bridge.async_all()) + listeners = tg.create_task(client.listener.async_all()) + sensors = tg.create_task(client.sensor.async_all()) + user_preferences = tg.create_task(client.user.async_preferences()) + except BaseExceptionGroup as err: + result = err.exceptions[0] if isinstance(result, InvalidCredentialsError): raise ConfigEntryAuthFailed( "Invalid username and/or password" ) from result if isinstance(result, NotionError): raise UpdateFailed( - f"There was a Notion error while updating {attr}: {result}" + f"There was a Notion error while updating: {result}" ) from result if isinstance(result, Exception): - if LOGGER.isEnabledFor(logging.DEBUG): - LOGGER.debug("".join(traceback.format_tb(result.__traceback__))) + LOGGER.debug( + "There was an unknown error while updating: %s", + result, + exc_info=result, + ) raise UpdateFailed( - f"There was an unknown error while updating {attr}: {result}" + f"There was an unknown error while updating: {result}" ) from result if isinstance(result, BaseException): raise result from None - data.update_data_from_response(result) # type: ignore[arg-type] - + data.update_bridges(bridges.result()) + data.update_listeners(listeners.result()) + data.update_sensors(sensors.result()) + data.update_user_preferences(user_preferences.result()) return data coordinator = DataUpdateCoordinator( @@ -232,7 +256,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: listener for listener in coordinator.data.listeners.values() if listener.sensor_id == sensor.uuid - and listener.listener_kind == TASK_TYPE_TO_LISTENER_MAP[task_type] + and listener.definition_id == TASK_TYPE_TO_LISTENER_MAP[task_type].value ) return {"new_unique_id": listener.id} diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index 8e4d5927152..dfa6dc5ec06 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Literal -from aionotion.sensor.models import ListenerKind +from aionotion.listener.models import ListenerKind from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -123,7 +123,7 @@ async def async_setup_entry( ) for listener_id, listener in coordinator.data.listeners.items() for description in BINARY_SENSOR_DESCRIPTIONS - if description.listener_kind == listener.listener_kind + if description.listener_kind.value == listener.definition_id and (sensor := coordinator.data.sensors[listener.sensor_id]) ] ) @@ -138,6 +138,6 @@ class NotionBinarySensor(NotionEntity, BinarySensorEntity): def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" if not self.listener.insights.primary.value: - LOGGER.warning("Unknown listener structure: %s", self.listener.dict()) + LOGGER.warning("Unknown listener structure: %s", self.listener) return False return self.listener.insights.primary.value == self.entity_description.on_state diff --git a/homeassistant/components/notion/config_flow.py b/homeassistant/components/notion/config_flow.py index 1e4adab2910..f43c87b5085 100644 --- a/homeassistant/components/notion/config_flow.py +++ b/homeassistant/components/notion/config_flow.py @@ -2,9 +2,9 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import dataclass, field from typing import Any -from aionotion import async_get_client from aionotion.errors import InvalidCredentialsError, NotionError import voluptuous as vol @@ -13,9 +13,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client -from .const import DOMAIN, LOGGER +from .const import CONF_REFRESH_TOKEN, CONF_USER_UUID, DOMAIN, LOGGER +from .util import async_get_client_with_credentials AUTH_SCHEMA = vol.Schema( { @@ -30,15 +30,23 @@ REAUTH_SCHEMA = vol.Schema( ) +@dataclass(frozen=True, kw_only=True) +class CredentialsValidationResult: + """Define a validation result.""" + + user_uuid: str | None = None + refresh_token: str | None = None + errors: dict[str, Any] = field(default_factory=dict) + + async def async_validate_credentials( hass: HomeAssistant, username: str, password: str -) -> dict[str, Any]: - """Validate a Notion username and password (returning any errors).""" - session = aiohttp_client.async_get_clientsession(hass) +) -> CredentialsValidationResult: + """Validate a Notion username and password.""" errors = {} try: - await async_get_client(username, password, session=session) + client = await async_get_client_with_credentials(hass, username, password) except InvalidCredentialsError: errors["base"] = "invalid_auth" except NotionError as err: @@ -48,7 +56,12 @@ async def async_validate_credentials( LOGGER.exception("Unknown error while validation credentials: %s", err) errors["base"] = "unknown" - return errors + if errors: + return CredentialsValidationResult(errors=errors) + + return CredentialsValidationResult( + user_uuid=client.user_uuid, refresh_token=client.refresh_token + ) class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -82,20 +95,24 @@ class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): }, ) - if errors := await async_validate_credentials( + credentials_validation_result = await async_validate_credentials( self.hass, self._reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD] - ): + ) + + if credentials_validation_result.errors: return self.async_show_form( step_id="reauth_confirm", data_schema=REAUTH_SCHEMA, - errors=errors, + errors=credentials_validation_result.errors, description_placeholders={ CONF_USERNAME: self._reauth_entry.data[CONF_USERNAME] }, ) self.hass.config_entries.async_update_entry( - self._reauth_entry, data=self._reauth_entry.data | user_input + self._reauth_entry, + data=self._reauth_entry.data + | {CONF_REFRESH_TOKEN: credentials_validation_result.refresh_token}, ) self.hass.async_create_task( self.hass.config_entries.async_reload(self._reauth_entry.entry_id) @@ -112,13 +129,22 @@ class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(user_input[CONF_USERNAME]) self._abort_if_unique_id_configured() - if errors := await async_validate_credentials( + credentials_validation_result = await async_validate_credentials( self.hass, user_input[CONF_USERNAME], user_input[CONF_PASSWORD] - ): + ) + + if credentials_validation_result.errors: return self.async_show_form( step_id="user", data_schema=AUTH_SCHEMA, - errors=errors, + errors=credentials_validation_result.errors, ) - return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_USER_UUID: credentials_validation_result.user_uuid, + CONF_REFRESH_TOKEN: credentials_validation_result.refresh_token, + }, + ) diff --git a/homeassistant/components/notion/const.py b/homeassistant/components/notion/const.py index 0961b7c10c5..b1ea921a71b 100644 --- a/homeassistant/components/notion/const.py +++ b/homeassistant/components/notion/const.py @@ -4,6 +4,9 @@ import logging DOMAIN = "notion" LOGGER = logging.getLogger(__package__) +CONF_REFRESH_TOKEN = "refresh_token" +CONF_USER_UUID = "user_uuid" + SENSOR_BATTERY = "low_battery" SENSOR_DOOR = "door" SENSOR_GARAGE_DOOR = "garage_door" diff --git a/homeassistant/components/notion/diagnostics.py b/homeassistant/components/notion/diagnostics.py index 86b84760016..5c32f235639 100644 --- a/homeassistant/components/notion/diagnostics.py +++ b/homeassistant/components/notion/diagnostics.py @@ -5,12 +5,12 @@ from typing import Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME +from homeassistant.const import CONF_EMAIL, CONF_UNIQUE_ID, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import NotionData -from .const import DOMAIN +from .const import CONF_REFRESH_TOKEN, CONF_USER_UUID, DOMAIN CONF_DEVICE_KEY = "device_key" CONF_HARDWARE_ID = "hardware_id" @@ -23,12 +23,13 @@ TO_REDACT = { CONF_EMAIL, CONF_HARDWARE_ID, CONF_LAST_BRIDGE_HARDWARE_ID, - CONF_PASSWORD, + CONF_REFRESH_TOKEN, # Config entry title and unique ID may contain sensitive data: CONF_TITLE, CONF_UNIQUE_ID, CONF_USERNAME, CONF_USER_ID, + CONF_USER_UUID, } diff --git a/homeassistant/components/notion/manifest.json b/homeassistant/components/notion/manifest.json index f23a082df35..9f725587e60 100644 --- a/homeassistant/components/notion/manifest.json +++ b/homeassistant/components/notion/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aionotion"], - "requirements": ["aionotion==2023.05.5"] + "requirements": ["aionotion==2024.02.1"] } diff --git a/homeassistant/components/notion/model.py b/homeassistant/components/notion/model.py index a774bfdfad3..059ea551b09 100644 --- a/homeassistant/components/notion/model.py +++ b/homeassistant/components/notion/model.py @@ -1,7 +1,7 @@ """Define Notion model mixins.""" from dataclasses import dataclass -from aionotion.sensor.models import ListenerKind +from aionotion.listener.models import ListenerKind @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index 1d2c81addfa..f5439895ac9 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -1,7 +1,7 @@ """Support for Notion sensors.""" from dataclasses import dataclass -from aionotion.sensor.models import ListenerKind +from aionotion.listener.models import ListenerKind from homeassistant.components.sensor import ( SensorDeviceClass, @@ -59,7 +59,7 @@ async def async_setup_entry( ) for listener_id, listener in coordinator.data.listeners.items() for description in SENSOR_DESCRIPTIONS - if description.listener_kind == listener.listener_kind + if description.listener_kind.value == listener.definition_id and (sensor := coordinator.data.sensors[listener.sensor_id]) ] ) @@ -71,7 +71,7 @@ class NotionSensor(NotionEntity, SensorEntity): @property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of the sensor.""" - if self.listener.listener_kind == ListenerKind.TEMPERATURE: + if self.listener.definition_id == ListenerKind.TEMPERATURE.value: if not self.coordinator.data.user_preferences: return None if self.coordinator.data.user_preferences.celsius_enabled: @@ -84,7 +84,7 @@ class NotionSensor(NotionEntity, SensorEntity): """Return the value reported by the sensor.""" if not self.listener.status_localized: return None - if self.listener.listener_kind == ListenerKind.TEMPERATURE: + if self.listener.definition_id == ListenerKind.TEMPERATURE.value: # The Notion API only returns a localized string for temperature (e.g. # "70°"); we simply remove the degree symbol: return self.listener.status_localized.state[:-1] diff --git a/homeassistant/components/notion/util.py b/homeassistant/components/notion/util.py new file mode 100644 index 00000000000..553199b7c7a --- /dev/null +++ b/homeassistant/components/notion/util.py @@ -0,0 +1,30 @@ +"""Define notion utilities.""" +from aionotion import ( + async_get_client_with_credentials as cwc, + async_get_client_with_refresh_token as cwrt, +) +from aionotion.client import Client + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.instance_id import async_get + + +async def async_get_client_with_credentials( + hass: HomeAssistant, email: str, password: str +) -> Client: + """Get a Notion client with credentials.""" + session = aiohttp_client.async_get_clientsession(hass) + instance_id = await async_get(hass) + return await cwc(email, password, session=session, session_name=instance_id) + + +async def async_get_client_with_refresh_token( + hass: HomeAssistant, user_uuid: str, refresh_token: str +) -> Client: + """Get a Notion client with credentials.""" + session = aiohttp_client.async_get_clientsession(hass) + instance_id = await async_get(hass) + return await cwrt( + user_uuid, refresh_token, session=session, session_name=instance_id + ) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 41fc4c2e03e..51f5c02d2bf 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -304,7 +304,7 @@ class NukiCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enfo async def _async_update_data(self) -> None: """Fetch data from Nuki bridge.""" try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # Note: TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. async with asyncio.timeout(10): events = await self.hass.async_add_executor_job( diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index e3b2d129017..f1da14bdd35 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -22,19 +22,20 @@ async def async_setup_entry( """Set up the Nuki binary sensors.""" entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] - lock_entities = [] - opener_entities = [] + entities: list[NukiEntity] = [] for lock in entry_data.locks: if lock.is_door_sensor_activated: - lock_entities.extend([NukiDoorsensorEntity(entry_data.coordinator, lock)]) + entities.append(NukiDoorsensorEntity(entry_data.coordinator, lock)) - async_add_entities(lock_entities) + entities.extend( + [ + NukiRingactionEntity(entry_data.coordinator, opener) + for opener in entry_data.openers + ] + ) - for opener in entry_data.openers: - opener_entities.extend([NukiRingactionEntity(entry_data.coordinator, opener)]) - - async_add_entities(opener_entities) + async_add_entities(entities) class NukiDoorsensorEntity(NukiEntity[NukiDevice], BinarySensorEntity): diff --git a/homeassistant/components/obihai/__init__.py b/homeassistant/components/obihai/__init__.py index a3e16fbad76..0ba0b3dfc5e 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -36,9 +36,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_mac = await hass.async_add_executor_job( requester.pyobihai.get_device_mac ) - hass.config_entries.async_update_entry(entry, unique_id=format_mac(device_mac)) - - entry.version = 2 + hass.config_entries.async_update_entry( + entry, unique_id=format_mac(device_mac), version=2 + ) LOGGER.info("Migration to version %s successful", entry.version) diff --git a/homeassistant/components/oncue/const.py b/homeassistant/components/oncue/const.py index 7118944a4ec..599ef5ee22b 100644 --- a/homeassistant/components/oncue/const.py +++ b/homeassistant/components/oncue/const.py @@ -1,6 +1,5 @@ """Constants for the Oncue integration.""" -import asyncio import aiohttp from aiooncue import ServiceFailedException @@ -8,7 +7,7 @@ from aiooncue import ServiceFailedException DOMAIN = "oncue" CONNECTION_EXCEPTIONS = ( - asyncio.TimeoutError, + TimeoutError, aiohttp.ClientError, ServiceFailedException, ) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 013dd2e453f..c6ee74c2c50 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -197,7 +197,7 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): self._stream_uri_future = loop.create_future() try: uri_no_auth = await self.device.async_get_stream_uri(self.profile) - except (asyncio.TimeoutError, Exception) as err: + except (TimeoutError, Exception) as err: LOGGER.error("Failed to get stream uri: %s", err) if self._stream_uri_future: self._stream_uri_future.set_exception(err) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 603957a230e..c5539818a1c 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -32,7 +32,7 @@ from .parsers import PARSERS # entities for them. UNHANDLED_TOPICS: set[str] = {"tns1:MediaControl/VideoEncoderConfiguration"} -SUBSCRIPTION_ERRORS = (Fault, asyncio.TimeoutError, TransportError) +SUBSCRIPTION_ERRORS = (Fault, TimeoutError, TransportError) CREATE_ERRORS = (ONVIFError, Fault, RequestError, XMLParseError, ValidationError) SET_SYNCHRONIZATION_POINT_ERRORS = (*SUBSCRIPTION_ERRORS, TypeError) UNSUBSCRIBE_ERRORS = (XMLParseError, *SUBSCRIPTION_ERRORS) diff --git a/homeassistant/components/openalpr_cloud/image_processing.py b/homeassistant/components/openalpr_cloud/image_processing.py index 64b46a1da94..0dbebda6962 100644 --- a/homeassistant/components/openalpr_cloud/image_processing.py +++ b/homeassistant/components/openalpr_cloud/image_processing.py @@ -209,7 +209,7 @@ class OpenAlprCloudEntity(ImageProcessingAlprEntity): _LOGGER.error("Error %d -> %s", request.status, data.get("error")) return - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.error("Timeout for OpenALPR API") return diff --git a/homeassistant/components/openexchangerates/config_flow.py b/homeassistant/components/openexchangerates/config_flow.py index b78227ed1e5..0425b44d9e6 100644 --- a/homeassistant/components/openexchangerates/config_flow.py +++ b/homeassistant/components/openexchangerates/config_flow.py @@ -81,7 +81,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" except OpenExchangeRatesClientError: errors["base"] = "cannot_connect" - except asyncio.TimeoutError: + except TimeoutError: errors["base"] = "timeout_connect" except Exception: # pylint: disable=broad-except LOGGER.exception("Unexpected exception") @@ -126,6 +126,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.currencies = await client.get_currencies() except OpenExchangeRatesClientError as err: raise AbortFlow("cannot_connect") from err - except asyncio.TimeoutError as err: + except TimeoutError as err: raise AbortFlow("timeout_connect") from err return self.currencies diff --git a/homeassistant/components/openhome/__init__.py b/homeassistant/components/openhome/__init__.py index c7ee5a7d00c..fb03ab214f3 100644 --- a/homeassistant/components/openhome/__init__.py +++ b/homeassistant/components/openhome/__init__.py @@ -1,6 +1,5 @@ """The openhome component.""" -import asyncio import logging import aiohttp @@ -43,7 +42,7 @@ async def async_setup_entry( try: await device.init() - except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError) as exc: + except (TimeoutError, aiohttp.ClientError, UpnpError) as exc: raise ConfigEntryNotReady from exc _LOGGER.debug("Initialised device: %s", device.uuid()) diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 4935af1bc46..25052824ffe 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -1,7 +1,6 @@ """Support for Openhome Devices.""" from __future__ import annotations -import asyncio from collections.abc import Awaitable, Callable, Coroutine import functools import logging @@ -76,7 +75,7 @@ def catch_request_errors() -> ( [_FuncType[_OpenhomeDeviceT, _P, _R]], _ReturnFuncType[_OpenhomeDeviceT, _P, _R] ] ): - """Catch asyncio.TimeoutError, aiohttp.ClientError, UpnpError errors.""" + """Catch TimeoutError, aiohttp.ClientError, UpnpError errors.""" def call_wrapper( func: _FuncType[_OpenhomeDeviceT, _P, _R], @@ -87,10 +86,10 @@ def catch_request_errors() -> ( async def wrapper( self: _OpenhomeDeviceT, *args: _P.args, **kwargs: _P.kwargs ) -> _R | None: - """Catch asyncio.TimeoutError, aiohttp.ClientError, UpnpError errors.""" + """Catch TimeoutError, aiohttp.ClientError, UpnpError errors.""" try: return await func(self, *args, **kwargs) - except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError): + except (TimeoutError, aiohttp.ClientError, UpnpError): _LOGGER.error("Error during call %s", func.__name__) return None @@ -186,7 +185,7 @@ class OpenhomeDevice(MediaPlayerEntity): self._attr_state = MediaPlayerState.PLAYING self._attr_available = True - except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError): + except (TimeoutError, aiohttp.ClientError, UpnpError): self._attr_available = False @catch_request_errors() diff --git a/homeassistant/components/openhome/update.py b/homeassistant/components/openhome/update.py index 691776e4dfd..6d36bccec65 100644 --- a/homeassistant/components/openhome/update.py +++ b/homeassistant/components/openhome/update.py @@ -1,7 +1,6 @@ """Update entities for Linn devices.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -93,7 +92,7 @@ class OpenhomeUpdateEntity(UpdateEntity): try: if self.latest_version: await self._device.update_firmware() - except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError) as err: + except (TimeoutError, aiohttp.ClientError, UpnpError) as err: raise HomeAssistantError( f"Error updating {self._device.device.friendly_name}: {err}" ) from err diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index cd8b98880d5..12f4724e056 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -114,7 +114,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: async with asyncio.timeout(CONNECTION_TIMEOUT): await gateway.connect_and_subscribe() - except (asyncio.TimeoutError, ConnectionError, SerialException) as ex: + except (TimeoutError, ConnectionError, SerialException) as ex: await gateway.cleanup() raise ConfigEntryNotReady( f"Could not connect to gateway at {gateway.device_path}: {ex}" diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index 07187f3a2ec..70bed0d1665 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -70,7 +70,7 @@ class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: async with asyncio.timeout(CONNECTION_TIMEOUT): await test_connection() - except asyncio.TimeoutError: + except TimeoutError: return self._show_form({"base": "timeout_connect"}) except (ConnectionError, SerialException): return self._show_form({"base": "cannot_connect"}) diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index 048ffdd237b..a9ea1946f91 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -104,8 +104,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if version == 1: data.pop(CONF_BINARY_SENSORS, None) data.pop(CONF_SENSORS, None) - version = entry.version = 2 - hass.config_entries.async_update_entry(entry, data=data) + version = 2 + hass.config_entries.async_update_entry(entry, data=data, version=2) LOGGER.debug("Migration to version %s successful", version) return True diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index cfe28e2eacc..22c97d72fa5 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -78,10 +78,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mode = FORECAST_MODE_ONECALL_DAILY new_data = {**data, CONF_MODE: mode} - version = entry.version = CONFIG_FLOW_VERSION - config_entries.async_update_entry(entry, data=new_data) + config_entries.async_update_entry( + entry, data=new_data, version=CONFIG_FLOW_VERSION + ) - _LOGGER.info("Migration to version %s successful", version) + _LOGGER.info("Migration to version %s successful", CONFIG_FLOW_VERSION) return True diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py index c7cdaddf382..f743122d0cb 100644 --- a/homeassistant/components/oralb/sensor.py +++ b/homeassistant/components/oralb/sensor.py @@ -38,23 +38,31 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = { ), OralBSensor.SECTOR: SensorEntityDescription( key=OralBSensor.SECTOR, + translation_key="sector", entity_category=EntityCategory.DIAGNOSTIC, ), OralBSensor.NUMBER_OF_SECTORS: SensorEntityDescription( key=OralBSensor.NUMBER_OF_SECTORS, + translation_key="number_of_sectors", entity_category=EntityCategory.DIAGNOSTIC, ), OralBSensor.SECTOR_TIMER: SensorEntityDescription( key=OralBSensor.SECTOR_TIMER, + translation_key="sector_timer", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), OralBSensor.TOOTHBRUSH_STATE: SensorEntityDescription( - key=OralBSensor.TOOTHBRUSH_STATE + key=OralBSensor.TOOTHBRUSH_STATE, + name=None, + ), + OralBSensor.PRESSURE: SensorEntityDescription( + key=OralBSensor.PRESSURE, + translation_key="pressure", ), - OralBSensor.PRESSURE: SensorEntityDescription(key=OralBSensor.PRESSURE), OralBSensor.MODE: SensorEntityDescription( key=OralBSensor.MODE, + translation_key="mode", entity_category=EntityCategory.DIAGNOSTIC, ), OralBSensor.SIGNAL_STRENGTH: SensorEntityDescription( @@ -94,10 +102,7 @@ def sensor_update_to_bluetooth_data_update( device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value for device_key, sensor_values in sensor_update.entity_values.items() }, - entity_names={ - device_key_to_bluetooth_entity_key(device_key): sensor_values.name - for device_key, sensor_values in sensor_update.entity_values.items() - }, + entity_names={}, ) diff --git a/homeassistant/components/oralb/strings.json b/homeassistant/components/oralb/strings.json index d1d544c2381..f60fd56a9a4 100644 --- a/homeassistant/components/oralb/strings.json +++ b/homeassistant/components/oralb/strings.json @@ -18,5 +18,24 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "sector": { + "name": "Sector" + }, + "number_of_sectors": { + "name": "Number of sectors" + }, + "sector_timer": { + "name": "Sector timer" + }, + "pressure": { + "name": "Pressure" + }, + "mode": { + "name": "Brushing mode" + } + } } } diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 3c08a74ed61..fe4cc8c1145 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -1,8 +1,6 @@ """The Open Thread Border Router integration.""" from __future__ import annotations -import asyncio - import aiohttp import python_otbr_api @@ -42,7 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ( HomeAssistantError, aiohttp.ClientError, - asyncio.TimeoutError, + TimeoutError, ) as err: raise ConfigEntryNotReady("Unable to connect") from err if border_agent_id is None: diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index b96e276af8b..0248ffdd079 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -1,7 +1,6 @@ """Config flow for the Open Thread Border Router integration.""" from __future__ import annotations -import asyncio from contextlib import suppress import logging from typing import cast @@ -115,7 +114,7 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): except ( python_otbr_api.OTBRError, aiohttp.ClientError, - asyncio.TimeoutError, + TimeoutError, ): errors["base"] = "cannot_connect" else: diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index cf6aba33e80..ca0faa160f0 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.5.0"] + "requirements": ["python-otbr-api==2.6.0"] } diff --git a/homeassistant/components/otbr/silabs_multiprotocol.py b/homeassistant/components/otbr/silabs_multiprotocol.py index 9a462c4610b..bd7eb997558 100644 --- a/homeassistant/components/otbr/silabs_multiprotocol.py +++ b/homeassistant/components/otbr/silabs_multiprotocol.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import logging import aiohttp @@ -64,7 +63,7 @@ async def async_get_channel(hass: HomeAssistant) -> int | None: except ( HomeAssistantError, aiohttp.ClientError, - asyncio.TimeoutError, + TimeoutError, ) as err: _LOGGER.warning("Failed to communicate with OTBR %s", err) return None diff --git a/homeassistant/components/ourgroceries/__init__.py b/homeassistant/components/ourgroceries/__init__.py index ebb928e72d0..472313aa315 100644 --- a/homeassistant/components/ourgroceries/__init__.py +++ b/homeassistant/components/ourgroceries/__init__.py @@ -1,8 +1,6 @@ """The OurGroceries integration.""" from __future__ import annotations -from asyncio import TimeoutError as AsyncIOTimeoutError - from aiohttp import ClientError from ourgroceries import OurGroceries from ourgroceries.exceptions import InvalidLoginException @@ -26,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: og = OurGroceries(data[CONF_USERNAME], data[CONF_PASSWORD]) try: await og.login() - except (AsyncIOTimeoutError, ClientError) as error: + except (TimeoutError, ClientError) as error: raise ConfigEntryNotReady from error except InvalidLoginException: return False diff --git a/homeassistant/components/ourgroceries/config_flow.py b/homeassistant/components/ourgroceries/config_flow.py index a982325fceb..65670dd7f92 100644 --- a/homeassistant/components/ourgroceries/config_flow.py +++ b/homeassistant/components/ourgroceries/config_flow.py @@ -1,7 +1,6 @@ """Config flow for OurGroceries integration.""" from __future__ import annotations -from asyncio import TimeoutError as AsyncIOTimeoutError import logging from typing import Any @@ -40,7 +39,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): og = OurGroceries(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) try: await og.login() - except (AsyncIOTimeoutError, ClientError): + except (TimeoutError, ClientError): errors["base"] = "cannot_connect" except InvalidLoginException: errors["base"] = "invalid_auth" diff --git a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py index 79c360a5f93..7b7493a37bb 100644 --- a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py +++ b/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py @@ -51,10 +51,7 @@ class ValveHeatingTemperatureInterface(OverkizEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT] _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] _attr_supported_features = ( - ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN diff --git a/homeassistant/components/panasonic_viera/config_flow.py b/homeassistant/components/panasonic_viera/config_flow.py index 38b6a351f29..80ab929231e 100644 --- a/homeassistant/components/panasonic_viera/config_flow.py +++ b/homeassistant/components/panasonic_viera/config_flow.py @@ -159,12 +159,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Load the data.""" self._data = config - self._data[CONF_PORT] = ( - self._data[CONF_PORT] if CONF_PORT in self._data else DEFAULT_PORT - ) - self._data[CONF_ON_ACTION] = ( - self._data[CONF_ON_ACTION] if CONF_ON_ACTION in self._data else None - ) + self._data[CONF_PORT] = self._data.get(CONF_PORT, DEFAULT_PORT) + self._data[CONF_ON_ACTION] = self._data.get(CONF_ON_ACTION) await self.async_set_unique_id(self._data[CONF_HOST]) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/pegel_online/manifest.json b/homeassistant/components/pegel_online/manifest.json index 9546017d4ff..f38d320b454 100644 --- a/homeassistant/components/pegel_online/manifest.json +++ b/homeassistant/components/pegel_online/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiopegelonline"], - "requirements": ["aiopegelonline==0.0.6"] + "requirements": ["aiopegelonline==0.0.8"] } diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index 00a9f534852..61af7e5cc91 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -42,7 +42,7 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> dict: """Fetch data from API endpoint.""" try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # Note: TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. async with asyncio.timeout(10): data = await self.hass.async_add_executor_job(self.fetch_data) diff --git a/homeassistant/components/pilight/__init__.py b/homeassistant/components/pilight/__init__.py index 60568e722ef..51a15a52ec8 100644 --- a/homeassistant/components/pilight/__init__.py +++ b/homeassistant/components/pilight/__init__.py @@ -5,7 +5,6 @@ from collections.abc import Callable from datetime import timedelta import functools import logging -import socket import threading from typing import Any, ParamSpec @@ -75,7 +74,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: try: pilight_client = pilight.Client(host=host, port=port) - except (OSError, socket.timeout) as err: + except (OSError, TimeoutError) as err: _LOGGER.error("Unable to connect to %s on port %s: %s", host, port, err) return False diff --git a/homeassistant/components/ping/helpers.py b/homeassistant/components/ping/helpers.py index ce3d5c3b461..e3ebaffec12 100644 --- a/homeassistant/components/ping/helpers.py +++ b/homeassistant/components/ping/helpers.py @@ -141,7 +141,7 @@ class PingDataSubProcess(PingData): assert match is not None rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": rtt_mdev} - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.exception( "Timed out running command: `%s`, after: %ss", self._ping_cmd, diff --git a/homeassistant/components/pjlink/media_player.py b/homeassistant/components/pjlink/media_player.py index 4bbf1225a92..1a7ff877bb8 100644 --- a/homeassistant/components/pjlink/media_player.py +++ b/homeassistant/components/pjlink/media_player.py @@ -1,8 +1,6 @@ """Support for controlling projector via the PJLink protocol.""" from __future__ import annotations -import socket - from pypjlink import MUTE_AUDIO, Projector from pypjlink.projector import ProjectorError import voluptuous as vol @@ -116,7 +114,7 @@ class PjLinkDevice(MediaPlayerEntity): try: projector = Projector.from_address(self._host, self._port) projector.authenticate(self._password) - except (socket.timeout, OSError) as err: + except (TimeoutError, OSError) as err: self._attr_available = False raise ProjectorError(ERR_PROJECTOR_UNAVAILABLE) from err diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index 8fc01140787..ea3f8574415 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["plexapi", "plexwebsocket"], "requirements": [ - "PlexAPI==4.15.7", + "PlexAPI==4.15.9", "plexauth==0.0.6", "plexwebsocket==0.0.14" ], diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 84e0619773b..3553df02e8d 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -45,6 +45,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_translation_key = DOMAIN + _enable_turn_on_off_backwards_compatibility = False _previous_mode: str = "heating" @@ -69,6 +70,10 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) + if HVACMode.OFF in self.hvac_modes: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) if presets := self.device.get("preset_modes"): self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._attr_preset_modes = presets diff --git a/homeassistant/components/plugwise/icons.json b/homeassistant/components/plugwise/icons.json index 4af2c0b4c75..2a57dd4350f 100644 --- a/homeassistant/components/plugwise/icons.json +++ b/homeassistant/components/plugwise/icons.json @@ -64,16 +64,38 @@ }, "select": { "dhw_mode": { - "default": "mdi:shower" + "default": "mdi:shower", + "state": { + "comfort": "mdi:sofa", + "eco": "mdi:leaf", + "off": "mdi:circle-off-outline", + "boost": "mdi:rocket-launch", + "auto": "mdi:auto-mode" + } }, "gateway_mode": { - "default": "mdi:cog-outline" + "default": "mdi:cog-outline", + "state": { + "away": "mdi:pause", + "full": "mdi:home", + "vacation": "mdi:beach" + } }, "regulation_mode": { - "default": "mdi:hvac" + "default": "mdi:hvac", + "state": { + "bleeding_hot": "mdi:fire-circle", + "bleeding_cold": "mdi:water-circle", + "off": "mdi:circle-off-outline", + "heating": "mdi:radiator", + "cooling": "mdi:snowflake" + } }, "select_schedule": { - "default": "mdi:calendar-clock" + "default": "mdi:calendar-clock", + "state": { + "off": "mdi:circle-off-outline" + } } }, "sensor": { diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index 201e397ba7d..718e4a831c9 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -95,7 +95,7 @@ class PointFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: async with asyncio.timeout(10): url = await self._get_authorization_url() - except asyncio.TimeoutError: + except TimeoutError: return self.async_abort(reason="authorize_url_timeout") except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected error generating auth url") diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index d975537ca61..80a8d19cefe 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -89,7 +89,7 @@ class PowerwallDataManager: if attempt == 1: await self._recreate_powerwall_login() data = await _fetch_powerwall_data(self.power_wall) - except (asyncio.TimeoutError, PowerwallUnreachableError) as err: + except (TimeoutError, PowerwallUnreachableError) as err: raise UpdateFailed("Unable to fetch data from powerwall") from err except MissingAttributeError as err: _LOGGER.error("The powerwall api has changed: %s", str(err)) @@ -136,7 +136,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Cancel closing power_wall on success stack.pop_all() - except (asyncio.TimeoutError, PowerwallUnreachableError) as err: + except (TimeoutError, PowerwallUnreachableError) as err: raise ConfigEntryNotReady from err except MissingAttributeError as err: # The error might include some important information about what exactly changed. diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index e86949e2227..2c0d5a3f096 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -166,7 +166,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders: dict[str, str] = {} try: info = await validate_input(self.hass, user_input) - except (PowerwallUnreachableError, asyncio.TimeoutError) as ex: + except (PowerwallUnreachableError, TimeoutError) as ex: errors[CONF_IP_ADDRESS] = "cannot_connect" description_placeholders = {"error": str(ex)} except WrongVersion as ex: diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index e17ae1190a4..86163704797 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -21,7 +21,10 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_LOW, HVACAction, ) -from homeassistant.components.cover import ATTR_POSITION, ATTR_TILT_POSITION +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, +) from homeassistant.components.http import HomeAssistantView from homeassistant.components.humidifier import ATTR_AVAILABLE_MODES, ATTR_HUMIDITY from homeassistant.components.light import ATTR_BRIGHTNESS @@ -437,7 +440,7 @@ class PrometheusMetrics: float(cover_state == state.state) ) - position = state.attributes.get(ATTR_POSITION) + position = state.attributes.get(ATTR_CURRENT_POSITION) if position is not None: position_metric = self._metric( "cover_position", @@ -446,7 +449,7 @@ class PrometheusMetrics: ) position_metric.labels(**self._labels(state)).set(float(position)) - tilt_position = state.attributes.get(ATTR_TILT_POSITION) + tilt_position = state.attributes.get(ATTR_CURRENT_TILT_POSITION) if tilt_position is not None: tilt_position_metric = self._metric( "cover_tilt_position", diff --git a/homeassistant/components/prowl/notify.py b/homeassistant/components/prowl/notify.py index d0b35aaf4b9..c365ce151ec 100644 --- a/homeassistant/components/prowl/notify.py +++ b/homeassistant/components/prowl/notify.py @@ -73,5 +73,5 @@ class ProwlNotificationService(BaseNotificationService): response.status, result, ) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Timeout accessing Prowl at %s", url) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 3f28028d703..349658223f3 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import cast import voluptuous as vol @@ -11,6 +12,7 @@ from homeassistant.const import ( CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE, + STATE_UNKNOWN, Platform, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant @@ -203,16 +205,21 @@ class Proximity(CoordinatorEntity[ProximityDataUpdateCoordinator]): self._attr_unit_of_measurement = self.coordinator.unit_of_measurement @property - def state(self) -> str | int | float: + def data(self) -> dict[str, str | int | None]: + """Get data from coordinator.""" + return self.coordinator.data.proximity + + @property + def state(self) -> str | float: """Return the state.""" - return self.coordinator.data.proximity[ATTR_DIST_TO] + if isinstance(distance := self.data[ATTR_DIST_TO], str): + return distance + return self.coordinator.convert_legacy(cast(int, distance)) @property def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" return { - ATTR_DIR_OF_TRAVEL: str( - self.coordinator.data.proximity[ATTR_DIR_OF_TRAVEL] - ), - ATTR_NEAREST: str(self.coordinator.data.proximity[ATTR_NEAREST]), + ATTR_DIR_OF_TRAVEL: str(self.data[ATTR_DIR_OF_TRAVEL] or STATE_UNKNOWN), + ATTR_NEAREST: str(self.data[ATTR_NEAREST]), } diff --git a/homeassistant/components/proximity/config_flow.py b/homeassistant/components/proximity/config_flow.py index 231a50c6c00..f3306bebf39 100644 --- a/homeassistant/components/proximity/config_flow.py +++ b/homeassistant/components/proximity/config_flow.py @@ -18,6 +18,7 @@ from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, ) +from homeassistant.util import slugify from .const import ( CONF_IGNORED_ZONES, @@ -89,11 +90,19 @@ class ProximityConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: self._async_abort_entries_match(user_input) - zone = self.hass.states.get(user_input[CONF_ZONE]) + title = cast(State, self.hass.states.get(user_input[CONF_ZONE])).name - return self.async_create_entry( - title=cast(State, zone).name, data=user_input - ) + slugified_existing_entry_titles = [ + slugify(e.title) for e in self._async_current_entries() + ] + + possible_title = title + tries = 1 + while slugify(possible_title) in slugified_existing_entry_titles: + tries += 1 + possible_title = f"{title} {tries}" + + return self.async_create_entry(title=possible_title, data=user_input) return self.async_show_form( step_id="user", diff --git a/homeassistant/components/proximity/const.py b/homeassistant/components/proximity/const.py index 7627d550e1f..e5b384b2f70 100644 --- a/homeassistant/components/proximity/const.py +++ b/homeassistant/components/proximity/const.py @@ -9,6 +9,8 @@ ATTR_DIST_TO: Final = "dist_to_zone" ATTR_ENTITIES_DATA: Final = "entities_data" ATTR_IN_IGNORED_ZONE: Final = "is_in_ignored_zone" ATTR_NEAREST: Final = "nearest" +ATTR_NEAREST_DIR_OF_TRAVEL: Final = "nearest_dir_of_travel" +ATTR_NEAREST_DIST_TO: Final = "nearest_dist_to_zone" ATTR_PROXIMITY_DATA: Final = "proximity_data" CONF_IGNORED_ZONES = "ignored_zones" diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index 4ae923276cc..047ab1b6b3a 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -3,6 +3,7 @@ from collections import defaultdict from dataclasses import dataclass import logging +from typing import cast from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.config_entries import ConfigEntry @@ -52,18 +53,15 @@ class StateChangedData: class ProximityData: """ProximityCoordinatorData class.""" - proximity: dict[str, str | float] + proximity: dict[str, str | int | None] entities: dict[str, dict[str, str | int | None]] -DEFAULT_DATA = ProximityData( - { - ATTR_DIST_TO: DEFAULT_DIST_TO_ZONE, - ATTR_DIR_OF_TRAVEL: DEFAULT_DIR_OF_TRAVEL, - ATTR_NEAREST: DEFAULT_NEAREST, - }, - {}, -) +DEFAULT_PROXIMITY_DATA: dict[str, str | int | None] = { + ATTR_DIST_TO: DEFAULT_DIST_TO_ZONE, + ATTR_DIR_OF_TRAVEL: DEFAULT_DIR_OF_TRAVEL, + ATTR_NEAREST: DEFAULT_NEAREST, +} class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): @@ -92,7 +90,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): update_interval=None, ) - self.data = DEFAULT_DATA + self.data = ProximityData(DEFAULT_PROXIMITY_DATA, {}) self.state_change_data: StateChangedData | None = None @@ -133,7 +131,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): }, ) - def _convert(self, value: float | str) -> float | str: + def convert_legacy(self, value: float | str) -> float | str: """Round and convert given distance value.""" if isinstance(value, str): return value @@ -238,7 +236,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): self.name, self.proximity_zone_id, ) - return DEFAULT_DATA + return ProximityData(DEFAULT_PROXIMITY_DATA, {}) entities_data = self.data.entities @@ -306,7 +304,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): ) # takeover data for legacy proximity entity - proximity_data: dict[str, str | float] = { + proximity_data: dict[str, str | int | None] = { ATTR_DIST_TO: DEFAULT_DIST_TO_ZONE, ATTR_DIR_OF_TRAVEL: DEFAULT_DIR_OF_TRAVEL, ATTR_NEAREST: DEFAULT_NEAREST, @@ -321,28 +319,26 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): _LOGGER.debug("set first entity_data: %s", entity_data) proximity_data = { ATTR_DIST_TO: distance_to, - ATTR_DIR_OF_TRAVEL: entity_data[ATTR_DIR_OF_TRAVEL] or "unknown", + ATTR_DIR_OF_TRAVEL: entity_data[ATTR_DIR_OF_TRAVEL], ATTR_NEAREST: str(entity_data[ATTR_NAME]), } continue - if float(nearest_distance_to) > float(distance_to): + if cast(int, nearest_distance_to) > int(distance_to): _LOGGER.debug("set closer entity_data: %s", entity_data) proximity_data = { ATTR_DIST_TO: distance_to, - ATTR_DIR_OF_TRAVEL: entity_data[ATTR_DIR_OF_TRAVEL] or "unknown", + ATTR_DIR_OF_TRAVEL: entity_data[ATTR_DIR_OF_TRAVEL], ATTR_NEAREST: str(entity_data[ATTR_NAME]), } continue - if float(nearest_distance_to) == float(distance_to): + if cast(int, nearest_distance_to) == int(distance_to): _LOGGER.debug("set equally close entity_data: %s", entity_data) proximity_data[ ATTR_NEAREST ] = f"{proximity_data[ATTR_NEAREST]}, {str(entity_data[ATTR_NAME])}" - proximity_data[ATTR_DIST_TO] = self._convert(proximity_data[ATTR_DIST_TO]) - return ProximityData(proximity_data, entities_data) def _create_removed_tracked_entity_issue(self, entity_id: str) -> None: diff --git a/homeassistant/components/proximity/diagnostics.py b/homeassistant/components/proximity/diagnostics.py index ba5e1f53722..3ccecbe1f19 100644 --- a/homeassistant/components/proximity/diagnostics.py +++ b/homeassistant/components/proximity/diagnostics.py @@ -4,10 +4,18 @@ from __future__ import annotations from typing import Any from homeassistant.components.device_tracker import ATTR_GPS, ATTR_IP, ATTR_MAC -from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics import REDACTED, async_redact_data from homeassistant.components.person import ATTR_USER_ID +from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + STATE_HOME, + STATE_NOT_HOME, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant from .const import DOMAIN @@ -21,6 +29,7 @@ TO_REDACT = { ATTR_MAC, ATTR_USER_ID, "context", + "location_name", } @@ -34,16 +43,27 @@ async def async_get_config_entry_diagnostics( "entry": entry.as_dict(), } + non_sensitiv_states = [ + STATE_HOME, + STATE_NOT_HOME, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ] + [z.name for z in hass.states.async_all(ZONE_DOMAIN)] + tracked_states: dict[str, dict] = {} for tracked_entity_id in coordinator.tracked_entities: if (state := hass.states.get(tracked_entity_id)) is None: continue - tracked_states[tracked_entity_id] = state.as_dict() + tracked_states[tracked_entity_id] = async_redact_data( + state.as_dict(), TO_REDACT + ) + if state.state not in non_sensitiv_states: + tracked_states[tracked_entity_id]["state"] = REDACTED diag_data["data"] = { "proximity": coordinator.data.proximity, "entities": coordinator.data.entities, "entity_mapping": coordinator.entity_mapping, - "tracked_states": async_redact_data(tracked_states, TO_REDACT), + "tracked_states": tracked_states, } return diag_data diff --git a/homeassistant/components/proximity/icons.json b/homeassistant/components/proximity/icons.json new file mode 100644 index 00000000000..2919c73eda0 --- /dev/null +++ b/homeassistant/components/proximity/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "dir_of_travel": { + "default": "mdi:crosshairs-question", + "state": { + "arrived": "mdi:map-marker-radius-outline", + "away_from": "mdi:sign-direction-minus", + "stationary": "mdi:map-marker-outline", + "towards": "mdi:sign-direction-plus" + } + }, + "nearest": { + "default": "mdi:near-me" + }, + "nearest_dir_of_travel": { + "default": "mdi:crosshairs-question", + "state": { + "arrived": "mdi:map-marker-radius-outline", + "away_from": "mdi:sign-direction-minus", + "stationary": "mdi:map-marker-outline", + "towards": "mdi:sign-direction-plus" + } + } + } + } +} diff --git a/homeassistant/components/proximity/sensor.py b/homeassistant/components/proximity/sensor.py index 4b1e1d1f29d..8eb7aae9bb9 100644 --- a/homeassistant/components/proximity/sensor.py +++ b/homeassistant/components/proximity/sensor.py @@ -17,37 +17,49 @@ 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_DIR_OF_TRAVEL, ATTR_DIST_TO, ATTR_NEAREST, DOMAIN +from .const import ( + ATTR_DIR_OF_TRAVEL, + ATTR_DIST_TO, + ATTR_NEAREST, + ATTR_NEAREST_DIR_OF_TRAVEL, + ATTR_NEAREST_DIST_TO, + DOMAIN, +) from .coordinator import ProximityDataUpdateCoordinator +DIRECTIONS = ["arrived", "away_from", "stationary", "towards"] + SENSORS_PER_ENTITY: list[SensorEntityDescription] = [ SensorEntityDescription( key=ATTR_DIST_TO, - name="Distance", + translation_key=ATTR_DIST_TO, device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.METERS, ), SensorEntityDescription( key=ATTR_DIR_OF_TRAVEL, - name="Direction of travel", translation_key=ATTR_DIR_OF_TRAVEL, - icon="mdi:compass-outline", device_class=SensorDeviceClass.ENUM, - options=[ - "arrived", - "away_from", - "stationary", - "towards", - ], + options=DIRECTIONS, ), ] SENSORS_PER_PROXIMITY: list[SensorEntityDescription] = [ SensorEntityDescription( key=ATTR_NEAREST, - name="Nearest", translation_key=ATTR_NEAREST, - icon="mdi:near-me", + ), + SensorEntityDescription( + key=ATTR_DIST_TO, + translation_key=ATTR_NEAREST_DIST_TO, + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.METERS, + ), + SensorEntityDescription( + key=ATTR_DIR_OF_TRAVEL, + translation_key=ATTR_NEAREST_DIR_OF_TRAVEL, + device_class=SensorDeviceClass.ENUM, + options=DIRECTIONS, ), ] @@ -57,6 +69,7 @@ class TrackedEntityDescriptor(NamedTuple): entity_id: str identifier: str + name: str def _device_info(coordinator: ProximityDataUpdateCoordinator) -> DeviceInfo: @@ -83,13 +96,24 @@ async def async_setup_entry( entity_reg = er.async_get(hass) for tracked_entity_id in coordinator.tracked_entities: + tracked_entity_object_id = tracked_entity_id.split(".")[-1] if (entity_entry := entity_reg.async_get(tracked_entity_id)) is not None: tracked_entity_descriptors.append( - TrackedEntityDescriptor(tracked_entity_id, entity_entry.id) + TrackedEntityDescriptor( + tracked_entity_id, + entity_entry.id, + entity_entry.name + or entity_entry.original_name + or tracked_entity_object_id, + ) ) else: tracked_entity_descriptors.append( - TrackedEntityDescriptor(tracked_entity_id, tracked_entity_id) + TrackedEntityDescriptor( + tracked_entity_id, + tracked_entity_id, + tracked_entity_object_id, + ) ) entities += [ @@ -151,8 +175,10 @@ class ProximityTrackedEntitySensor( self.tracked_entity_id = tracked_entity_descriptor.entity_id self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{tracked_entity_descriptor.identifier}_{description.key}" - self._attr_name = f"{self.tracked_entity_id.split('.')[-1]} {description.name}" self._attr_device_info = _device_info(coordinator) + self._attr_translation_placeholders = { + "tracked_entity": tracked_entity_descriptor.name + } async def async_added_to_hass(self) -> None: """Register entity mapping.""" @@ -162,18 +188,19 @@ class ProximityTrackedEntitySensor( ) @property - def data(self) -> dict[str, str | int | None] | None: + def data(self) -> dict[str, str | int | None]: """Get data from coordinator.""" - return self.coordinator.data.entities.get(self.tracked_entity_id) + return self.coordinator.data.entities[self.tracked_entity_id] @property def available(self) -> bool: """Return if entity is available.""" - return super().available and self.data is not None + return ( + super().available + and self.tracked_entity_id in self.coordinator.data.entities + ) @property def native_value(self) -> str | float | None: """Return native sensor value.""" - if self.data is None: - return None return self.data.get(self.entity_description.key) diff --git a/homeassistant/components/proximity/strings.json b/homeassistant/components/proximity/strings.json index f52f3d03516..72c95eeeeae 100644 --- a/homeassistant/components/proximity/strings.json +++ b/homeassistant/components/proximity/strings.json @@ -32,7 +32,7 @@ "entity": { "sensor": { "dir_of_travel": { - "name": "Direction of travel", + "name": "{tracked_entity} Direction of travel", "state": { "arrived": "Arrived", "away_from": "Away from", @@ -40,7 +40,18 @@ "towards": "Towards" } }, - "nearest": { "name": "Nearest device" } + "dist_to_zone": { "name": "{tracked_entity} Distance" }, + "nearest": { "name": "Nearest device" }, + "nearest_dir_of_travel": { + "name": "Nearest direction of travel", + "state": { + "arrived": "Arrived", + "away_from": "Away from", + "stationary": "Stationary", + "towards": "Towards" + } + }, + "nearest_dist_to_zone": { "name": "Nearest distance" } } }, "issues": { diff --git a/homeassistant/components/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index 94cf21e13df..08670ef5433 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -113,9 +113,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> new_data[CONF_PASSWORD] = password ir.async_delete_issue(hass, DOMAIN, "firmware_5_1_required") - config_entry.minor_version = 2 - - hass.config_entries.async_update_entry(config_entry, data=new_data) + hass.config_entries.async_update_entry( + config_entry, data=new_data, minor_version=2 + ) return True diff --git a/homeassistant/components/prusalink/config_flow.py b/homeassistant/components/prusalink/config_flow.py index 378c5e7395a..e4e9b9d719c 100644 --- a/homeassistant/components/prusalink/config_flow.py +++ b/homeassistant/components/prusalink/config_flow.py @@ -50,7 +50,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, async with asyncio.timeout(5): version = await api.get_version() - except (asyncio.TimeoutError, ClientError) as err: + except (TimeoutError, ClientError) as err: _LOGGER.error("Could not connect to PrusaLink: %s", err) raise CannotConnect from err diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index 1c87a275126..f68ad6ce896 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -108,8 +108,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if country in COUNTRIES: for device in data["devices"]: device[CONF_REGION] = country - version = entry.version = 2 - config_entries.async_update_entry(entry, data=data) + version = 2 + config_entries.async_update_entry(entry, data=data, version=2) _LOGGER.info( "PlayStation 4 Config Updated: Region changed to: %s", country, @@ -120,33 +120,34 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Prevent changing entity_id. Updates entity registry. registry = er.async_get(hass) - for entity_id, e_entry in registry.entities.items(): - if e_entry.config_entry_id == entry.entry_id: - unique_id = e_entry.unique_id + for e_entry in registry.entities.get_entries_for_config_entry_id( + entry.entry_id + ): + unique_id = e_entry.unique_id + entity_id = e_entry.entity_id - # Remove old entity entry. - registry.async_remove(entity_id) + # Remove old entity entry. + registry.async_remove(entity_id) - # Format old unique_id. - unique_id = format_unique_id(entry.data[CONF_TOKEN], unique_id) + # Format old unique_id. + unique_id = format_unique_id(entry.data[CONF_TOKEN], unique_id) - # Create new entry with old entity_id. - new_id = split_entity_id(entity_id)[1] - registry.async_get_or_create( - "media_player", - DOMAIN, - unique_id, - suggested_object_id=new_id, - config_entry=entry, - device_id=e_entry.device_id, - ) - entry.version = 3 - _LOGGER.info( - "PlayStation 4 identifier for entity: %s has changed", - entity_id, - ) - config_entries.async_update_entry(entry) - return True + # Create new entry with old entity_id. + new_id = split_entity_id(entity_id)[1] + registry.async_get_or_create( + "media_player", + DOMAIN, + unique_id, + suggested_object_id=new_id, + config_entry=entry, + device_id=e_entry.device_id, + ) + _LOGGER.info( + "PlayStation 4 identifier for entity: %s has changed", + entity_id, + ) + config_entries.async_update_entry(entry, version=3) + return True msg = f"""{reason[version]} for the PlayStation 4 Integration. Please remove the PS4 Integration and re-configure diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index f14ef6ce2aa..42a1021afe4 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -1,5 +1,4 @@ """Support for PlayStation 4 consoles.""" -import asyncio from contextlib import suppress import logging from typing import Any, cast @@ -257,7 +256,7 @@ class PS4Device(MediaPlayerEntity): except PSDataIncomplete: title = None - except asyncio.TimeoutError: + except TimeoutError: title = None _LOGGER.error("PS Store Search Timed out") @@ -345,11 +344,13 @@ class PS4Device(MediaPlayerEntity): _LOGGER.info("Assuming status from registry") e_registry = er.async_get(self.hass) d_registry = dr.async_get(self.hass) - for entity_id, entry in e_registry.entities.items(): - if entry.config_entry_id == self._entry_id: - self._attr_unique_id = entry.unique_id - self.entity_id = entity_id - break + + for entry in e_registry.entities.get_entries_for_config_entry_id( + self._entry_id + ): + self._attr_unique_id = entry.unique_id + self.entity_id = entry.entity_id + break for device in d_registry.devices.values(): if self._entry_id in device.config_entries: self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index a4fec1c3d4d..2cedcb8598a 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -75,7 +75,7 @@ async def handle_webhook(hass, webhook_id, request): try: async with asyncio.timeout(5): data = dict(await request.post()) - except (asyncio.TimeoutError, aiohttp.web.HTTPException) as error: + except (TimeoutError, aiohttp.web.HTTPException) as error: _LOGGER.error("Could not get information from POST <%s>", error) return diff --git a/homeassistant/components/qingping/config_flow.py b/homeassistant/components/qingping/config_flow.py index 5b7837a9694..a90085afb4f 100644 --- a/homeassistant/components/qingping/config_flow.py +++ b/homeassistant/components/qingping/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Qingping integration.""" from __future__ import annotations -import asyncio from typing import Any from qingping_ble import QingpingBluetoothDeviceData as DeviceData @@ -62,7 +61,7 @@ class QingpingConfigFlow(ConfigFlow, domain=DOMAIN): self._discovery_info = await self._async_wait_for_full_advertisement( discovery_info, device ) - except asyncio.TimeoutError: + except TimeoutError: return self.async_abort(reason="not_supported") self._discovery_info = discovery_info self._discovered_device = device diff --git a/homeassistant/components/qingping/manifest.json b/homeassistant/components/qingping/manifest.json index 5cde039c5ce..c25652ca91e 100644 --- a/homeassistant/components/qingping/manifest.json +++ b/homeassistant/components/qingping/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/qingping", "iot_class": "local_push", - "requirements": ["qingping-ble==0.9.0"] + "requirements": ["qingping-ble==0.10.0"] } diff --git a/homeassistant/components/rabbitair/config_flow.py b/homeassistant/components/rabbitair/config_flow.py index 70cd07f4d91..e265740179d 100644 --- a/homeassistant/components/rabbitair/config_flow.py +++ b/homeassistant/components/rabbitair/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Rabbit Air integration.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -36,7 +35,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, except ValueError as err: # Most likely caused by the invalid access token. raise InvalidAccessToken from err - except asyncio.TimeoutError as err: + except TimeoutError as err: # Either the host doesn't respond or the auth failed. raise TimeoutConnect from err except OSError as err: diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index c14603fe9ca..7f395169644 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -96,7 +96,7 @@ class DiskSpaceDataUpdateCoordinator(RadarrDataUpdateCoordinator[list[RootFolder """Fetch the data.""" root_folders = await self.api_client.async_get_root_folders() if isinstance(root_folders, RootFolder): - root_folders = [root_folders] + return [root_folders] return root_folders @@ -105,7 +105,10 @@ class HealthDataUpdateCoordinator(RadarrDataUpdateCoordinator[list[Health]]): async def _fetch_data(self) -> list[Health]: """Fetch the health data.""" - return await self.api_client.async_get_failed_health_checks() + health = await self.api_client.async_get_failed_health_checks() + if isinstance(health, Health): + return [health] + return health class MoviesDataUpdateCoordinator(RadarrDataUpdateCoordinator[int]): diff --git a/homeassistant/components/radiotherm/__init__.py b/homeassistant/components/radiotherm/__init__.py index 808ee56b092..86a9fe58013 100644 --- a/homeassistant/components/radiotherm/__init__.py +++ b/homeassistant/components/radiotherm/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Coroutine -from socket import timeout from typing import Any, TypeVar from urllib.error import URLError @@ -32,7 +31,7 @@ async def _async_call_or_raise_not_ready( except RadiothermTstatError as ex: msg = f"{host} was busy (invalid value returned): {ex}" raise ConfigEntryNotReady(msg) from ex - except timeout as ex: + except TimeoutError as ex: msg = f"{host} timed out waiting for a response: {ex}" raise ConfigEntryNotReady(msg) from ex except (OSError, URLError) as ex: diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py index ca488ade461..c370cc86484 100644 --- a/homeassistant/components/radiotherm/config_flow.py +++ b/homeassistant/components/radiotherm/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -from socket import timeout from typing import Any from urllib.error import URLError @@ -30,7 +29,7 @@ async def validate_connection(hass: HomeAssistant, host: str) -> RadioThermInitD """Validate the connection.""" try: return await async_get_init_data(hass, host) - except (timeout, RadiothermTstatError, URLError, OSError) as ex: + except (TimeoutError, RadiothermTstatError, URLError, OSError) as ex: raise CannotConnect(f"Failed to connect to {host}: {ex}") from ex diff --git a/homeassistant/components/radiotherm/coordinator.py b/homeassistant/components/radiotherm/coordinator.py index ffc6bfcc8ba..5b0161d9f22 100644 --- a/homeassistant/components/radiotherm/coordinator.py +++ b/homeassistant/components/radiotherm/coordinator.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import timedelta import logging -from socket import timeout from urllib.error import URLError from radiotherm.validate import RadiothermTstatError @@ -39,7 +38,7 @@ class RadioThermUpdateCoordinator(DataUpdateCoordinator[RadioThermUpdate]): except RadiothermTstatError as ex: msg = f"{self._description} was busy (invalid value returned): {ex}" raise UpdateFailed(msg) from ex - except timeout as ex: + except TimeoutError as ex: msg = f"{self._description}) timed out waiting for a response: {ex}" raise UpdateFailed(msg) from ex except (OSError, URLError) as ex: diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index f90e13d37f3..d9cf7b565a7 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -114,7 +114,7 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): controller.get_serial_number(), controller.get_wifi_params(), ) - except asyncio.TimeoutError as err: + except TimeoutError as err: raise ConfigFlowError( f"Timeout connecting to Rain Bird controller: {str(err)}", "timeout_connect", diff --git a/homeassistant/components/rainforest_raven/config_flow.py b/homeassistant/components/rainforest_raven/config_flow.py index cd8ce68c7e7..2f0234efb7a 100644 --- a/homeassistant/components/rainforest_raven/config_flow.py +++ b/homeassistant/components/rainforest_raven/config_flow.py @@ -106,7 +106,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(unique_id) try: await self._validate_device(dev_path) - except asyncio.TimeoutError: + except TimeoutError: return self.async_abort(reason="timeout_connect") except RAVEnConnectionError: return self.async_abort(reason="cannot_connect") @@ -147,7 +147,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(unique_id) try: await self._validate_device(dev_path) - except asyncio.TimeoutError: + except TimeoutError: errors[CONF_DEVICE] = "timeout_connect" except RAVEnConnectionError: errors[CONF_DEVICE] = "cannot_connect" diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 5c3ff18f71c..730e288df02 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -507,7 +507,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 1 -> 2: Update unique IDs to be consistent across platform (including removing # the silly removal of colons in the MAC address that was added originally): if version == 1: - version = entry.version = 2 + version = 2 + hass.config_entries.async_update_entry(entry, version=version) @callback def migrate_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any]: diff --git a/homeassistant/components/rainmachine/util.py b/homeassistant/components/rainmachine/util.py index dfb03b11b5d..77a91c627a9 100644 --- a/homeassistant/components/rainmachine/util.py +++ b/homeassistant/components/rainmachine/util.py @@ -60,9 +60,10 @@ def async_finish_entity_domain_replacements( try: [registry_entry] = [ registry_entry - for registry_entry in ent_reg.entities.values() - if registry_entry.config_entry_id == entry.entry_id - and registry_entry.domain == strategy.old_domain + for registry_entry in ent_reg.entities.get_entries_for_config_entry_id( + entry.entry_id + ) + if registry_entry.domain == strategy.old_domain and registry_entry.unique_id == strategy.old_unique_id ] except ValueError: diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index 0c5b4a8b0dd..a6d330e6151 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -54,6 +54,8 @@ async def async_setup_entry( class RandomBinarySensor(BinarySensorEntity): """Representation of a Random binary sensor.""" + _attr_translation_key = "random" + def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random binary sensor.""" self._attr_name = config.get(CONF_NAME) diff --git a/homeassistant/components/random/icons.json b/homeassistant/components/random/icons.json new file mode 100644 index 00000000000..83d5ecd0688 --- /dev/null +++ b/homeassistant/components/random/icons.json @@ -0,0 +1,14 @@ +{ + "entity": { + "binary_sensor": { + "random": { + "default": "mdi:dice-multiple" + } + }, + "sensor": { + "random": { + "default": "mdi:dice-multiple" + } + } + } +} diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index f1ca4290d83..8cc21e34ce9 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -65,6 +65,8 @@ async def async_setup_entry( class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" + _attr_translation_key = "random" + def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random sensor.""" self._attr_name = config.get(CONF_NAME) diff --git a/homeassistant/components/raspberry_pi/__init__.py b/homeassistant/components/raspberry_pi/__init__.py index 3750a1c7068..19697d9b69d 100644 --- a/homeassistant/components/raspberry_pi/__init__.py +++ b/homeassistant/components/raspberry_pi/__init__.py @@ -1,7 +1,7 @@ """The Raspberry Pi integration.""" from __future__ import annotations -from homeassistant.components.hassio import get_os_info +from homeassistant.components.hassio import get_os_info, is_hassio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -9,6 +9,11 @@ from homeassistant.exceptions import ConfigEntryNotReady async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Raspberry Pi config entry.""" + if not is_hassio(hass): + # Not running under supervisor, Home Assistant may have been migrated + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False + if (os_info := get_os_info(hass)) is None: # The hassio integration has not yet fetched data from the supervisor raise ConfigEntryNotReady diff --git a/homeassistant/components/raspberry_pi/manifest.json b/homeassistant/components/raspberry_pi/manifest.json index d30c637d2c3..5ed68154ce1 100644 --- a/homeassistant/components/raspberry_pi/manifest.json +++ b/homeassistant/components/raspberry_pi/manifest.json @@ -1,9 +1,10 @@ { "domain": "raspberry_pi", "name": "Raspberry Pi", + "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/core"], "config_flow": false, - "dependencies": ["hardware", "hassio"], + "dependencies": ["hardware"], "documentation": "https://www.home-assistant.io/integrations/raspberry_pi", "integration_type": "hardware" } diff --git a/homeassistant/components/recollect_waste/__init__.py b/homeassistant/components/recollect_waste/__init__.py index 076067312eb..5f3a50e93ed 100644 --- a/homeassistant/components/recollect_waste/__init__.py +++ b/homeassistant/components/recollect_waste/__init__.py @@ -92,7 +92,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 1 -> 2: Update unique ID of existing, single sensor entity to be consistent with # common format for platforms going forward: if version == 1: - version = entry.version = 2 + version = 2 + hass.config_entries.async_update_entry(entry, version=version) @callback def migrate_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any]: diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 07591c468b8..ce539b7f0c8 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -1335,7 +1335,7 @@ class Recorder(threading.Thread): try: async with asyncio.timeout(DB_LOCK_TIMEOUT): await database_locked.wait() - except asyncio.TimeoutError as err: + except TimeoutError as err: task.database_unlock.set() raise TimeoutError( f"Could not lock database within {DB_LOCK_TIMEOUT} seconds." diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 13ba7400952..98b6d15facb 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.25", + "SQLAlchemy==2.0.27", "fnv-hash-fast==0.5.0", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 0b63bb8daa2..a9d8c0b2482 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -10,8 +10,6 @@ from typing import TYPE_CHECKING from sqlalchemy.orm.session import Session -import homeassistant.util.dt as dt_util - from .db_schema import Events, States, StatesMeta from .models import DatabaseEngine from .queries import ( @@ -251,7 +249,7 @@ def _select_state_attributes_ids_to_purge( state_ids = set() attributes_ids = set() for state_id, attributes_id in session.execute( - find_states_to_purge(dt_util.utc_to_timestamp(purge_before), max_bind_vars) + find_states_to_purge(purge_before.timestamp(), max_bind_vars) ).all(): state_ids.add(state_id) if attributes_id: @@ -271,7 +269,7 @@ def _select_event_data_ids_to_purge( event_ids = set() data_ids = set() for event_id, data_id in session.execute( - find_events_to_purge(dt_util.utc_to_timestamp(purge_before), max_bind_vars) + find_events_to_purge(purge_before.timestamp(), max_bind_vars) ).all(): event_ids.add(event_id) if data_id: @@ -464,7 +462,7 @@ def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( """ states = session.execute( find_legacy_detached_states_and_attributes_to_purge( - dt_util.utc_to_timestamp(purge_before), max_bind_vars + purge_before.timestamp(), max_bind_vars ) ).all() _LOGGER.debug("Selected %s state ids to remove", len(states)) @@ -489,7 +487,7 @@ def _select_legacy_event_state_and_attributes_and_data_ids_to_purge( """ events = session.execute( find_legacy_event_state_and_attributes_and_data_ids_to_purge( - dt_util.utc_to_timestamp(purge_before), max_bind_vars + purge_before.timestamp(), max_bind_vars ) ).all() _LOGGER.debug("Selected %s event ids to remove", len(events)) diff --git a/homeassistant/components/renson/button.py b/homeassistant/components/renson/button.py index 117fadb502b..5cdf0e4787b 100644 --- a/homeassistant/components/renson/button.py +++ b/homeassistant/components/renson/button.py @@ -1,9 +1,9 @@ """Renson ventilation unit buttons.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from _collections_abc import Callable from renson_endura_delta.renson import RensonVentilation from homeassistant.components.button import ( diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index 7dbe295afee..e021b72ff3d 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -1,7 +1,6 @@ """Support for RESTful switches.""" from __future__ import annotations -import asyncio from http import HTTPStatus import logging from typing import Any @@ -117,7 +116,7 @@ async def async_setup_platform( "Missing resource or schema in configuration. " "Add http:// or https:// to your URL" ) - except (asyncio.TimeoutError, httpx.RequestError) as exc: + except (TimeoutError, httpx.RequestError) as exc: raise PlatformNotReady(f"No route to resource/endpoint: {resource}") from exc @@ -177,7 +176,7 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): _LOGGER.error( "Can't turn on %s. Is resource/endpoint offline?", self._resource ) - except (asyncio.TimeoutError, httpx.RequestError): + except (TimeoutError, httpx.RequestError): _LOGGER.error("Error while switching on %s", self._resource) async def async_turn_off(self, **kwargs: Any) -> None: @@ -192,7 +191,7 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): _LOGGER.error( "Can't turn off %s. Is resource/endpoint offline?", self._resource ) - except (asyncio.TimeoutError, httpx.RequestError): + except (TimeoutError, httpx.RequestError): _LOGGER.error("Error while switching off %s", self._resource) async def set_device_state(self, body: Any) -> httpx.Response: @@ -217,7 +216,7 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): req = None try: req = await self.get_device_state(self.hass) - except (asyncio.TimeoutError, httpx.TimeoutException): + except (TimeoutError, httpx.TimeoutException): _LOGGER.exception("Timed out while fetching data") except httpx.RequestError as err: _LOGGER.exception("Error while fetching data: %s", err) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index c99df16170b..199186cf222 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -1,7 +1,6 @@ """Support for exposing regular REST commands as services.""" from __future__ import annotations -import asyncio from http import HTTPStatus from json.decoder import JSONDecodeError import logging @@ -188,7 +187,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) from err return {"content": _content, "status": response.status} - except asyncio.TimeoutError as err: + except TimeoutError as err: raise HomeAssistantError( f"Timeout when calling resource '{request_url}'", translation_domain=DOMAIN, diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 42b6d9a3ecf..5b90e656911 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -285,7 +285,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: except ( SerialException, OSError, - asyncio.TimeoutError, + TimeoutError, ): reconnect_interval = config[DOMAIN][CONF_RECONNECT_INTERVAL] _LOGGER.exception( diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index 0d0cf218cd0..7917fa0bded 100644 --- a/homeassistant/components/rflink/manifest.json +++ b/homeassistant/components/rflink/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/rflink", "iot_class": "assumed_state", "loggers": ["rflink"], - "requirements": ["rflink==0.0.65"] + "requirements": ["rflink==0.0.66"] } diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index ffbc3d26421..8ddd5ffba4c 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -91,7 +91,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await async_setup_internal(hass, entry) - except asyncio.TimeoutError: + except TimeoutError: # Library currently doesn't support reload _LOGGER.error( "Connection timeout: failed to receive response from RFXtrx device" diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 12b9290af99..fe6aaf07d40 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -372,7 +372,7 @@ class OptionsFlow(config_entries.OptionsFlow): entity_registry.async_remove(entry.entity_id) # Wait for entities to finish cleanup - with suppress(asyncio.TimeoutError): + with suppress(TimeoutError): async with asyncio.timeout(10): await wait_for_entities.wait() remove_track_state_changes() @@ -407,7 +407,7 @@ class OptionsFlow(config_entries.OptionsFlow): ) # Wait for entities to finish renaming - with suppress(asyncio.TimeoutError): + with suppress(TimeoutError): async with asyncio.timeout(10): await wait_for_entities.wait() remove_track_state_changes() diff --git a/homeassistant/components/ridwell/__init__.py b/homeassistant/components/ridwell/__init__.py index 1b0a83f1c05..53575e79c45 100644 --- a/homeassistant/components/ridwell/__init__.py +++ b/homeassistant/components/ridwell/__init__.py @@ -42,7 +42,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 1 -> 2: Update unique ID of existing, single sensor entity to be consistent with # common format for platforms going forward: if version == 1: - version = entry.version = 2 + version = 2 + hass.config_entries.async_update_entry(entry, version=version) @callback def migrate_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any]: diff --git a/homeassistant/components/ring/coordinator.py b/homeassistant/components/ring/coordinator.py index 5b6412caffa..943b1c628bf 100644 --- a/homeassistant/components/ring/coordinator.py +++ b/homeassistant/components/ring/coordinator.py @@ -77,7 +77,7 @@ class RingDataCoordinator(DataUpdateCoordinator[dict[int, RingDeviceData]]): try: history_task = None async with TaskGroup() as tg: - if hasattr(device, "history"): + if device.has_capability("history"): history_task = tg.create_task( _call_api( self.hass, @@ -96,7 +96,7 @@ class RingDataCoordinator(DataUpdateCoordinator[dict[int, RingDeviceData]]): if history_task: data[device.id].history = history_task.result() except ExceptionGroup as eg: - raise eg.exceptions[0] + raise eg.exceptions[0] # noqa: B904 return data diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index a2ccb2bf444..0390db640e5 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/ring", "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], - "requirements": ["ring-doorbell[listen]==0.8.6"] + "requirements": ["ring-doorbell[listen]==0.8.7"] } diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 356eb1c2b9b..32382a2f929 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -162,6 +163,7 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = ( category=["doorbots", "authorized_doorbots", "stickup_cams"], native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, cls=RingSensor, ), diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 0b4dfa29e78..f4293213c00 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -115,6 +115,7 @@ async def setup_device( device.name, ) _LOGGER.debug(err) + await mqtt_client.async_release() raise err coordinator = RoborockDataUpdateCoordinator( hass, device, networking, product_info, mqtt_client @@ -122,9 +123,15 @@ async def setup_device( # Verify we can communicate locally - if we can't, switch to cloud api await coordinator.verify_api() coordinator.api.is_available = True + try: + await coordinator.get_maps() + except RoborockException as err: + _LOGGER.warning("Failed to get map data") + _LOGGER.debug(err) try: await coordinator.async_config_entry_first_refresh() - except ConfigEntryNotReady: + except ConfigEntryNotReady as ex: + await coordinator.release() if isinstance(coordinator.api, RoborockMqttClient): _LOGGER.warning( "Not setting up %s because the we failed to get data for the first time using the online client. " @@ -136,7 +143,7 @@ async def setup_device( # but in case if it isn't, the error can be included in debug logs for the user to grab. if coordinator.last_exception: _LOGGER.debug(coordinator.last_exception) - raise coordinator.last_exception + raise coordinator.last_exception from ex elif coordinator.last_exception: # If this is reached, we have verified that we can communicate with the Vacuum locally, # so if there is an error here - it is not a communication issue but some other problem @@ -147,20 +154,16 @@ async def setup_device( device.name, extra_error, ) - raise coordinator.last_exception + raise coordinator.last_exception from ex return coordinator async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle removal of an entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - await asyncio.gather( - *( - coordinator.release() - for coordinator in hass.data[DOMAIN][entry.entry_id].values() - ) - ) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + release_tasks = set() + for coordinator in hass.data[DOMAIN][entry.entry_id].values(): + release_tasks.add(coordinator.release()) hass.data[DOMAIN].pop(entry.entry_id) - + await asyncio.gather(*release_tasks) return unload_ok diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index cd08cf871d4..7154a36f7b8 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -59,6 +59,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): if mac := self.roborock_device_info.network_info.mac: self.device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, mac)} + # Maps from map flag to map name + self.maps: dict[int, str] = {} async def verify_api(self) -> None: """Verify that the api is reachable. If it is not, switch clients.""" @@ -77,7 +79,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): async def release(self) -> None: """Disconnect from API.""" - await self.api.async_disconnect() + await self.api.async_release() + await self.cloud_api.async_release() async def _update_device_prop(self) -> None: """Update device properties.""" @@ -107,3 +110,10 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): self.current_map = ( self.roborock_device_info.props.status.map_status - 3 ) // 4 + + async def get_maps(self) -> None: + """Add a map to the coordinators mapping.""" + maps = await self.api.get_multi_maps_list() + if maps and maps.map_info: + for roborock_map in maps.map_info: + self.maps[roborock_map.mapFlag] = roborock_map.name diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 17531f6c627..2921a372e00 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -1,5 +1,4 @@ """Support for Roborock device base class.""" - from typing import Any from roborock.api import AttributeCache, RoborockClient @@ -7,6 +6,7 @@ from roborock.cloud_api import RoborockMqttClient from roborock.command_cache import CacheableAttribute from roborock.containers import Consumable, Status from roborock.exceptions import RoborockException +from roborock.roborock_message import RoborockDataProtocol from roborock.roborock_typing import RoborockCommand from homeassistant.exceptions import HomeAssistantError @@ -24,7 +24,10 @@ class RoborockEntity(Entity): _attr_has_entity_name = True def __init__( - self, unique_id: str, device_info: DeviceInfo, api: RoborockClient + self, + unique_id: str, + device_info: DeviceInfo, + api: RoborockClient, ) -> None: """Initialize the coordinated Roborock Device.""" self._attr_unique_id = unique_id @@ -75,6 +78,9 @@ class RoborockCoordinatedEntity( self, unique_id: str, coordinator: RoborockDataUpdateCoordinator, + listener_request: list[RoborockDataProtocol] + | RoborockDataProtocol + | None = None, ) -> None: """Initialize the coordinated Roborock Device.""" RoborockEntity.__init__( @@ -85,6 +91,23 @@ class RoborockCoordinatedEntity( ) CoordinatorEntity.__init__(self, coordinator=coordinator) self._attr_unique_id = unique_id + if isinstance(listener_request, RoborockDataProtocol): + listener_request = [listener_request] + self.listener_requests = listener_request or [] + + async def async_added_to_hass(self) -> None: + """Add listeners when the device is added to hass.""" + await super().async_added_to_hass() + for listener_request in self.listener_requests: + self.api.add_listener( + listener_request, self._update_from_listener, cache=self.api.cache + ) + + async def async_will_remove_from_hass(self) -> None: + """Remove listeners when the device is removed from hass.""" + for listener_request in self.listener_requests: + self.api.remove_listener(listener_request, self._update_from_listener) + await super().async_will_remove_from_hass() @property def _device_status(self) -> Status: @@ -107,7 +130,7 @@ class RoborockCoordinatedEntity( await self.coordinator.async_refresh() return res - def _update_from_listener(self, value: Status | Consumable): + def _update_from_listener(self, value: Status | Consumable) -> None: """Update the status or consumable data from a listener and then write the new entity state.""" if isinstance(value, Status): self.coordinator.roborock_device_info.props.status = value diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index b2a14b57819..66957232679 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -66,13 +66,7 @@ class RoborockMap(RoborockCoordinatedEntity, ImageEntity): self._attr_image_last_updated = dt_util.utcnow() self.map_flag = map_flag self.cached_map = self._create_image(starting_map) - - @property - def entity_category(self) -> EntityCategory | None: - """Return diagnostic entity category for any non-selected maps.""" - if not self.is_selected: - return EntityCategory.DIAGNOSTIC - return None + self._attr_entity_category = EntityCategory.DIAGNOSTIC @property def is_selected(self) -> bool: @@ -127,42 +121,37 @@ async def create_coordinator_maps( Only one map can be loaded at a time per device. """ entities = [] - maps = await coord.cloud_api.get_multi_maps_list() - if maps is not None and maps.map_info is not None: - cur_map = coord.current_map - # This won't be None at this point as the coordinator will have run first. - assert cur_map is not None - # Sort the maps so that we start with the current map and we can skip the - # load_multi_map call. - maps_info = sorted( - maps.map_info, key=lambda data: data.mapFlag == cur_map, reverse=True + + cur_map = coord.current_map + # This won't be None at this point as the coordinator will have run first. + assert cur_map is not None + # Sort the maps so that we start with the current map and we can skip the + # load_multi_map call. + maps_info = sorted( + coord.maps.items(), key=lambda data: data[0] == cur_map, reverse=True + ) + for map_flag, map_name in maps_info: + # Load the map - so we can access it with get_map_v1 + if map_flag != cur_map: + # Only change the map and sleep if we have multiple maps. + await coord.api.send_command(RoborockCommand.LOAD_MULTI_MAP, [map_flag]) + # We cannot get the map until the roborock servers fully process the + # map change. + await asyncio.sleep(MAP_SLEEP) + # Get the map data + api_data: bytes = await coord.cloud_api.get_map_v1() + entities.append( + RoborockMap( + f"{slugify(coord.roborock_device_info.device.duid)}_map_{map_name}", + coord, + map_flag, + api_data, + map_name, + ) ) - for roborock_map in maps_info: - # Load the map - so we can access it with get_map_v1 - if roborock_map.mapFlag != cur_map: - # Only change the map and sleep if we have multiple maps. - await coord.api.send_command( - RoborockCommand.LOAD_MULTI_MAP, [roborock_map.mapFlag] - ) - # We cannot get the map until the roborock servers fully process the - # map change. - await asyncio.sleep(MAP_SLEEP) - # Get the map data - api_data: bytes = await coord.cloud_api.get_map_v1() - entities.append( - RoborockMap( - f"{slugify(coord.roborock_device_info.device.duid)}_map_{roborock_map.name}", - coord, - roborock_map.mapFlag, - api_data, - roborock_map.name, - ) - ) - if len(maps.map_info) != 1: - # Set the map back to the map the user previously had selected so that it - # does not change the end user's app. - # Only needs to happen when we changed maps above. - await coord.cloud_api.send_command( - RoborockCommand.LOAD_MULTI_MAP, [cur_map] - ) + if len(coord.maps) != 1: + # Set the map back to the map the user previously had selected so that it + # does not change the end user's app. + # Only needs to happen when we changed maps above. + await coord.cloud_api.send_command(RoborockCommand.LOAD_MULTI_MAP, [cur_map]) return entities diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index ae5dd12689d..3fdd10c97d5 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -107,10 +107,8 @@ class RoborockSelectEntity(RoborockCoordinatedEntity, SelectEntity): ) -> None: """Create a select entity.""" self.entity_description = entity_description - super().__init__(unique_id, coordinator) + super().__init__(unique_id, coordinator, entity_description.protocol_listener) self._attr_options = options - if (protocol := self.entity_description.protocol_listener) is not None: - self.api.add_listener(protocol, self._update_from_listener, self.api.cache) async def async_select_option(self, option: str) -> None: """Set the option.""" diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index d5258879acb..8d723ec57cd 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -232,10 +232,8 @@ class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity): description: RoborockSensorDescription, ) -> None: """Initialize the entity.""" - super().__init__(unique_id, coordinator) self.entity_description = description - if (protocol := self.entity_description.protocol_listener) is not None: - self.api.add_listener(protocol, self._update_from_listener, self.api.cache) + super().__init__(unique_id, coordinator, description.protocol_listener) @property def native_value(self) -> StateType | datetime.datetime: diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 3b8f0e756b7..dafbb731bd2 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -92,14 +92,16 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): ) -> None: """Initialize a vacuum.""" StateVacuumEntity.__init__(self) - RoborockCoordinatedEntity.__init__(self, unique_id, coordinator) + RoborockCoordinatedEntity.__init__( + self, + unique_id, + coordinator, + listener_request=[ + RoborockDataProtocol.FAN_POWER, + RoborockDataProtocol.STATE, + ], + ) self._attr_fan_speed_list = self._device_status.fan_power_options - self.api.add_listener( - RoborockDataProtocol.FAN_POWER, self._update_from_listener, self.api.cache - ) - self.api.add_listener( - RoborockDataProtocol.STATE, self._update_from_listener, self.api.cache - ) @property def state(self) -> str | None: diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index 586e2a5f062..bd302e16a90 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -90,7 +90,7 @@ async def async_connect_or_timeout( except RoombaConnectionError as err: _LOGGER.debug("Error to connect to vacuum: %s", err) raise CannotConnect from err - except asyncio.TimeoutError as err: + except TimeoutError as err: # api looping if user or password incorrect and roomba exist await async_disconnect_or_timeout(hass, roomba) _LOGGER.debug("Timeout expired: %s", err) @@ -102,7 +102,7 @@ async def async_connect_or_timeout( async def async_disconnect_or_timeout(hass: HomeAssistant, roomba: Roomba) -> None: """Disconnect to vacuum.""" _LOGGER.debug("Disconnect vacuum") - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): async with asyncio.timeout(3): await hass.async_add_executor_job(roomba.disconnect) diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py index df5027ebaa8..89cc22ef766 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -18,6 +18,7 @@ from .const import ( KEY_SYS_CLIENTS, UNDO_UPDATE_LISTENERS, ) +from .coordinator import RuckusUnleashedDataUpdateCoordinator _LOGGER = logging.getLogger(__package__) @@ -65,14 +66,19 @@ def add_new_entities(coordinator, async_add_entities, tracked): @callback -def restore_entities(registry, coordinator, entry, async_add_entities, tracked): +def restore_entities( + registry: er.EntityRegistry, + coordinator: RuckusUnleashedDataUpdateCoordinator, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + tracked: set[str], +) -> None: """Restore clients that are not a part of active clients list.""" - missing = [] + missing: list[RuckusUnleashedDevice] = [] - for entity in registry.entities.values(): + for entity in registry.entities.get_entries_for_config_entry_id(entry.entry_id): if ( - entity.config_entry_id == entry.entry_id - and entity.platform == DOMAIN + entity.platform == DOMAIN and entity.unique_id not in coordinator.data[KEY_SYS_CLIENTS] ): missing.append( diff --git a/homeassistant/components/ruuvitag_ble/strings.json b/homeassistant/components/ruuvitag_ble/strings.json new file mode 100644 index 00000000000..d1d544c2381 --- /dev/null +++ b/homeassistant/components/ruuvitag_ble/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "not_supported": "Device not supported", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/rympro/coordinator.py b/homeassistant/components/rympro/coordinator.py index f07929c0ab4..592c82adc68 100644 --- a/homeassistant/components/rympro/coordinator.py +++ b/homeassistant/components/rympro/coordinator.py @@ -33,7 +33,12 @@ class RymProDataUpdateCoordinator(DataUpdateCoordinator[dict[int, dict]]): async def _async_update_data(self) -> dict[int, dict]: """Fetch data from Rym Pro.""" try: - return await self.rympro.last_read() + meters = await self.rympro.last_read() + for meter_id, meter in meters.items(): + meter["consumption_forecast"] = await self.rympro.consumption_forecast( + meter_id + ) + return meters except UnauthorizedError as error: assert self.config_entry await self.hass.config_entries.async_reload(self.config_entry.entry_id) diff --git a/homeassistant/components/rympro/sensor.py b/homeassistant/components/rympro/sensor.py index 35e4b155b28..a6b5b8df93d 100644 --- a/homeassistant/components/rympro/sensor.py +++ b/homeassistant/components/rympro/sensor.py @@ -1,9 +1,12 @@ """Sensor for RymPro meters.""" from __future__ import annotations +from dataclasses import dataclass + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -17,6 +20,30 @@ from .const import DOMAIN from .coordinator import RymProDataUpdateCoordinator +@dataclass(kw_only=True, frozen=True) +class RymProSensorEntityDescription(SensorEntityDescription): + """Class describing RymPro sensor entities.""" + + value_key: str + + +SENSOR_DESCRIPTIONS: tuple[RymProSensorEntityDescription, ...] = ( + RymProSensorEntityDescription( + key="total_consumption", + translation_key="total_consumption", + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=3, + value_key="read", + ), + RymProSensorEntityDescription( + key="monthly_forecast", + translation_key="monthly_forecast", + suggested_display_precision=3, + value_key="consumption_forecast", + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -25,8 +52,9 @@ async def async_setup_entry( """Set up sensors for device.""" coordinator: RymProDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( - RymProSensor(coordinator, meter_id, meter["read"], config_entry.entry_id) + RymProSensor(coordinator, meter_id, description, config_entry.entry_id) for meter_id, meter in coordinator.data.items() + for description in SENSOR_DESCRIPTIONS ) @@ -34,32 +62,31 @@ class RymProSensor(CoordinatorEntity[RymProDataUpdateCoordinator], SensorEntity) """Sensor for RymPro meters.""" _attr_has_entity_name = True - _attr_translation_key = "total_consumption" _attr_device_class = SensorDeviceClass.WATER _attr_native_unit_of_measurement = UnitOfVolume.CUBIC_METERS - _attr_state_class = SensorStateClass.TOTAL_INCREASING + entity_description: RymProSensorEntityDescription def __init__( self, coordinator: RymProDataUpdateCoordinator, meter_id: int, - last_read: int, + description: RymProSensorEntityDescription, entry_id: str, ) -> None: """Initialize sensor.""" super().__init__(coordinator) self._meter_id = meter_id unique_id = f"{entry_id}_{meter_id}" - self._attr_unique_id = f"{unique_id}_total_consumption" + self._attr_unique_id = f"{unique_id}_{description.key}" self._attr_extra_state_attributes = {"meter_id": str(meter_id)} self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, manufacturer="Read Your Meter Pro", name=f"Meter {meter_id}", ) - self._attr_native_value = last_read + self.entity_description = description @property def native_value(self) -> float | None: """Return the state of the sensor.""" - return self.coordinator.data[self._meter_id]["read"] + return self.coordinator.data[self._meter_id][self.entity_description.value_key] diff --git a/homeassistant/components/rympro/strings.json b/homeassistant/components/rympro/strings.json index 2909d6c1b9b..c58bf5b93ba 100644 --- a/homeassistant/components/rympro/strings.json +++ b/homeassistant/components/rympro/strings.json @@ -21,6 +21,9 @@ "sensor": { "total_consumption": { "name": "Total consumption" + }, + "monthly_forecast": { + "name": "Monthly forecast" } } } diff --git a/homeassistant/components/samsam/__init__.py b/homeassistant/components/samsam/__init__.py new file mode 100644 index 00000000000..a7109c35339 --- /dev/null +++ b/homeassistant/components/samsam/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: SamSam.""" diff --git a/homeassistant/components/samsam/manifest.json b/homeassistant/components/samsam/manifest.json new file mode 100644 index 00000000000..61078e6c432 --- /dev/null +++ b/homeassistant/components/samsam/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "samsam", + "name": "SamSam", + "integration_type": "virtual", + "supported_by": "energyzero" +} diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 2ced868ada7..a70a336ebfd 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -198,10 +198,13 @@ async def _async_create_bridge_with_updated_data( mac: str | None = entry.data.get(CONF_MAC) model: str | None = entry.data.get(CONF_MODEL) - if (not mac or not model) and not load_info_attempted: + mac_is_incorrectly_formatted = mac and dr.format_mac(mac) != mac + if ( + not mac or not model or mac_is_incorrectly_formatted + ) and not load_info_attempted: info = await bridge.async_device_info() - if not mac: + if not mac or mac_is_incorrectly_formatted: LOGGER.debug("Attempting to get mac for %s", host) if info: mac = mac_from_device_info(info) @@ -215,7 +218,7 @@ async def _async_create_bridge_with_updated_data( # Samsung sometimes returns a value of "none" for the mac address # this should be ignored LOGGER.info("Updated mac to %s for %s", mac, host) - updated_data[CONF_MAC] = mac + updated_data[CONF_MAC] = dr.format_mac(mac) else: LOGGER.info("Failed to get mac for %s", host) @@ -269,7 +272,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> en_reg = er.async_get(hass) en_reg.async_clear_config_entry(config_entry.entry_id) - version = config_entry.version = 2 + version = 2 + hass.config_entries.async_update_entry(config_entry, version=2) + LOGGER.debug("Migration to version %s successful", version) return True diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 2e6f64f08e1..e7f71210dfe 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -80,6 +80,17 @@ def _entry_is_complete( ) +def _mac_is_same_with_incorrect_formatting( + current_unformatted_mac: str, formatted_mac: str +) -> bool: + """Check if two macs are the same but formatted incorrectly.""" + current_formatted_mac = format_mac(current_unformatted_mac) + return ( + current_formatted_mac == formatted_mac + and current_unformatted_mac != current_formatted_mac + ) + + class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a Samsung TV config flow.""" @@ -359,7 +370,10 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): and data.get(CONF_SSDP_MAIN_TV_AGENT_LOCATION) != self._ssdp_main_tv_agent_location ) - update_mac = self._mac and not data.get(CONF_MAC) + update_mac = self._mac and ( + not (data_mac := data.get(CONF_MAC)) + or _mac_is_same_with_incorrect_formatting(data_mac, self._mac) + ) update_model = self._model and not data.get(CONF_MODEL) if ( update_ssdp_rendering_control_location @@ -464,7 +478,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle a flow initialized by dhcp discovery.""" LOGGER.debug("Samsung device found via DHCP: %s", discovery_info) - self._mac = discovery_info.macaddress + self._mac = format_mac(discovery_info.macaddress) self._host = discovery_info.ip self._async_start_discovery_with_mac_address() await self._async_set_device_unique_id() diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 780d47e4743..00b8fec8e6a 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", - "async-upnp-client==0.38.1" + "async-upnp-client==0.38.2" ], "ssdp": [ { diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 14589274da6..44fce7f953f 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -219,7 +219,7 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): try: async with asyncio.timeout(APP_LIST_DELAY): await self._app_list_event.wait() - except asyncio.TimeoutError as err: + except TimeoutError as err: # No need to try again self._app_list_event.set() LOGGER.debug("Failed to load app list from %s: %r", self._host, err) diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 6d066f86072..56c686df6b4 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -9,13 +9,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify from .const import DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator, async_get_connect_info from .data import ENTITY_MIGRATIONS -from .services import async_load_screenlogic_services, async_unload_screenlogic_services +from .services import async_load_screenlogic_services from .util import generate_unique_id _LOGGER = logging.getLogger(__name__) @@ -36,6 +37,16 @@ PLATFORMS = [ Platform.SWITCH, ] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Screenlogic.""" + + async_load_screenlogic_services(hass) + + return True + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Screenlogic from a config entry.""" @@ -62,8 +73,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - async_load_screenlogic_services(hass, entry) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -77,8 +86,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.gateway.async_disconnect() hass.data[DOMAIN].pop(entry.entry_id) - async_unload_screenlogic_services(hass) - return unload_ok diff --git a/homeassistant/components/screenlogic/const.py b/homeassistant/components/screenlogic/const.py index 3125f52989e..104736f300b 100644 --- a/homeassistant/components/screenlogic/const.py +++ b/homeassistant/components/screenlogic/const.py @@ -20,6 +20,8 @@ DOMAIN = "screenlogic" DEFAULT_SCAN_INTERVAL = 30 MIN_SCAN_INTERVAL = 10 +ATTR_CONFIG_ENTRY = "config_entry" + SERVICE_SET_COLOR_MODE = "set_color_mode" ATTR_COLOR_MODE = "color_mode" SUPPORTED_COLOR_MODES = {slugify(cm.name): cm.value for cm in COLOR_MODE} diff --git a/homeassistant/components/screenlogic/services.py b/homeassistant/components/screenlogic/services.py index 2c8e786491c..116a66d97df 100644 --- a/homeassistant/components/screenlogic/services.py +++ b/homeassistant/components/screenlogic/services.py @@ -6,14 +6,19 @@ from screenlogicpy import ScreenLogicError from screenlogicpy.device_const.system import EQUIPMENT_FLAG import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import ( + config_validation as cv, + issue_registry as ir, + selector, +) from homeassistant.helpers.service import async_extract_config_entry_ids from .const import ( ATTR_COLOR_MODE, + ATTR_CONFIG_ENTRY, ATTR_RUNTIME, DOMAIN, MAX_RUNTIME, @@ -27,44 +32,103 @@ from .coordinator import ScreenlogicDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -SET_COLOR_MODE_SCHEMA = cv.make_entity_service_schema( +BASE_SERVICE_SCHEMA = vol.Schema( { - vol.Required(ATTR_COLOR_MODE): vol.In(SUPPORTED_COLOR_MODES), - }, + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ) + } ) -TURN_ON_SUPER_CHLOR_SCHEMA = cv.make_entity_service_schema( +SET_COLOR_MODE_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + **cv.ENTITY_SERVICE_FIELDS, + vol.Required(ATTR_COLOR_MODE): vol.In(SUPPORTED_COLOR_MODES), + } + ), + cv.has_at_least_one_key(ATTR_CONFIG_ENTRY, *cv.ENTITY_SERVICE_FIELDS), +) + +TURN_ON_SUPER_CHLOR_SCHEMA = BASE_SERVICE_SCHEMA.extend( { - vol.Optional(ATTR_RUNTIME, default=24): vol.Clamp( - min=MIN_RUNTIME, max=MAX_RUNTIME + vol.Optional(ATTR_RUNTIME, default=24): vol.All( + vol.Coerce(int), vol.Clamp(min=MIN_RUNTIME, max=MAX_RUNTIME) ), } ) @callback -def async_load_screenlogic_services(hass: HomeAssistant, entry: ConfigEntry): +def async_load_screenlogic_services(hass: HomeAssistant): """Set up services for the ScreenLogic integration.""" async def extract_screenlogic_config_entry_ids(service_call: ServiceCall): if not ( - screenlogic_entry_ids := [ - entry_id - for entry_id in await async_extract_config_entry_ids(hass, service_call) - if (entry := hass.config_entries.async_get_entry(entry_id)) - and entry.domain == DOMAIN - ] + screenlogic_entry_ids := await async_extract_config_entry_ids( + hass, service_call + ) ): - raise HomeAssistantError( - f"Failed to call service '{service_call.service}'. Config entry for" - " target not found" + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry for " + "target not found" ) return screenlogic_entry_ids + async def get_coordinators( + service_call: ServiceCall, + ) -> list[ScreenlogicDataUpdateCoordinator]: + entry_ids: set[str] + if entry_id := service_call.data.get(ATTR_CONFIG_ENTRY): + entry_ids = {entry_id} + else: + ir.async_create_issue( + hass, + DOMAIN, + "service_target_deprecation", + breaks_in_ha_version="2024.8.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_target_deprecation", + ) + entry_ids = await extract_screenlogic_config_entry_ids(service_call) + + coordinators: list[ScreenlogicDataUpdateCoordinator] = [] + for entry_id in entry_ids: + config_entry: ConfigEntry | None = hass.config_entries.async_get_entry( + entry_id + ) + if not config_entry: + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry " + f"'{entry_id}' not found" + ) + if not config_entry.domain == DOMAIN: + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry " + f"'{entry_id}' is not a {DOMAIN} config" + ) + if not config_entry.state == ConfigEntryState.LOADED: + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry " + f"'{entry_id}' not loaded" + ) + coordinators.append(hass.data[DOMAIN][entry_id]) + + return coordinators + async def async_set_color_mode(service_call: ServiceCall) -> None: color_num = SUPPORTED_COLOR_MODES[service_call.data[ATTR_COLOR_MODE]] - for entry_id in await extract_screenlogic_config_entry_ids(service_call): - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][entry_id] + coordinator: ScreenlogicDataUpdateCoordinator + for coordinator in await get_coordinators(service_call): _LOGGER.debug( "Service %s called on %s with mode %s", SERVICE_SET_COLOR_MODE, @@ -83,13 +147,19 @@ def async_load_screenlogic_services(hass: HomeAssistant, entry: ConfigEntry): is_on: bool, runtime: int | None = None, ) -> None: - for entry_id in await extract_screenlogic_config_entry_ids(service_call): - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][entry_id] + coordinator: ScreenlogicDataUpdateCoordinator + for coordinator in await get_coordinators(service_call): + if EQUIPMENT_FLAG.CHLORINATOR not in coordinator.gateway.equipment_flags: + raise ServiceValidationError( + f"Equipment configuration for {coordinator.gateway.name} does not" + f" support {service_call.service}" + ) + rt_log = f" with runtime {runtime}" if runtime else "" _LOGGER.debug( - "Service %s called on %s with runtime %s", - SERVICE_START_SUPER_CHLORINATION, + "Service %s called on %s%s", + service_call.service, coordinator.gateway.name, - runtime, + rt_log, ) try: await coordinator.gateway.async_set_scg_config( @@ -107,43 +177,20 @@ def async_load_screenlogic_services(hass: HomeAssistant, entry: ConfigEntry): async def async_stop_super_chlor(service_call: ServiceCall) -> None: await async_set_super_chlor(service_call, False) - if not hass.services.has_service(DOMAIN, SERVICE_SET_COLOR_MODE): - hass.services.async_register( - DOMAIN, SERVICE_SET_COLOR_MODE, async_set_color_mode, SET_COLOR_MODE_SCHEMA - ) + hass.services.async_register( + DOMAIN, SERVICE_SET_COLOR_MODE, async_set_color_mode, SET_COLOR_MODE_SCHEMA + ) - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - equipment_flags = coordinator.gateway.equipment_flags + hass.services.async_register( + DOMAIN, + SERVICE_START_SUPER_CHLORINATION, + async_start_super_chlor, + TURN_ON_SUPER_CHLOR_SCHEMA, + ) - if EQUIPMENT_FLAG.CHLORINATOR in equipment_flags: - if not hass.services.has_service(DOMAIN, SERVICE_START_SUPER_CHLORINATION): - hass.services.async_register( - DOMAIN, - SERVICE_START_SUPER_CHLORINATION, - async_start_super_chlor, - TURN_ON_SUPER_CHLOR_SCHEMA, - ) - - if not hass.services.has_service(DOMAIN, SERVICE_STOP_SUPER_CHLORINATION): - hass.services.async_register( - DOMAIN, - SERVICE_STOP_SUPER_CHLORINATION, - async_stop_super_chlor, - ) - - -@callback -def async_unload_screenlogic_services(hass: HomeAssistant): - """Unload services for the ScreenLogic integration.""" - - if not hass.data[DOMAIN]: - _LOGGER.debug("Unloading all ScreenLogic services") - for service in hass.services.async_services_for_domain(DOMAIN): - hass.services.async_remove(DOMAIN, service) - elif not any( - EQUIPMENT_FLAG.CHLORINATOR in coordinator.gateway.equipment_flags - for coordinator in hass.data[DOMAIN].values() - ): - _LOGGER.debug("Unloading ScreenLogic chlorination services") - hass.services.async_remove(DOMAIN, SERVICE_START_SUPER_CHLORINATION) - hass.services.async_remove(DOMAIN, SERVICE_STOP_SUPER_CHLORINATION) + hass.services.async_register( + DOMAIN, + SERVICE_STOP_SUPER_CHLORINATION, + async_stop_super_chlor, + BASE_SERVICE_SCHEMA, + ) diff --git a/homeassistant/components/screenlogic/services.yaml b/homeassistant/components/screenlogic/services.yaml index 7b51d1a21db..f05537640ca 100644 --- a/homeassistant/components/screenlogic/services.yaml +++ b/homeassistant/components/screenlogic/services.yaml @@ -1,9 +1,11 @@ # ScreenLogic Services set_color_mode: - target: - device: - integration: screenlogic fields: + config_entry: + required: false + selector: + config_entry: + integration: screenlogic color_mode: required: true selector: @@ -32,10 +34,12 @@ set_color_mode: - thumper - white start_super_chlorination: - target: - device: - integration: screenlogic fields: + config_entry: + required: true + selector: + config_entry: + integration: screenlogic runtime: default: 24 selector: @@ -45,6 +49,9 @@ start_super_chlorination: unit_of_measurement: hours mode: slider stop_super_chlorination: - target: - device: - integration: screenlogic + fields: + config_entry: + required: true + selector: + config_entry: + integration: screenlogic diff --git a/homeassistant/components/screenlogic/strings.json b/homeassistant/components/screenlogic/strings.json index fcddbc1d415..755eeb4ffb2 100644 --- a/homeassistant/components/screenlogic/strings.json +++ b/homeassistant/components/screenlogic/strings.json @@ -41,6 +41,10 @@ "name": "Set Color Mode", "description": "Sets the color mode for all color-capable lights attached to this ScreenLogic gateway.", "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + }, "color_mode": { "name": "Color Mode", "description": "The ScreenLogic color mode to set." @@ -51,6 +55,10 @@ "name": "Start Super Chlorination", "description": "Begins super chlorination, running for the specified period or 24 hours if none is specified.", "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + }, "runtime": { "name": "Run Time", "description": "Number of hours for super chlorination to run." @@ -59,7 +67,26 @@ }, "stop_super_chlorination": { "name": "Stop Super Chlorination", - "description": "Stops super chlorination." + "description": "Stops super chlorination.", + "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + } + } + } + }, + "issues": { + "service_target_deprecation": { + "title": "Deprecating use of target for ScreenLogic services", + "fix_flow": { + "step": { + "confirm": { + "title": "Deprecating target for ScreenLogic services", + "description": "Use of an Area, Device, or Entity as a target for ScreenLogic services is being deprecated. Instead, use `config_entry` with the entry_id of the desired ScreenLogic integration.\n\nPlease update your automations and scripts and select **submit** to fix this issue." + } + } + } } } } diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py index cfe1a12a24f..3ad35ff345d 100644 --- a/homeassistant/components/sense/const.py +++ b/homeassistant/components/sense/const.py @@ -1,6 +1,5 @@ """Constants for monitoring a Sense energy sensor.""" -import asyncio import socket from sense_energy import ( @@ -39,11 +38,11 @@ FROM_GRID_ID = "from_grid" SOLAR_POWERED_NAME = "Solar Powered Percentage" SOLAR_POWERED_ID = "solar_powered" -SENSE_TIMEOUT_EXCEPTIONS = (asyncio.TimeoutError, SenseAPITimeoutException) +SENSE_TIMEOUT_EXCEPTIONS = (TimeoutError, SenseAPITimeoutException) SENSE_WEBSOCKET_EXCEPTIONS = (socket.gaierror, SenseWebsocketException) SENSE_CONNECT_EXCEPTIONS = ( socket.gaierror, - asyncio.TimeoutError, + TimeoutError, SenseAPITimeoutException, SenseAPIException, ) diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py index 923bc3eae1f..9a278d0c4df 100644 --- a/homeassistant/components/sensibo/__init__.py +++ b/homeassistant/components/sensibo/__init__.py @@ -47,12 +47,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (AuthenticationError, ConnectionError, NoDevicesError, NoUsernameError): return False - entry.version = 2 - LOGGER.debug("Migrate Sensibo config entry unique id to %s", new_unique_id) hass.config_entries.async_update_entry( entry, unique_id=new_unique_id, + version=2, ) return True diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index bcc851e02ae..0ad2a0a714f 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -173,9 +173,9 @@ async def async_setup_entry( platform.async_register_entity_service( SERVICE_ENABLE_CLIMATE_REACT, { - vol.Required(ATTR_HIGH_TEMPERATURE_THRESHOLD): float, + vol.Required(ATTR_HIGH_TEMPERATURE_THRESHOLD): vol.Coerce(float), vol.Required(ATTR_HIGH_TEMPERATURE_STATE): dict, - vol.Required(ATTR_LOW_TEMPERATURE_THRESHOLD): float, + vol.Required(ATTR_LOW_TEMPERATURE_THRESHOLD): vol.Coerce(float), vol.Required(ATTR_LOW_TEMPERATURE_STATE): dict, vol.Required(ATTR_SMART_TYPE): vol.In( ["temperature", "feelsLike", "humidity"] diff --git a/homeassistant/components/sensibo/const.py b/homeassistant/components/sensibo/const.py index d6dbe957def..0b5f151c49f 100644 --- a/homeassistant/components/sensibo/const.py +++ b/homeassistant/components/sensibo/const.py @@ -1,6 +1,5 @@ """Constants for Sensibo.""" -import asyncio import logging from aiohttp.client_exceptions import ClientConnectionError @@ -27,7 +26,7 @@ TIMEOUT = 8 SENSIBO_ERRORS = ( ClientConnectionError, - asyncio.TimeoutError, + TimeoutError, AuthenticationError, SensiboError, ) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index aad882821d6..3dc8f878791 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -640,7 +640,11 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, }, - SensorDeviceClass.WEIGHT: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.WEIGHT: { + SensorStateClass.MEASUREMENT, + SensorStateClass.TOTAL, + SensorStateClass.TOTAL_INCREASING, + }, SensorDeviceClass.WIND_SPEED: {SensorStateClass.MEASUREMENT}, } diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index 24245d9bf03..f23826cfe95 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -117,7 +117,7 @@ "speed": { "default": "mdi:speedometer" }, - "sulfur_dioxide": { + "sulphur_dioxide": { "default": "mdi:molecule" }, "temperature": { diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 1aba934aba4..a53ae906718 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -36,7 +36,13 @@ from homeassistant.loader import async_suggest_report_issue from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum -from .const import ATTR_LAST_RESET, ATTR_STATE_CLASS, DOMAIN, SensorStateClass +from .const import ( + ATTR_LAST_RESET, + ATTR_STATE_CLASS, + DOMAIN, + SensorStateClass, + UnitOfVolumeFlowRate, +) _LOGGER = logging.getLogger(__name__) @@ -52,6 +58,7 @@ EQUIVALENT_UNITS = { "RPM": REVOLUTIONS_PER_MINUTE, "ft3": UnitOfVolume.CUBIC_FEET, "m3": UnitOfVolume.CUBIC_METERS, + "ft³/m": UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, } # Keep track of entities for which a warning about decreasing value has been logged @@ -141,36 +148,28 @@ def _equivalent_units(units: set[str | None]) -> bool: if len(units) == 1: return True units = { - EQUIVALENT_UNITS[unit] if unit in EQUIVALENT_UNITS else unit for unit in units + EQUIVALENT_UNITS[unit] if unit in EQUIVALENT_UNITS else unit # noqa: SIM401 + for unit in units } return len(units) == 1 -def _parse_float(state: str) -> float: - """Parse a float string, throw on inf or nan.""" - fstate = float(state) - if not math.isfinite(fstate): - raise ValueError - return fstate - - -def _float_or_none(state: str) -> float | None: - """Return a float or None.""" - try: - return _parse_float(state) - except (ValueError, TypeError): - return None - - def _entity_history_to_float_and_state( entity_history: Iterable[State], ) -> list[tuple[float, State]]: """Return a list of (float, state) tuples for the given entity.""" - return [ - (fstate, state) - for state in entity_history - if (fstate := _float_or_none(state.state)) is not None - ] + float_states: list[tuple[float, State]] = [] + append = float_states.append + isfinite = math.isfinite + for state in entity_history: + try: + if (float_state := float(state.state)) is not None and isfinite( + float_state + ): + append((float_state, state)) + except (ValueError, TypeError): + pass + return float_states def _normalize_states( @@ -224,13 +223,14 @@ def _normalize_states( converter = statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER[statistics_unit] valid_fstates: list[tuple[float, State]] = [] - convert: Callable[[float], float] + convert: Callable[[float], float] | None = None last_unit: str | None | object = object() + valid_units = converter.VALID_UNITS for fstate, state in fstates: state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) # Exclude states with unsupported unit from statistics - if state_unit not in converter.VALID_UNITS: + if state_unit not in valid_units: if WARN_UNSUPPORTED_UNIT not in hass.data: hass.data[WARN_UNSUPPORTED_UNIT] = set() if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: @@ -249,13 +249,20 @@ def _normalize_states( LINK_DEV_STATISTICS, ) continue + if state_unit != last_unit: # The unit of measurement has changed since the last state change # recreate the converter factory - convert = converter.converter_factory(state_unit, statistics_unit) + if state_unit == statistics_unit: + convert = None + else: + convert = converter.converter_factory(state_unit, statistics_unit) last_unit = state_unit - valid_fstates.append((convert(fstate), state)) + if convert is not None: + fstate = convert(fstate) + + valid_fstates.append((fstate, state)) return statistics_unit, valid_fstates diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index e0a2d5f75c4..425225e07ef 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.40.0"] + "requirements": ["sentry-sdk==1.40.3"] } diff --git a/homeassistant/components/sharkiq/__init__.py b/homeassistant/components/sharkiq/__init__.py index f80e7acf9a6..53a8c4cba3d 100644 --- a/homeassistant/components/sharkiq/__init__.py +++ b/homeassistant/components/sharkiq/__init__.py @@ -40,7 +40,7 @@ async def async_connect_or_timeout(ayla_api: AylaApi) -> bool: except SharkIqAuthError: LOGGER.error("Authentication error connecting to Shark IQ api") return False - except asyncio.TimeoutError as exc: + except TimeoutError as exc: LOGGER.error("Timeout expired") raise CannotConnect from exc diff --git a/homeassistant/components/sharkiq/config_flow.py b/homeassistant/components/sharkiq/config_flow.py index 1957d12048f..c0ca5e1b9e5 100644 --- a/homeassistant/components/sharkiq/config_flow.py +++ b/homeassistant/components/sharkiq/config_flow.py @@ -53,7 +53,7 @@ async def _validate_input( async with asyncio.timeout(10): LOGGER.debug("Initialize connection to Ayla networks API") await ayla_api.async_sign_in() - except (asyncio.TimeoutError, aiohttp.ClientError, TypeError) as error: + except (TimeoutError, aiohttp.ClientError, TypeError) as error: LOGGER.error(error) raise CannotConnect( "Unable to connect to SharkIQ services. Check your region settings." diff --git a/homeassistant/components/shell_command/__init__.py b/homeassistant/components/shell_command/__init__.py index 67258d701e9..5aa8dadee19 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -90,7 +90,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: try: async with asyncio.timeout(COMMAND_TIMEOUT): stdout_data, stderr_data = await process.communicate() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error( "Timed out running command: `%s`, after: %ss", cmd, COMMAND_TIMEOUT ) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 17f60f566aa..f4294dee9ee 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass +from functools import partial from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar from aioshelly.const import RPC_GENERATIONS @@ -57,7 +58,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ ShellyButtonDescription[ShellyBlockCoordinator]( key="self_test", name="Self test", - icon="mdi:progress-wrench", + translation_key="self_test", entity_category=EntityCategory.DIAGNOSTIC, press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_self_test(), supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, @@ -65,7 +66,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ ShellyButtonDescription[ShellyBlockCoordinator]( key="mute", name="Mute", - icon="mdi:volume-mute", + translation_key="mute", entity_category=EntityCategory.CONFIG, press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_mute(), supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, @@ -73,7 +74,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ ShellyButtonDescription[ShellyBlockCoordinator]( key="unmute", name="Unmute", - icon="mdi:volume-high", + translation_key="unmute", entity_category=EntityCategory.CONFIG, press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_unmute(), supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, @@ -83,8 +84,8 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ @callback def async_migrate_unique_ids( - entity_entry: er.RegistryEntry, coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator, + entity_entry: er.RegistryEntry, ) -> dict[str, Any] | None: """Migrate button unique IDs.""" if not entity_entry.entity_id.startswith("button"): @@ -117,35 +118,25 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set buttons for device.""" - - @callback - def _async_migrate_unique_ids( - entity_entry: er.RegistryEntry, - ) -> dict[str, Any] | None: - """Migrate button unique IDs.""" - if TYPE_CHECKING: - assert coordinator is not None - return async_migrate_unique_ids(entity_entry, coordinator) - - coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None = None + entry_data = get_entry_data(hass)[config_entry.entry_id] + coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None if get_device_entry_gen(config_entry) in RPC_GENERATIONS: - coordinator = get_entry_data(hass)[config_entry.entry_id].rpc + coordinator = entry_data.rpc else: - coordinator = get_entry_data(hass)[config_entry.entry_id].block + coordinator = entry_data.block - if coordinator is not None: - await er.async_migrate_entries( - hass, config_entry.entry_id, _async_migrate_unique_ids - ) + if TYPE_CHECKING: + assert coordinator is not None - entities: list[ShellyButton] = [] + await er.async_migrate_entries( + hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator) + ) - for button in BUTTONS: - if not button.supported(coordinator): - continue - entities.append(ShellyButton(coordinator, button)) - - async_add_entities(entities) + async_add_entities( + ShellyButton(coordinator, button) + for button in BUTTONS + if button.supported(coordinator) + ) class ShellyButton( diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 59343ca6d2f..3ceb38c84c3 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -43,7 +43,12 @@ from .const import ( ) from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ShellyRpcEntity -from .utils import async_remove_shelly_entity, get_device_entry_gen, get_rpc_key_ids +from .utils import ( + async_remove_shelly_entity, + get_device_entry_gen, + get_rpc_key_ids, + is_rpc_thermostat_internal_actuator, +) async def async_setup_entry( @@ -127,7 +132,7 @@ def async_setup_rpc_entry( for id_ in climate_key_ids: climate_ids.append(id_) - if coordinator.device.shelly.get("relay_in_thermostat", False): + if is_rpc_thermostat_internal_actuator(coordinator.device.status): # Wall Display relay is used as the thermostat actuator, # we need to remove a switch entity unique_id = f"{coordinator.mac}-switch:{id_}" @@ -156,7 +161,6 @@ class BlockSleepingClimate( """Representation of a Shelly climate device.""" _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] - _attr_icon = "mdi:thermostat" _attr_max_temp = SHTRV_01_TEMPERATURE_SETTINGS["max"] _attr_min_temp = SHTRV_01_TEMPERATURE_SETTINGS["min"] _attr_supported_features = ( @@ -439,7 +443,6 @@ class BlockSleepingClimate( class RpcClimate(ShellyRpcEntity, ClimateEntity): """Entity that controls a thermostat on RPC based Shelly devices.""" - _attr_icon = "mdi:thermostat" _attr_max_temp = RPC_THERMOSTAT_SETTINGS["max"] _attr_min_temp = RPC_THERMOSTAT_SETTINGS["min"] _attr_supported_features = ( diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 3dd156e9e30..846c527a5f8 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -273,7 +273,6 @@ class BlockEntityDescription(EntityDescription): # restrict the type to str. name: str = "" - icon_fn: Callable[[dict], str] | None = None unit_fn: Callable[[dict], str] | None = None value: Callable[[Any], Any] = lambda val: val available: Callable[[Block], bool] | None = None diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json new file mode 100644 index 00000000000..1baf61acf3b --- /dev/null +++ b/homeassistant/components/shelly/icons.json @@ -0,0 +1,46 @@ +{ + "entity": { + "button": { + "mute": { + "default": "mdi:volume-mute" + }, + "self_test": { + "default": "mdi:progress-wrench" + }, + "unmute": { + "default": "mdi:volume-high" + } + }, + "number": { + "valve_position": { + "default": "mdi:pipe-valve" + } + }, + "sensor": { + "gas_concentration": { + "default": "mdi:gauge" + }, + "lamp_life": { + "default": "mdi:progress-wrench" + }, + "operation": { + "default": "mdi:cog-transfer" + }, + "tilt": { + "default": "mdi:angle-acute" + }, + "valve_status": { + "default": "mdi:valve" + } + }, + "switch": { + "valve_switch": { + "default": "mdi:valve", + "state": { + "off": "mdi:valve-closed", + "on": "mdi:valve-open" + } + } + } + } +} diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 4cab817e67c..ef3963c53c3 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -40,7 +40,7 @@ class BlockNumberDescription(BlockEntityDescription, NumberEntityDescription): NUMBERS: dict[tuple[str, str], BlockNumberDescription] = { ("device", "valvePos"): BlockNumberDescription( key="device|valvepos", - icon="mdi:pipe-valve", + translation_key="valve_position", name="Valve position", native_unit_of_measurement=PERCENTAGE, available=lambda block: cast(int, block.valveError) != 1, diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index e46800963a3..399ae27e853 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -235,7 +235,7 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { key="sensor|concentration", name="Gas concentration", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - icon="mdi:gauge", + translation_key="gas_concentration", state_class=SensorStateClass.MEASUREMENT, ), ("sensor", "temp"): BlockSensorDescription( @@ -279,14 +279,14 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { key="sensor|tilt", name="Tilt", native_unit_of_measurement=DEGREE, - icon="mdi:angle-acute", + translation_key="tilt", state_class=SensorStateClass.MEASUREMENT, ), ("relay", "totalWorkTime"): BlockSensorDescription( key="relay|totalWorkTime", name="Lamp life", native_unit_of_measurement=PERCENTAGE, - icon="mdi:progress-wrench", + translation_key="lamp_life", value=lambda value: 100 - (value / 3600 / SHAIR_MAX_WORK_HOURS), suggested_display_precision=1, extra_state_attributes=lambda block: { @@ -308,7 +308,6 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { device_class=SensorDeviceClass.ENUM, options=["unknown", "warmup", "normal", "fault"], translation_key="operation", - icon="mdi:cog-transfer", value=lambda value: value, extra_state_attributes=lambda block: {"self_test": block.selfTest}, ), @@ -316,7 +315,6 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { key="valve|valve", name="Valve status", translation_key="valve_status", - icon="mdi:valve", device_class=SensorDeviceClass.ENUM, options=[ "checking", @@ -959,6 +957,34 @@ RPC_SENSORS: Final = { name="Analog input", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + removal_condition=lambda config, _status, key: (config[key]["enable"] is False), + ), + "analoginput_xpercent": RpcSensorDescription( + key="input", + sub_key="xpercent", + name="Analog value", + removal_condition=lambda config, status, key: ( + config[key]["enable"] is False or status[key].get("xpercent") is None + ), + ), + "pulse_counter": RpcSensorDescription( + key="input", + sub_key="counts", + name="Pulse counter", + native_unit_of_measurement="pulse", + state_class=SensorStateClass.TOTAL, + value=lambda status, _: status["total"], + removal_condition=lambda config, _status, key: (config[key]["enable"] is False), + ), + "counter_value": RpcSensorDescription( + key="input", + sub_key="counts", + name="Counter value", + value=lambda status, _: status["xtotal"], + removal_condition=lambda config, status, key: ( + config[key]["enable"] is False + or status[key]["counts"].get("xtotal") is None + ), ), } diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index e5d91943a55..a45fd9295f2 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -5,7 +5,13 @@ from dataclasses import dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import MODEL_2, MODEL_25, MODEL_GAS, RPC_GENERATIONS +from aioshelly.const import ( + MODEL_2, + MODEL_25, + MODEL_GAS, + MODEL_WALL_DISPLAY, + RPC_GENERATIONS, +) from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity @@ -20,7 +26,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .const import DOMAIN, GAS_VALVE_OPEN_STATES, MODEL_WALL_DISPLAY +from .const import DOMAIN, GAS_VALVE_OPEN_STATES from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ( BlockEntityDescription, @@ -35,6 +41,7 @@ from .utils import ( get_rpc_key_ids, is_block_channel_type_light, is_rpc_channel_type_light, + is_rpc_thermostat_internal_actuator, ) @@ -128,7 +135,7 @@ def async_setup_rpc_entry( continue if coordinator.model == MODEL_WALL_DISPLAY: - if not coordinator.device.shelly.get("relay_in_thermostat", False): + if not is_rpc_thermostat_internal_actuator(coordinator.device.status): # Wall Display relay is not used as the thermostat actuator, # we need to remove a climate entity unique_id = f"{coordinator.mac}-thermostat:{id_}" @@ -153,6 +160,7 @@ class BlockValveSwitch(ShellyBlockAttributeEntity, SwitchEntity): """ entity_description: BlockSwitchDescription + _attr_translation_key = "valve_switch" def __init__( self, @@ -173,11 +181,6 @@ class BlockValveSwitch(ShellyBlockAttributeEntity, SwitchEntity): 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.""" async_create_issue( diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index f5196504fe6..652b6cf99ed 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -367,6 +367,11 @@ def is_rpc_channel_type_light(config: dict[str, Any], channel: int) -> bool: return cast(str, con_types[channel]).lower().startswith("light") +def is_rpc_thermostat_internal_actuator(status: dict[str, Any]) -> bool: + """Return true if the thermostat uses an internal relay.""" + return cast(bool, status["sys"].get("relay_in_thermostat", False)) + + def get_rpc_input_triggers(device: RpcDevice) -> list[tuple[str, str]]: """Return list of input triggers for RPC device.""" triggers = [] diff --git a/homeassistant/components/smart_meter_texas/__init__.py b/homeassistant/components/smart_meter_texas/__init__.py index d1a3d5ae95f..47b74c53db6 100644 --- a/homeassistant/components/smart_meter_texas/__init__.py +++ b/homeassistant/components/smart_meter_texas/__init__.py @@ -1,5 +1,4 @@ """The Smart Meter Texas integration.""" -import asyncio import logging import ssl @@ -47,7 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SmartMeterTexasAuthError: _LOGGER.error("Username or password was not accepted") return False - except asyncio.TimeoutError as error: + except TimeoutError as error: raise ConfigEntryNotReady from error await smart_meter_texas_data.setup() diff --git a/homeassistant/components/smart_meter_texas/config_flow.py b/homeassistant/components/smart_meter_texas/config_flow.py index 53428131e17..dc0e4e93eff 100644 --- a/homeassistant/components/smart_meter_texas/config_flow.py +++ b/homeassistant/components/smart_meter_texas/config_flow.py @@ -1,5 +1,4 @@ """Config flow for Smart Meter Texas integration.""" -import asyncio import logging from aiohttp import ClientError @@ -36,7 +35,7 @@ async def validate_input(hass: core.HomeAssistant, data): try: await client.authenticate() - except (asyncio.TimeoutError, ClientError, SmartMeterTexasAPIError) as error: + except (TimeoutError, ClientError, SmartMeterTexasAPIError) as error: raise CannotConnect from error except SmartMeterTexasAuthError as error: raise InvalidAuth(error) from error diff --git a/homeassistant/components/smarttub/controller.py b/homeassistant/components/smarttub/controller.py index 72157e086e3..353e2093997 100644 --- a/homeassistant/components/smarttub/controller.py +++ b/homeassistant/components/smarttub/controller.py @@ -56,7 +56,7 @@ class SmartTubController: # credentials were changed or invalidated, we need new ones raise ConfigEntryAuthFailed from ex except ( - asyncio.TimeoutError, + TimeoutError, client_exceptions.ClientOSError, client_exceptions.ServerDisconnectedError, client_exceptions.ContentTypeError, diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 5b3f60f4b08..1dbfb5ecedd 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -40,9 +40,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: }, } - if not hass.config_entries.async_update_entry(entry, data=new_data): + if not hass.config_entries.async_update_entry(entry, data=new_data, version=2): return False - entry.version = 2 - return True diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index 05683f19b11..5814db8168e 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -171,7 +171,7 @@ class SmhiWeather(WeatherEntity): self._forecast_daily = await self._smhi_api.async_get_forecast() self._forecast_hourly = await self._smhi_api.async_get_forecast_hour() self._fail_count = 0 - except (asyncio.TimeoutError, SmhiForecastException): + except (TimeoutError, SmhiForecastException): _LOGGER.error("Failed to connect to SMHI API, retry in 5 minutes") self._fail_count += 1 if self._fail_count < 3: diff --git a/homeassistant/components/snooz/config_flow.py b/homeassistant/components/snooz/config_flow.py index d2188eeec73..7174fbc358c 100644 --- a/homeassistant/components/snooz/config_flow.py +++ b/homeassistant/components/snooz/config_flow.py @@ -144,7 +144,7 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): try: await self._pairing_task - except asyncio.TimeoutError: + except TimeoutError: return self.async_show_progress_done(next_step_id="pairing_timeout") finally: self._pairing_task = None diff --git a/homeassistant/components/somfy_mylink/__init__.py b/homeassistant/components/somfy_mylink/__init__.py index 8ac6c4672fd..7883c88f0b8 100644 --- a/homeassistant/components/somfy_mylink/__init__.py +++ b/homeassistant/components/somfy_mylink/__init__.py @@ -1,5 +1,4 @@ """Component for the Somfy MyLink device supporting the Synergy API.""" -import asyncio import logging from somfy_mylink_synergy import SomfyMyLinkSynergy @@ -30,7 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: mylink_status = await somfy_mylink.status_info() - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise ConfigEntryNotReady( "Unable to connect to the Somfy MyLink device, please check your settings" ) from ex diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index de38ac271ce..e42191c1230 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Somfy MyLink integration.""" from __future__ import annotations -import asyncio from copy import deepcopy import logging @@ -40,7 +39,7 @@ async def validate_input(hass: core.HomeAssistant, data): try: status_info = await somfy_mylink.status_info() - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise CannotConnect from ex if not status_info or "error" in status_info: diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py index c592e8435c2..69d2ba76e22 100644 --- a/homeassistant/components/sonarr/__init__.py +++ b/homeassistant/components/sonarr/__init__.py @@ -104,8 +104,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: **entry.data, CONF_URL: f"{new_proto}://{new_host_port}{new_path}", } - hass.config_entries.async_update_entry(entry, data=data) - entry.version = 2 + hass.config_entries.async_update_entry(entry, data=data, version=2) LOGGER.info("Migration to version %s successful", entry.version) diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 7a8ced30eb7..582e62a67eb 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -72,7 +72,7 @@ async def async_setup_entry( 10 ): # set timeout to avoid blocking the setup process await device.get_supported_methods() - except (SongpalException, asyncio.TimeoutError) as ex: + except (SongpalException, TimeoutError) as ex: _LOGGER.warning("[%s(%s)] Unable to connect", name, endpoint) _LOGGER.debug("Unable to get methods from songpal: %s", ex) raise PlatformNotReady from ex diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index c79856c58b6..0df6a7422fe 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -393,7 +393,7 @@ class SonosDiscoveryManager: OSError, SoCoException, Timeout, - asyncio.TimeoutError, + TimeoutError, ) as ex: if not self.hosts_in_error.get(ip_addr): _LOGGER.warning( @@ -447,7 +447,7 @@ class SonosDiscoveryManager: OSError, SoCoException, Timeout, - asyncio.TimeoutError, + TimeoutError, ) as ex: _LOGGER.warning("Discovery message failed to %s : %s", ip_addr, ex) elif not known_speaker.available: diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index fea5b5de7de..1ffb45dd764 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -1126,7 +1126,7 @@ class SonosSpeaker: async with asyncio.timeout(5): while not _test_groups(groups): await hass.data[DATA_SONOS].topology_condition.wait() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Timeout waiting for target groups %s", groups) any_speaker = next(iter(hass.data[DATA_SONOS].discovered.values())) diff --git a/homeassistant/components/spider/climate.py b/homeassistant/components/spider/climate.py index 1498c4b0039..15ba19e9b3a 100644 --- a/homeassistant/components/spider/climate.py +++ b/homeassistant/components/spider/climate.py @@ -43,6 +43,7 @@ class SpiderThermostat(ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False def __init__(self, api, thermostat): """Initialize the thermostat.""" @@ -53,6 +54,13 @@ class SpiderThermostat(ClimateEntity): for operation_value in thermostat.operation_values: if operation_value in SPIDER_STATE_TO_HA: self.support_hvac.append(SPIDER_STATE_TO_HA[operation_value]) + self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE + if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + if thermostat.has_fan_mode: + self._attr_supported_features |= ClimateEntityFeature.FAN_MODE @property def device_info(self) -> DeviceInfo: @@ -65,15 +73,6 @@ class SpiderThermostat(ClimateEntity): name=self.thermostat.name, ) - @property - def supported_features(self) -> ClimateEntityFeature: - """Return the list of supported features.""" - if self.thermostat.has_fan_mode: - return ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE - ) - return ClimateEntityFeature.TARGET_TEMPERATURE - @property def unique_id(self): """Return the id of the thermostat, if any.""" diff --git a/homeassistant/components/splunk/__init__.py b/homeassistant/components/splunk/__init__.py index 1a2a868608e..32b63c42370 100644 --- a/homeassistant/components/splunk/__init__.py +++ b/homeassistant/components/splunk/__init__.py @@ -1,5 +1,4 @@ """Support to send data to a Splunk instance.""" -import asyncio from http import HTTPStatus import json import logging @@ -120,7 +119,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.warning(err) except ClientConnectionError as err: _LOGGER.warning(err) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Connection to %s:%s timed out", host, port) except ClientResponseError as err: _LOGGER.error(err.message) diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 1188a9ec05e..b440b795e0e 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.25", "sqlparse==0.4.4"] + "requirements": ["SQLAlchemy==2.0.27", "sqlparse==0.4.4"] } diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index b155c7eddc0..d2786bf213b 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -140,7 +140,7 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async with asyncio.timeout(TIMEOUT): await self._discover() return await self.async_step_edit() - except asyncio.TimeoutError: + except TimeoutError: errors["base"] = "no_server_found" # display the form diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 8afed8b4fd1..2737565822d 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.38.1"] + "requirements": ["async-upnp-client==0.38.2"] } diff --git a/homeassistant/components/starline/device_tracker.py b/homeassistant/components/starline/device_tracker.py index ca8118d6b43..1ddcbc9373b 100644 --- a/homeassistant/components/starline/device_tracker.py +++ b/homeassistant/components/starline/device_tracker.py @@ -44,7 +44,7 @@ class StarlineDeviceTracker(StarlineEntity, TrackerEntity, RestoreEntity): @property def location_accuracy(self): """Return the gps accuracy of the device.""" - return self._device.position["r"] if "r" in self._device.position else 0 + return self._device.position.get("r", 0) @property def latitude(self): diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 1a43601940e..4f02ee1a1f6 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -139,7 +139,7 @@ class StarlineSensor(StarlineEntity, SensorEntity): if self._key == "mileage" and self._device.mileage: return self._device.mileage.get("val") if self._key == "gps_count" and self._device.position: - return self._device.position["sat_qty"] + return self._device.position.get("sat_qty") return None @property diff --git a/homeassistant/components/steamist/const.py b/homeassistant/components/steamist/const.py index cacd79b77ac..ae75193a3cc 100644 --- a/homeassistant/components/steamist/const.py +++ b/homeassistant/components/steamist/const.py @@ -1,12 +1,11 @@ """Constants for the Steamist integration.""" -import asyncio import aiohttp DOMAIN = "steamist" -CONNECTION_EXCEPTIONS = (asyncio.TimeoutError, aiohttp.ClientError) +CONNECTION_EXCEPTIONS = (TimeoutError, aiohttp.ClientError) STARTUP_SCAN_TIMEOUT = 5 DISCOVER_SCAN_TIMEOUT = 10 diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 5768f886adb..1d2957b35a3 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -333,7 +333,7 @@ class StreamOutput: try: async with asyncio.timeout(timeout): await self._part_event.wait() - except asyncio.TimeoutError: + except TimeoutError: return False return True diff --git a/homeassistant/components/suez_water/__init__.py b/homeassistant/components/suez_water/__init__.py index 66c3981705c..02d78dfee41 100644 --- a/homeassistant/components/suez_water/__init__.py +++ b/homeassistant/components/suez_water/__init__.py @@ -28,8 +28,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not client.check_credentials(): raise ConfigEntryError return client - except PySuezError: - raise ConfigEntryNotReady + except PySuezError as ex: + raise ConfigEntryNotReady from ex hass.data.setdefault(DOMAIN, {})[ entry.entry_id diff --git a/homeassistant/components/suez_water/config_flow.py b/homeassistant/components/suez_water/config_flow.py index ba288c90e34..d01b8035a0c 100644 --- a/homeassistant/components/suez_water/config_flow.py +++ b/homeassistant/components/suez_water/config_flow.py @@ -40,8 +40,8 @@ def validate_input(data: dict[str, Any]) -> None: ) if not client.check_credentials(): raise InvalidAuth - except PySuezError: - raise CannotConnect + except PySuezError as ex: + raise CannotConnect from ex class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index d87b711e376..21b51d33f5c 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -107,8 +107,9 @@ async def async_migrate_entry( ) # Set a valid unique id for config entries - config_entry.minor_version = 2 - hass.config_entries.async_update_entry(config_entry, unique_id=new_unique_id) + hass.config_entries.async_update_entry( + config_entry, unique_id=new_unique_id, minor_version=2 + ) _LOGGER.debug( "Migration to version %s.%s successful", diff --git a/homeassistant/components/switch/icons.json b/homeassistant/components/switch/icons.json index 00520914b9f..fbc1af5a126 100644 --- a/homeassistant/components/switch/icons.json +++ b/homeassistant/components/switch/icons.json @@ -1,7 +1,10 @@ { "entity_component": { "_": { - "default": "mdi:toggle-switch-variant" + "default": "mdi:toggle-switch-variant", + "state": { + "off": "mdi:toggle-switch-variant-off" + } }, "switch": { "default": "mdi:toggle-switch-variant", diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index 3fe2ff7bc7d..d94c7c9f098 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -114,8 +114,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> options = {**config_entry.options} if config_entry.minor_version < 2: options.setdefault(CONF_INVERT, False) - config_entry.minor_version = 2 - hass.config_entries.async_update_entry(config_entry, options=options) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) _LOGGER.debug( "Migration to version %s.%s successful", diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index dee1fe5cd8f..d5e182a31dc 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -156,7 +156,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass, config_entry.entry_id, update_unique_id ) - config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, version=2) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py index 8dd740262f9..1fc5cfcba12 100644 --- a/homeassistant/components/switchbee/climate.py +++ b/homeassistant/components/switchbee/climate.py @@ -1,4 +1,5 @@ """Support for SwitchBee climate.""" + from __future__ import annotations from typing import Any @@ -87,11 +88,9 @@ async def async_setup_entry( class SwitchBeeClimateEntity(SwitchBeeDeviceEntity[SwitchBeeThermostat], ClimateEntity): """Representation of a SwitchBee climate.""" - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE - ) _attr_fan_modes = SUPPORTED_FAN_MODES _attr_target_temperature_step = 1 + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -106,6 +105,13 @@ class SwitchBeeClimateEntity(SwitchBeeDeviceEntity[SwitchBeeThermostat], Climate self._attr_temperature_unit = HVAC_UNIT_SB_TO_HASS[device.temperature_unit] self._attr_hvac_modes = [HVAC_MODE_SB_TO_HASS[mode] for mode in device.modes] self._attr_hvac_modes.append(HVACMode.OFF) + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + if len(self.hvac_modes) > 1: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) self._update_attrs_from_coordinator() @callback diff --git a/homeassistant/components/switchbot/coordinator.py b/homeassistant/components/switchbot/coordinator.py index 1965867887c..29679605e8b 100644 --- a/homeassistant/components/switchbot/coordinator.py +++ b/homeassistant/components/switchbot/coordinator.py @@ -115,7 +115,7 @@ class SwitchbotDataUpdateCoordinator(ActiveBluetoothDataUpdateCoordinator[None]) async def async_wait_ready(self) -> bool: """Wait for the device to be ready.""" - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): async with asyncio.timeout(DEVICE_STARTUP_TIMEOUT): await self._ready_event.wait() return True diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 2f92726a6da..401d85e7376 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.44.0"] + "requirements": ["PySwitchbot==0.45.0"] } diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index 2085398232f..64571f15af0 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -1,7 +1,6 @@ """Switcher integration Button platform.""" from __future__ import annotations -import asyncio from collections.abc import Callable from dataclasses import dataclass @@ -147,7 +146,7 @@ class SwitcherThermostatButtonEntity( self.coordinator.data.device_key, ) as swapi: response = await self.entity_description.press_fn(swapi, self._remote) - except (asyncio.TimeoutError, OSError, RuntimeError) as err: + except (TimeoutError, OSError, RuntimeError) as err: error = repr(err) if error or not response or not response.successful: diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index 272d3ccf6ef..180b71b1fe6 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -1,7 +1,6 @@ """Switcher integration Climate platform.""" from __future__ import annotations -import asyncio from typing import Any, cast from aioswitcher.api import SwitcherBaseResponse, SwitcherType2Api @@ -86,6 +85,7 @@ class SwitcherClimateEntity( _attr_has_entity_name = True _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, coordinator: SwitcherDataUpdateCoordinator, remote: SwitcherBreezeRemote @@ -118,6 +118,10 @@ class SwitcherClimateEntity( if features["swing"] and not remote.separated_swing_command: self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + # There is always support for off + minimum one other mode so no need to check + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) self._update_data(True) @callback @@ -167,7 +171,7 @@ class SwitcherClimateEntity( self.coordinator.data.device_key, ) as swapi: response = await swapi.control_breeze_device(self._remote, **kwargs) - except (asyncio.TimeoutError, OSError, RuntimeError) as err: + except (TimeoutError, OSError, RuntimeError) as err: error = repr(err) if error or not response or not response.successful: diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index 1e34ddd2325..4d81480e136 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -1,7 +1,6 @@ """Switcher integration Cover platform.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -103,7 +102,7 @@ class SwitcherCoverEntity( self.coordinator.data.device_key, ) as swapi: response = await getattr(swapi, api)(*args) - except (asyncio.TimeoutError, OSError, RuntimeError) as err: + except (TimeoutError, OSError, RuntimeError) as err: error = repr(err) if error or not response or not response.successful: diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 88867393834..c24157f70fc 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -1,7 +1,6 @@ """Switcher integration Switch platform.""" from __future__ import annotations -import asyncio from datetime import timedelta import logging from typing import Any @@ -118,7 +117,7 @@ class SwitcherBaseSwitchEntity( self.coordinator.data.device_key, ) as swapi: response = await getattr(swapi, api)(*args) - except (asyncio.TimeoutError, OSError, RuntimeError) as err: + except (TimeoutError, OSError, RuntimeError) as err: error = repr(err) if error or not response or not response.successful: diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 9eec64ec5f6..d2f5c795b7f 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -97,7 +97,7 @@ async def async_setup_entry( raise ConfigEntryNotReady( f"Could not connect to {entry.title} ({entry.data[CONF_HOST]})." ) from exception - except asyncio.TimeoutError as exception: + except TimeoutError as exception: raise ConfigEntryNotReady( f"Timed out waiting for {entry.title} ({entry.data[CONF_HOST]})." ) from exception @@ -117,7 +117,7 @@ async def async_setup_entry( raise ConfigEntryNotReady( f"Could not connect to {entry.title} ({entry.data[CONF_HOST]})." ) from exception - except asyncio.TimeoutError as exception: + except TimeoutError as exception: raise ConfigEntryNotReady( f"Timed out waiting for {entry.title} ({entry.data[CONF_HOST]})." ) from exception @@ -134,7 +134,7 @@ async def async_setup_entry( entry.data[CONF_HOST], ) await asyncio.sleep(1) - except asyncio.TimeoutError as exception: + except TimeoutError as exception: raise ConfigEntryNotReady( f"Timed out waiting for {entry.title} ({entry.data[CONF_HOST]})." ) from exception diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index a001f22c9e8..0b6a8b4622b 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -75,7 +75,7 @@ async def _validate_input( "Connection error when connecting to %s: %s", data[CONF_HOST], exception ) raise CannotConnect from exception - except asyncio.TimeoutError as exception: + except TimeoutError as exception: _LOGGER.warning("Timed out connecting to %s: %s", data[CONF_HOST], exception) raise CannotConnect from exception except ValueError as exception: diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 5a606721b00..532092ab133 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -215,7 +215,7 @@ class SystemBridgeDataUpdateCoordinator( ) self.last_update_success = False self.async_update_listeners() - except asyncio.TimeoutError as exception: + except TimeoutError as exception: self.logger.warning( "Timed out waiting for %s. Will retry: %s", self.title, diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index cd3cad8024e..4b33a2f7423 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -85,7 +85,7 @@ async def get_integration_info( assert registration.info_callback async with asyncio.timeout(INFO_CALLBACK_TIMEOUT): data = await registration.info_callback(hass) - except asyncio.TimeoutError: + except TimeoutError: data = {"error": {"type": "failed", "error": "timeout"}} except Exception: # pylint: disable=broad-except _LOGGER.exception("Error fetching info") @@ -236,7 +236,7 @@ async def async_check_can_reach_url( return "ok" except aiohttp.ClientError: data = {"type": "failed", "error": "unreachable"} - except asyncio.TimeoutError: + except TimeoutError: data = {"type": "failed", "error": "timeout"} if more_info is not None: data["more_info"] = more_info diff --git a/homeassistant/components/systemmonitor/__init__.py b/homeassistant/components/systemmonitor/__init__.py index 69dbb1f7952..20f9863e33d 100644 --- a/homeassistant/components/systemmonitor/__init__.py +++ b/homeassistant/components/systemmonitor/__init__.py @@ -1,10 +1,16 @@ """The System Monitor integration.""" +import logging + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -23,3 +29,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + + if entry.version == 1: + new_options = {**entry.options} + if entry.minor_version == 1: + # Migration copies process sensors to binary sensors + # Repair will remove sensors when user submit the fix + if processes := entry.options.get(SENSOR_DOMAIN): + new_options[BINARY_SENSOR_DOMAIN] = processes + hass.config_entries.async_update_entry( + entry, options=new_options, version=1, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", entry.version, entry.minor_version + ) + + return True diff --git a/homeassistant/components/systemmonitor/binary_sensor.py b/homeassistant/components/systemmonitor/binary_sensor.py new file mode 100644 index 00000000000..4dffc33e2b3 --- /dev/null +++ b/homeassistant/components/systemmonitor/binary_sensor.py @@ -0,0 +1,147 @@ +"""Binary sensors for System Monitor.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from functools import lru_cache +import logging +import sys +from typing import Generic, Literal + +import psutil + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +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 homeassistant.util import slugify + +from .const import CONF_PROCESS, DOMAIN +from .coordinator import MonitorCoordinator, SystemMonitorProcessCoordinator, dataT + +_LOGGER = logging.getLogger(__name__) + +CONF_ARG = "arg" + + +SENSOR_TYPE_NAME = 0 +SENSOR_TYPE_UOM = 1 +SENSOR_TYPE_ICON = 2 +SENSOR_TYPE_DEVICE_CLASS = 3 +SENSOR_TYPE_MANDATORY_ARG = 4 + +SIGNAL_SYSTEMMONITOR_UPDATE = "systemmonitor_update" + + +@lru_cache +def get_cpu_icon() -> Literal["mdi:cpu-64-bit", "mdi:cpu-32-bit"]: + """Return cpu icon.""" + if sys.maxsize > 2**32: + return "mdi:cpu-64-bit" + return "mdi:cpu-32-bit" + + +def get_process(entity: SystemMonitorSensor[list[psutil.Process]]) -> bool: + """Return process.""" + state = False + for proc in entity.coordinator.data: + try: + _LOGGER.debug("process %s for argument %s", proc.name(), entity.argument) + if entity.argument == proc.name(): + state = True + break + except psutil.NoSuchProcess as err: + _LOGGER.warning( + "Failed to load process with ID: %s, old name: %s", + err.pid, + err.name, + ) + return state + + +@dataclass(frozen=True, kw_only=True) +class SysMonitorBinarySensorEntityDescription( + BinarySensorEntityDescription, Generic[dataT] +): + """Describes System Monitor binary sensor entities.""" + + value_fn: Callable[[SystemMonitorSensor[dataT]], bool] + + +SENSOR_TYPES: tuple[ + SysMonitorBinarySensorEntityDescription[list[psutil.Process]], ... +] = ( + SysMonitorBinarySensorEntityDescription[list[psutil.Process]]( + key="binary_process", + translation_key="process", + icon=get_cpu_icon(), + value_fn=get_process, + device_class=BinarySensorDeviceClass.RUNNING, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up System Montor binary sensors based on a config entry.""" + entities: list[SystemMonitorSensor] = [] + process_coordinator = SystemMonitorProcessCoordinator(hass, "Process coordinator") + await process_coordinator.async_request_refresh() + + for sensor_description in SENSOR_TYPES: + _entry = entry.options.get(BINARY_SENSOR_DOMAIN, {}) + for argument in _entry.get(CONF_PROCESS, []): + entities.append( + SystemMonitorSensor( + process_coordinator, + sensor_description, + entry.entry_id, + argument, + ) + ) + async_add_entities(entities) + + +class SystemMonitorSensor( + CoordinatorEntity[MonitorCoordinator[dataT]], BinarySensorEntity +): + """Implementation of a system monitor binary sensor.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC + entity_description: SysMonitorBinarySensorEntityDescription[dataT] + + def __init__( + self, + coordinator: MonitorCoordinator[dataT], + sensor_description: SysMonitorBinarySensorEntityDescription[dataT], + entry_id: str, + argument: str, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + self.entity_description = sensor_description + self._attr_translation_placeholders = {"process": argument} + self._attr_unique_id: str = slugify(f"{sensor_description.key}_{argument}") + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="System Monitor", + name="System Monitor", + ) + self.argument = argument + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self) diff --git a/homeassistant/components/systemmonitor/config_flow.py b/homeassistant/components/systemmonitor/config_flow.py index 6d9787a39f5..9c7e739dbf9 100644 --- a/homeassistant/components/systemmonitor/config_flow.py +++ b/homeassistant/components/systemmonitor/config_flow.py @@ -6,8 +6,8 @@ from typing import Any import voluptuous as vol +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import entity_registry as er @@ -34,7 +34,7 @@ async def validate_sensor_setup( """Validate sensor input.""" # Standard behavior is to merge the result with the options. # In this case, we want to add a sub-item so we update the options directly. - sensors: dict[str, list] = handler.options.setdefault(SENSOR_DOMAIN, {}) + sensors: dict[str, list] = handler.options.setdefault(BINARY_SENSOR_DOMAIN, {}) processes = sensors.setdefault(CONF_PROCESS, []) previous_processes = processes.copy() processes.clear() @@ -44,7 +44,7 @@ async def validate_sensor_setup( for process in previous_processes: if process not in processes and ( entity_id := entity_registry.async_get_entity_id( - SENSOR_DOMAIN, DOMAIN, slugify(f"process_{process}") + BINARY_SENSOR_DOMAIN, DOMAIN, slugify(f"binary_process_{process}") ) ): entity_registry.async_remove(entity_id) @@ -58,7 +58,7 @@ async def validate_import_sensor_setup( """Validate sensor input.""" # Standard behavior is to merge the result with the options. # In this case, we want to add a sub-item so we update the options directly. - sensors: dict[str, list] = handler.options.setdefault(SENSOR_DOMAIN, {}) + sensors: dict[str, list] = handler.options.setdefault(BINARY_SENSOR_DOMAIN, {}) import_processes: list[str] = user_input["processes"] processes = sensors.setdefault(CONF_PROCESS, []) processes.extend(import_processes) @@ -104,7 +104,7 @@ async def get_sensor_setup_schema(handler: SchemaCommonFlowHandler) -> vol.Schem async def get_suggested_value(handler: SchemaCommonFlowHandler) -> dict[str, Any]: """Return suggested values for sensor setup.""" - sensors: dict[str, list] = handler.options.get(SENSOR_DOMAIN, {}) + sensors: dict[str, list] = handler.options.get(BINARY_SENSOR_DOMAIN, {}) processes: list[str] = sensors.get(CONF_PROCESS, []) return {CONF_PROCESS: processes} @@ -130,6 +130,8 @@ class SystemMonitorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW + VERSION = 1 + MINOR_VERSION = 2 def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" diff --git a/homeassistant/components/systemmonitor/coordinator.py b/homeassistant/components/systemmonitor/coordinator.py index 9143d31f163..bf625eacf9a 100644 --- a/homeassistant/components/systemmonitor/coordinator.py +++ b/homeassistant/components/systemmonitor/coordinator.py @@ -1,4 +1,5 @@ """DataUpdateCoordinators for the System monitor integration.""" + from __future__ import annotations from abc import abstractmethod @@ -43,7 +44,8 @@ dataT = TypeVar( | sswap | VirtualMemory | tuple[float, float, float] - | sdiskusage, + | sdiskusage + | None, ) @@ -130,12 +132,15 @@ class SystemMonitorLoadCoordinator(MonitorCoordinator[tuple[float, float, float] return os.getloadavg() -class SystemMonitorProcessorCoordinator(MonitorCoordinator[float]): +class SystemMonitorProcessorCoordinator(MonitorCoordinator[float | None]): """A System monitor Processor Data Update Coordinator.""" - def update_data(self) -> float: + def update_data(self) -> float | None: """Fetch data.""" - return psutil.cpu_percent(interval=None) + cpu_percent = psutil.cpu_percent(interval=None) + if cpu_percent > 0.0: + return cpu_percent + return None class SystemMonitorBootTimeCoordinator(MonitorCoordinator[datetime]): diff --git a/homeassistant/components/systemmonitor/repairs.py b/homeassistant/components/systemmonitor/repairs.py new file mode 100644 index 00000000000..10b5d18830d --- /dev/null +++ b/homeassistant/components/systemmonitor/repairs.py @@ -0,0 +1,72 @@ +"""Repairs platform for the System Monitor integration.""" + +from __future__ import annotations + +from typing import Any, cast + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +class ProcessFixFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, entry: ConfigEntry, processes: list[str]) -> None: + """Create flow.""" + super().__init__() + self.entry = entry + self._processes = processes + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_migrate_process_sensor() + + async def async_step_migrate_process_sensor( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the options step of a fix flow.""" + if user_input is None: + return self.async_show_form( + step_id="migrate_process_sensor", + description_placeholders={"processes": ", ".join(self._processes)}, + ) + + # Migration has copied the sensors to binary sensors + # Pop the sensors to repair and remove entities + new_options: dict[str, Any] = self.entry.options.copy() + new_options.pop(SENSOR_DOMAIN) + + entity_reg = er.async_get(self.hass) + entries = er.async_entries_for_config_entry(entity_reg, self.entry.entry_id) + for entry in entries: + if entry.entity_id.startswith("sensor.") and entry.unique_id.startswith( + "process_" + ): + entity_reg.async_remove(entry.entity_id) + + self.hass.config_entries.async_update_entry(self.entry, options=new_options) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_create_entry(data={}) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, Any] | None, +) -> RepairsFlow: + """Create flow.""" + entry = None + if data and (entry_id := data.get("entry_id")): + entry_id = cast(str, entry_id) + processes: list[str] = data["processes"] + entry = hass.config_entries.async_get_entry(entry_id) + assert entry + return ProcessFixFlow(entry, processes) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index e751ffebb12..91cbdffdee3 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -1,4 +1,5 @@ """Support for monitoring the local system.""" + from __future__ import annotations from collections.abc import Callable @@ -39,6 +40,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify @@ -344,7 +346,9 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription[Any]] = { native_unit_of_measurement=PERCENTAGE, icon=get_cpu_icon(), state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda entity: round(entity.coordinator.data), + value_fn=lambda entity: ( + round(entity.coordinator.data) if entity.coordinator.data else None + ), ), "processor_temperature": SysMonitorSensorEntityDescription[ dict[str, list[shwtemp]] @@ -638,6 +642,20 @@ async def async_setup_entry( # noqa: C901 True, ) ) + async_create_issue( + hass, + DOMAIN, + "process_sensor", + breaks_in_ha_version="2024.9.0", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="process_sensor", + data={ + "entry_id": entry.entry_id, + "processes": _entry[CONF_PROCESS], + }, + ) continue if _type == "processor_use": diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json index ff1fbc221ee..aae2463c9da 100644 --- a/homeassistant/components/systemmonitor/strings.json +++ b/homeassistant/components/systemmonitor/strings.json @@ -22,7 +22,25 @@ } } }, + "issues": { + "process_sensor": { + "title": "Process sensors are deprecated and will be removed", + "fix_flow": { + "step": { + "migrate_process_sensor": { + "title": "Process sensors have been setup as binary sensors", + "description": "Process sensors `{processes}` have been created as binary sensors and the sensors will be removed in 2024.9.0.\n\nPlease update all automations, scripts, dashboards or other things depending on these sensors to use the newly created binary sensors instead and press **Submit** to fix this issue." + } + } + } + } + }, "entity": { + "binary_sensor": { + "process": { + "name": "Process {process}" + } + }, "sensor": { "disk_free": { "name": "Disk free {mount_point}" diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 293492b90e8..11d8fa9c062 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -1,4 +1,5 @@ """Utils for System Monitor.""" + import logging import os @@ -71,6 +72,7 @@ def read_cpu_temperature(temps: dict[str, list[shwtemp]] | None = None) -> float temps = psutil.sensors_temperatures() entry: shwtemp + _LOGGER.debug("CPU Temperatures: %s", temps) for name, entries in temps.items(): for i, entry in enumerate(entries, start=1): # In case the label is empty (e.g. on Raspberry PI 4), diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index f1f200a5964..c28ebf4aab2 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -12,6 +12,7 @@ from aiotankerkoenig import ( TankerkoenigConnectionError, TankerkoenigError, TankerkoenigInvalidKeyError, + TankerkoenigRateLimitError, ) from homeassistant.config_entries import ConfigEntry @@ -19,7 +20,7 @@ from homeassistant.const import CONF_API_KEY, CONF_SHOW_ON_MAP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_FUEL_TYPES, CONF_STATIONS @@ -78,13 +79,22 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): station_ids = list(self.stations) prices = {} - # The API seems to only return at most 10 results, so split the list in chunks of 10 # and merge it together. for index in range(ceil(len(station_ids) / 10)): - data = await self._tankerkoenig.prices( - station_ids[index * 10 : (index + 1) * 10] - ) + try: + data = await self._tankerkoenig.prices( + station_ids[index * 10 : (index + 1) * 10] + ) + except TankerkoenigInvalidKeyError as err: + raise ConfigEntryAuthFailed(err) from err + except (TankerkoenigError, TankerkoenigConnectionError) as err: + if isinstance(err, TankerkoenigRateLimitError): + _LOGGER.warning( + "API rate limit reached, consider to increase polling interval" + ) + raise UpdateFailed(err) from err + prices.update(data) return prices diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json index adea5b96490..cac849b7bb5 100644 --- a/homeassistant/components/tankerkoenig/manifest.json +++ b/homeassistant/components/tankerkoenig/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tankerkoenig", "iot_class": "cloud_polling", "loggers": ["aiotankerkoenig"], - "requirements": ["aiotankerkoenig==0.3.0"] + "requirements": ["aiotankerkoenig==0.4.0"] } diff --git a/homeassistant/components/technove/manifest.json b/homeassistant/components/technove/manifest.json index 33739bbd867..c63151560f8 100644 --- a/homeassistant/components/technove/manifest.json +++ b/homeassistant/components/technove/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/technove", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["python-technove==1.2.1"], + "requirements": ["python-technove==1.2.2"], "zeroconf": ["_technove-stations._tcp.local."] } diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index 8a850ee610c..f38bf61d8ed 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -63,7 +63,9 @@ "state": { "unplugged": "Unplugged", "plugged_waiting": "Plugged, waiting", - "plugged_charging": "Plugged, charging" + "plugged_charging": "Plugged, charging", + "out_of_activation_period": "Out of activation period", + "high_charge_period": "High charge period" } } } diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py index cbc608d03a6..eeb0f8e0d5a 100644 --- a/homeassistant/components/tedee/__init__.py +++ b/homeassistant/components/tedee/__init__.py @@ -1,25 +1,12 @@ """Init the tedee component.""" -from collections.abc import Awaitable, Callable -from http import HTTPStatus import logging -from typing import Any -from aiohttp.hdrs import METH_POST -from aiohttp.web import Request, Response -from pytedee_async.exception import TedeeWebhookException - -from homeassistant.components.http import HomeAssistantView -from homeassistant.components.webhook import ( - async_generate_url as webhook_generate_url, - async_register as webhook_register, - async_unregister as webhook_unregister, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .const import DOMAIN, NAME +from .const import DOMAIN from .coordinator import TedeeApiCoordinator PLATFORMS = [ @@ -50,38 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - async def unregister_webhook(_: Any) -> None: - await coordinator.async_unregister_webhook() - webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - - async def register_webhook() -> None: - webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) - webhook_name = "Tedee" - if entry.title != NAME: - webhook_name = f"{NAME} {entry.title}" - - webhook_register( - hass, - DOMAIN, - webhook_name, - entry.data[CONF_WEBHOOK_ID], - get_webhook_handler(coordinator), - allowed_methods=[METH_POST], - ) - _LOGGER.debug("Registered Tedee webhook at hass: %s", webhook_url) - - try: - await coordinator.async_register_webhook(webhook_url) - except TedeeWebhookException as ex: - _LOGGER.warning("Failed to register Tedee webhook from bridge: %s", ex) - else: - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) - ) - - entry.async_create_background_task( - hass, register_webhook(), "tedee_register_webhook" - ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -90,34 +45,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -def get_webhook_handler( - coordinator: TedeeApiCoordinator, -) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: - """Return webhook handler.""" - - async def async_webhook_handler( - hass: HomeAssistant, webhook_id: str, request: Request - ) -> Response | None: - # Handle http post calls to the path. - if not request.body_exists: - return HomeAssistantView.json( - result="No Body", status_code=HTTPStatus.BAD_REQUEST - ) - - body = await request.json() - try: - coordinator.webhook_received(body) - except TedeeWebhookException as ex: - return HomeAssistantView.json( - result=str(ex), status_code=HTTPStatus.BAD_REQUEST - ) - - return HomeAssistantView.json(result="OK", status_code=HTTPStatus.OK) - - return async_webhook_handler diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index 7efa25fa245..645e25d4e85 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -1,4 +1,5 @@ """Tedee sensor entities.""" + from collections.abc import Callable from dataclasses import dataclass @@ -58,21 +59,17 @@ async def async_setup_entry( """Set up the Tedee sensor entity.""" coordinator = hass.data[DOMAIN][entry.entry_id] - for entity_description in ENTITIES: - async_add_entities( - [ - TedeeBinarySensorEntity(lock, coordinator, entity_description) - for lock in coordinator.data.values() - ] - ) + async_add_entities( + TedeeBinarySensorEntity(lock, coordinator, entity_description) + for lock in coordinator.data.values() + for entity_description in ENTITIES + ) def _async_add_new_lock(lock_id: int) -> None: lock = coordinator.data[lock_id] async_add_entities( - [ - TedeeBinarySensorEntity(lock, coordinator, entity_description) - for entity_description in ENTITIES - ] + TedeeBinarySensorEntity(lock, coordinator, entity_description) + for entity_description in ENTITIES ) coordinator.new_lock_callbacks.append(_async_add_new_lock) diff --git a/homeassistant/components/tedee/config_flow.py b/homeassistant/components/tedee/config_flow.py index 8bd9efd2b17..7c8c7b4c3ab 100644 --- a/homeassistant/components/tedee/config_flow.py +++ b/homeassistant/components/tedee/config_flow.py @@ -1,4 +1,5 @@ """Config flow for Tedee integration.""" + from collections.abc import Mapping from typing import Any @@ -11,9 +12,8 @@ from pytedee_async import ( ) import voluptuous as vol -from homeassistant.components.webhook import async_generate_id as webhook_generate_id from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID +from homeassistant.const import CONF_HOST from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -62,10 +62,7 @@ class TedeeConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") await self.async_set_unique_id(local_bridge.serial) self._abort_if_unique_id_configured() - return self.async_create_entry( - title=NAME, - data={**user_input, CONF_WEBHOOK_ID: webhook_generate_id()}, - ) + return self.async_create_entry(title=NAME, data=user_input) return self.async_show_form( step_id="user", @@ -87,14 +84,24 @@ class TedeeConfigFlow(ConfigFlow, domain=DOMAIN): self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required( - CONF_LOCAL_ACCESS_TOKEN, - default=entry_data[CONF_LOCAL_ACCESS_TOKEN], - ): str, - } - ), - ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + assert self.reauth_entry + + if not user_input: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required( + CONF_LOCAL_ACCESS_TOKEN, + default=self.reauth_entry.data[CONF_LOCAL_ACCESS_TOKEN], + ): str, + } + ), + ) + return await self.async_step_user(user_input) diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index cdd907b2e58..c846f2a8d9a 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -3,7 +3,6 @@ from collections.abc import Awaitable, Callable from datetime import timedelta import logging import time -from typing import Any from pytedee_async import ( TedeeClient, @@ -11,7 +10,6 @@ from pytedee_async import ( TedeeDataUpdateException, TedeeLocalAuthException, TedeeLock, - TedeeWebhookException, ) from pytedee_async.bridge import TedeeBridge @@ -25,7 +23,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN -SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL = timedelta(seconds=20) GET_LOCKS_INTERVAL_SECONDS = 3600 _LOGGER = logging.getLogger(__name__) @@ -55,7 +53,6 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): self._next_get_locks = time.time() self._locks_last_update: set[int] = set() self.new_lock_callbacks: list[Callable[[int], None]] = [] - self.tedee_webhook_id: int | None = None @property def bridge(self) -> TedeeBridge: @@ -106,27 +103,6 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]): except (TedeeClientException, TimeoutError) as ex: raise UpdateFailed("Querying API failed. Error: %s" % str(ex)) from ex - def webhook_received(self, message: dict[str, Any]) -> None: - """Handle webhook message.""" - self.tedee_client.parse_webhook_message(message) - self.async_set_updated_data(self.tedee_client.locks_dict) - - async def async_register_webhook(self, webhook_url: str) -> None: - """Register the webhook at the Tedee bridge.""" - self.tedee_webhook_id = await self.tedee_client.register_webhook(webhook_url) - - async def async_unregister_webhook(self) -> None: - """Unregister the webhook at the Tedee bridge.""" - if self.tedee_webhook_id is not None: - try: - await self.tedee_client.delete_webhook(self.tedee_webhook_id) - except TedeeWebhookException as ex: - _LOGGER.warning( - "Failed to unregister Tedee webhook from bridge: %s", ex - ) - else: - _LOGGER.debug("Unregistered Tedee webhook") - def _async_add_remove_locks(self) -> None: """Add new locks, remove non-existing locks.""" if not self._locks_last_update: diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index 0a13b2266fa..1776e3b7ab2 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -3,7 +3,7 @@ "name": "Tedee", "codeowners": ["@patrickhilker", "@zweckj"], "config_flow": true, - "dependencies": ["http", "webhook"], + "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", "requirements": ["pytedee-async==0.2.13"] diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py index 9880f73746d..225686f6b18 100644 --- a/homeassistant/components/tedee/sensor.py +++ b/homeassistant/components/tedee/sensor.py @@ -1,4 +1,5 @@ """Tedee sensor entities.""" + from collections.abc import Callable from dataclasses import dataclass @@ -11,7 +12,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfTime +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -33,15 +34,17 @@ ENTITIES: tuple[TedeeSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda lock: lock.battery_level, + entity_category=EntityCategory.DIAGNOSTIC, ), TedeeSensorEntityDescription( key="pullspring_duration", translation_key="pullspring_duration", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, icon="mdi:timer-lock-open", value_fn=lambda lock: lock.duration_pullspring, + entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -54,21 +57,17 @@ async def async_setup_entry( """Set up the Tedee sensor entity.""" coordinator = hass.data[DOMAIN][entry.entry_id] - for entity_description in ENTITIES: - async_add_entities( - [ - TedeeSensorEntity(lock, coordinator, entity_description) - for lock in coordinator.data.values() - ] - ) + async_add_entities( + TedeeSensorEntity(lock, coordinator, entity_description) + for lock in coordinator.data.values() + for entity_description in ENTITIES + ) def _async_add_new_lock(lock_id: int) -> None: lock = coordinator.data[lock_id] async_add_entities( - [ - TedeeSensorEntity(lock, coordinator, entity_description) - for entity_description in ENTITIES - ] + TedeeSensorEntity(lock, coordinator, entity_description) + for entity_description in ENTITIES ) coordinator.new_lock_callbacks.append(_async_add_new_lock) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 1d71e055e2e..2ba7752a85f 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -602,12 +602,8 @@ class TelegramNotificationService: if keys: params[ATTR_REPLYMARKUP] = ReplyKeyboardMarkup( [[key.strip() for key in row.split(",")] for row in keys], - resize_keyboard=data[ATTR_RESIZE_KEYBOARD] - if ATTR_RESIZE_KEYBOARD in data - else False, - one_time_keyboard=data[ATTR_ONE_TIME_KEYBOARD] - if ATTR_ONE_TIME_KEYBOARD in data - else False, + resize_keyboard=data.get(ATTR_RESIZE_KEYBOARD, False), + one_time_keyboard=data.get(ATTR_ONE_TIME_KEYBOARD, False), ) else: params[ATTR_REPLYMARKUP] = ReplyKeyboardRemove(True) diff --git a/homeassistant/components/tellduslive/config_flow.py b/homeassistant/components/tellduslive/config_flow.py index 060b90a7d70..33910f6ead1 100644 --- a/homeassistant/components/tellduslive/config_flow.py +++ b/homeassistant/components/tellduslive/config_flow.py @@ -94,7 +94,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): auth_url = await self.hass.async_add_executor_job(self._get_auth_url) if not auth_url: return self.async_abort(reason="unknown_authorize_url_generation") - except asyncio.TimeoutError: + except TimeoutError: return self.async_abort(reason="authorize_url_timeout") except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected error generating auth url") diff --git a/homeassistant/components/tesla_wall_connector/binary_sensor.py b/homeassistant/components/tesla_wall_connector/binary_sensor.py index e9ac03c69e1..d21d9a75e0b 100644 --- a/homeassistant/components/tesla_wall_connector/binary_sensor.py +++ b/homeassistant/components/tesla_wall_connector/binary_sensor.py @@ -50,7 +50,7 @@ WALL_CONNECTOR_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Create the Wall Connector sensor devices.""" wall_connector_data = hass.data[DOMAIN][config_entry.entry_id] @@ -60,7 +60,7 @@ async def async_setup_entry( for description in WALL_CONNECTOR_SENSORS ] - async_add_devices(all_entities) + async_add_entities(all_entities) class WallConnectorBinarySensorEntity(WallConnectorEntity, BinarySensorEntity): diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 67d3d4ba22e..bba01f8692d 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -156,7 +156,7 @@ WALL_CONNECTOR_SENSORS = [ suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].session_energy_wh, device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), WallConnectorSensorDescription( key="energy_kWh", @@ -172,7 +172,7 @@ WALL_CONNECTOR_SENSORS = [ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Create the Wall Connector sensor devices.""" wall_connector_data = hass.data[DOMAIN][config_entry.entry_id] @@ -182,7 +182,7 @@ async def async_setup_entry( for description in WALL_CONNECTOR_SENSORS ] - async_add_devices(all_entities) + async_add_entities(all_entities) class WallConnectorSensorEntity(WallConnectorEntity, SensorEntity): diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json new file mode 100644 index 00000000000..a4521b52945 --- /dev/null +++ b/homeassistant/components/teslemetry/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "climate": { + "driver_temp": { + "state_attributes": { + "preset_mode": { + "state": { + "off": "mdi:power", + "keep": "mdi:fan", + "dog": "mdi:dog", + "camp": "mdi:tent" + } + } + } + } + } + } +} diff --git a/homeassistant/components/tessie/lock.py b/homeassistant/components/tessie/lock.py index 1a0d879cd79..9a27e95c73e 100644 --- a/homeassistant/components/tessie/lock.py +++ b/homeassistant/components/tessie/lock.py @@ -3,9 +3,15 @@ from __future__ import annotations from typing import Any -from tessie_api import lock, open_unlock_charge_port, unlock +from tessie_api import ( + disable_speed_limit, + enable_speed_limit, + lock, + open_unlock_charge_port, + unlock, +) -from homeassistant.components.lock import LockEntity +from homeassistant.components.lock import ATTR_CODE, LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -24,7 +30,7 @@ async def async_setup_entry( async_add_entities( klass(vehicle.state_coordinator) - for klass in (TessieLockEntity, TessieCableLockEntity) + for klass in (TessieLockEntity, TessieCableLockEntity, TessieSpeedLimitEntity) for vehicle in data ) @@ -55,6 +61,38 @@ class TessieLockEntity(TessieEntity, LockEntity): self.set((self.key, False)) +class TessieSpeedLimitEntity(TessieEntity, LockEntity): + """Speed Limit with PIN entity for Tessie.""" + + _attr_code_format = r"^\d\d\d\d$" + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "vehicle_state_speed_limit_mode_active") + + @property + def is_locked(self) -> bool | None: + """Return the state of the Lock.""" + return self._value + + async def async_lock(self, **kwargs: Any) -> None: + """Enable speed limit with pin.""" + code: str | None = kwargs.get(ATTR_CODE) + if code: + await self.run(enable_speed_limit, pin=code) + self.set((self.key, True)) + + async def async_unlock(self, **kwargs: Any) -> None: + """Disable speed limit with pin.""" + code: str | None = kwargs.get(ATTR_CODE) + if code: + await self.run(disable_speed_limit, pin=code) + self.set((self.key, False)) + + class TessieCableLockEntity(TessieEntity, LockEntity): """Cable Lock entity for Tessie.""" diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 01e6a654163..f5900095836 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -59,6 +59,9 @@ }, "charge_state_charge_port_latch": { "name": "Charge cable lock" + }, + "vehicle_state_speed_limit_mode_active": { + "name": "Speed limit" } }, "media_player": { diff --git a/homeassistant/components/tfiac/climate.py b/homeassistant/components/tfiac/climate.py index 7e5999b7f02..ea6a6f22d2b 100644 --- a/homeassistant/components/tfiac/climate.py +++ b/homeassistant/components/tfiac/climate.py @@ -63,7 +63,7 @@ ON_MODE = "is_on" async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the TFIAC climate device.""" @@ -73,7 +73,7 @@ async def async_setup_platform( except futures.TimeoutError: _LOGGER.error("Unable to connect to %s", config[CONF_HOST]) return - async_add_devices([TfiacClimate(hass, tfiac_client)]) + async_add_entities([TfiacClimate(hass, tfiac_client)]) class TfiacClimate(ClimateEntity): diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index 817df22d6e1..51348afb0a4 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -13,6 +13,10 @@ { "local_name": "TP96*", "connectable": false + }, + { + "local_name": "TP97*", + "connectable": false } ], "codeowners": ["@bdraco", "@h3ss"], @@ -20,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.9.0"] + "requirements": ["thermopro-ble==0.10.0"] } diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index 06005d7e4ed..b9568a979fa 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -136,7 +136,7 @@ class TtnDataStorage: async with asyncio.timeout(DEFAULT_TIMEOUT): response = await session.get(self._url, headers=self._headers) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.error("Error while accessing: %s", self._url) return None diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index eeac24a626f..65d4c9d044c 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.5.0", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.6.0", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/homeassistant/components/thread/strings.json b/homeassistant/components/thread/strings.json index 0a9cf0004bc..474999b06bd 100644 --- a/homeassistant/components/thread/strings.json +++ b/homeassistant/components/thread/strings.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, "step": { "confirm": { "description": "[%key:common::config_flow::description::confirm_setup%]" diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 6bd68e17c4d..52db8421781 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -1,5 +1,4 @@ """Support for Tibber.""" -import asyncio import logging import aiohttp @@ -55,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await tibber_connection.update_info() except ( - asyncio.TimeoutError, + TimeoutError, aiohttp.ClientError, tibber.RetryableHttpException, ) as err: diff --git a/homeassistant/components/tibber/config_flow.py b/homeassistant/components/tibber/config_flow.py index 3fb426d6b11..8c926c5cc81 100644 --- a/homeassistant/components/tibber/config_flow.py +++ b/homeassistant/components/tibber/config_flow.py @@ -1,7 +1,6 @@ """Adds config flow for Tibber integration.""" from __future__ import annotations -import asyncio from typing import Any import aiohttp @@ -46,7 +45,7 @@ class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await tibber_connection.update_info() - except asyncio.TimeoutError: + except TimeoutError: errors[CONF_ACCESS_TOKEN] = ERR_TIMEOUT except tibber.InvalidLogin: errors[CONF_ACCESS_TOKEN] = ERR_TOKEN diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py index 270528fc4e9..997afa62359 100644 --- a/homeassistant/components/tibber/notify.py +++ b/homeassistant/components/tibber/notify.py @@ -1,7 +1,6 @@ """Support for Tibber notifications.""" from __future__ import annotations -import asyncio from collections.abc import Callable import logging from typing import Any @@ -41,5 +40,5 @@ class TibberNotificationService(BaseNotificationService): title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) try: await self._notify(title=title, message=message) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Timeout sending message with Tibber") diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 52e18c9c6a2..c6e1bdc1895 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -1,7 +1,6 @@ """Support for Tibber sensors.""" from __future__ import annotations -import asyncio import datetime from datetime import timedelta import logging @@ -255,7 +254,7 @@ async def async_setup_entry( for home in tibber_connection.get_homes(only_active=False): try: await home.update_info() - except asyncio.TimeoutError as err: + except TimeoutError as err: _LOGGER.error("Timeout connecting to Tibber home: %s ", err) raise PlatformNotReady() from err except aiohttp.ClientError as err: @@ -399,7 +398,7 @@ class TibberSensorElPrice(TibberSensor): _LOGGER.debug("Fetching data") try: await self._tibber_home.update_info_and_price_info() - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): return data = self._tibber_home.info["viewer"]["home"] self._attr_extra_state_attributes["app_nickname"] = data["appNickname"] diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index d6855f42c0a..aece537c867 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -56,13 +56,12 @@ def _get_config_schema( vol.Required(CONF_API_KEY, default=input_dict.get(CONF_API_KEY)): str, } - default_location = ( - input_dict[CONF_LOCATION] - if CONF_LOCATION in input_dict - else { + default_location = input_dict.get( + CONF_LOCATION, + { CONF_LATITUDE: hass.config.latitude, CONF_LONGITUDE: hass.config.longitude, - } + }, ) return vol.Schema( { diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index e1e51f19e3a..10c0c16ff7f 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -61,7 +61,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle discovery via dhcp.""" return await self._async_handle_discovery( - discovery_info.ip, discovery_info.macaddress + discovery_info.ip, dr.format_mac(discovery_info.macaddress) ) async def async_step_integration_discovery( diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 87d30e4f76a..e27ee7de49f 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -163,6 +163,7 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): _attr_supported_features = LightEntityFeature.TRANSITION _attr_name = None + _fixed_color_mode: ColorMode | None = None device: SmartBulb @@ -193,6 +194,9 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): if device.is_dimmable: modes.add(ColorMode.BRIGHTNESS) self._attr_supported_color_modes = filter_supported_color_modes(modes) + if len(self._attr_supported_color_modes) == 1: + # If the light supports only a single color mode, set it now + self._fixed_color_mode = next(iter(self._attr_supported_color_modes)) self._async_update_attrs() @callback @@ -273,14 +277,14 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): def _determine_color_mode(self) -> ColorMode: """Return the active color mode.""" - if self.device.is_color: - if self.device.is_variable_color_temp and self.device.color_temp: - return ColorMode.COLOR_TEMP - return ColorMode.HS - if self.device.is_variable_color_temp: - return ColorMode.COLOR_TEMP + if self._fixed_color_mode: + # The light supports only a single color mode, return it + return self._fixed_color_mode - return ColorMode.BRIGHTNESS + # The light supports both color temp and color, determine which on is active + if self.device.is_variable_color_temp and self.device.color_temp: + return ColorMode.COLOR_TEMP + return ColorMode.HS @callback def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/tplink_tapo/__init__.py b/homeassistant/components/tplink_tapo/__init__.py new file mode 100644 index 00000000000..d76870ccea4 --- /dev/null +++ b/homeassistant/components/tplink_tapo/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: TP-Link Tapo.""" diff --git a/homeassistant/components/tplink_tapo/manifest.json b/homeassistant/components/tplink_tapo/manifest.json new file mode 100644 index 00000000000..a0d86b2dc62 --- /dev/null +++ b/homeassistant/components/tplink_tapo/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "tplink_tapo", + "name": "Tapo", + "integration_type": "virtual", + "supported_by": "tplink" +} diff --git a/homeassistant/components/traccar/manifest.json b/homeassistant/components/traccar/manifest.json index c3b9e540ab6..05946958dd5 100644 --- a/homeassistant/components/traccar/manifest.json +++ b/homeassistant/components/traccar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/traccar", "iot_class": "cloud_push", "loggers": ["pytraccar"], - "requirements": ["pytraccar==2.0.0", "stringcase==1.2.0"] + "requirements": ["pytraccar==2.1.0", "stringcase==1.2.0"] } diff --git a/homeassistant/components/traccar_server/coordinator.py b/homeassistant/components/traccar_server/coordinator.py index 90c910e6062..df9b5adaf1a 100644 --- a/homeassistant/components/traccar_server/coordinator.py +++ b/homeassistant/components/traccar_server/coordinator.py @@ -78,7 +78,7 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat self.client.get_geofences(), ) except TraccarException as ex: - raise UpdateFailed("Error while updating device data: %s") from ex + raise UpdateFailed(f"Error while updating device data: {ex}") from ex if TYPE_CHECKING: assert isinstance(devices, list[DeviceModel]) # type: ignore[misc] diff --git a/homeassistant/components/traccar_server/diagnostics.py b/homeassistant/components/traccar_server/diagnostics.py new file mode 100644 index 00000000000..ce296499398 --- /dev/null +++ b/homeassistant/components/traccar_server/diagnostics.py @@ -0,0 +1,79 @@ +"""Diagnostics platform for Traccar Server.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import DOMAIN +from .coordinator import TraccarServerCoordinator + +TO_REDACT = {CONF_ADDRESS, CONF_LATITUDE, CONF_LONGITUDE} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: TraccarServerCoordinator = hass.data[DOMAIN][config_entry.entry_id] + entity_registry = er.async_get(hass) + + entities = er.async_entries_for_config_entry( + entity_registry, + config_entry_id=config_entry.entry_id, + ) + + return async_redact_data( + { + "config_entry_options": dict(config_entry.options), + "coordinator_data": coordinator.data, + "entities": [ + { + "enity_id": entity.entity_id, + "disabled": entity.disabled, + "state": {"state": state.state, "attributes": state.attributes}, + } + for entity in entities + if (state := hass.states.get(entity.entity_id)) is not None + ], + }, + TO_REDACT, + ) + + +async def async_get_device_diagnostics( + hass: HomeAssistant, + entry: ConfigEntry, + device: dr.DeviceEntry, +) -> dict[str, Any]: + """Return device diagnostics.""" + coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id] + entity_registry = er.async_get(hass) + + entities = er.async_entries_for_device( + entity_registry, + device_id=device.id, + include_disabled_entities=True, + ) + + return async_redact_data( + { + "config_entry_options": dict(entry.options), + "coordinator_data": coordinator.data, + "entities": [ + { + "enity_id": entity.entity_id, + "disabled": entity.disabled, + "state": {"state": state.state, "attributes": state.attributes}, + } + for entity in entities + if (state := hass.states.get(entity.entity_id)) is not None + ], + }, + TO_REDACT, + ) diff --git a/homeassistant/components/traccar_server/manifest.json b/homeassistant/components/traccar_server/manifest.json index ca284dd02dd..cf4af2b51b3 100644 --- a/homeassistant/components/traccar_server/manifest.json +++ b/homeassistant/components/traccar_server/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/traccar_server", "iot_class": "local_polling", - "requirements": ["pytraccar==2.0.0"] + "requirements": ["pytraccar==2.1.0"] } diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 8dd0ed8e91b..38080fffe6e 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -129,6 +129,13 @@ async def _generate_trackables( if not trackable["device_id"]: return None + if "details" not in trackable: + _LOGGER.info( + "Tracker %s has no details and will be skipped. This happens for shared trackers", + trackable["device_id"], + ) + return None + tracker = client.tracker(trackable["device_id"]) tracker_details, hw_info, pos_report = await asyncio.gather( diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 00296f3108c..c115a549fd4 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -36,7 +36,6 @@ async def async_setup_entry( class TractiveDeviceTracker(TractiveEntity, TrackerEntity): """Tractive device tracker.""" - _attr_icon = "mdi:paw" _attr_translation_key = "tracker" def __init__(self, client: TractiveClient, item: Trackables) -> None: diff --git a/homeassistant/components/tractive/icons.json b/homeassistant/components/tractive/icons.json new file mode 100644 index 00000000000..4fc4238d381 --- /dev/null +++ b/homeassistant/components/tractive/icons.json @@ -0,0 +1,58 @@ +{ + "entity": { + "device_tracker": { + "tracker": { + "default": "mdi:paw" + } + }, + "sensor": { + "activity": { + "default": "mdi:run" + }, + "activity_time": { + "default": "mdi:clock-time-eight-outline" + }, + "calories": { + "default": "mdi:fire" + }, + "daily_goal": { + "default": "mdi:flag-checkered" + }, + "minutes_day_sleep": { + "default": "mdi:sleep" + }, + "minutes_night_sleep": { + "default": "mdi:sleep" + }, + "rest_time": { + "default": "mdi:clock-time-eight-outline" + }, + "sleep": { + "default": "mdi:sleep" + }, + "tracker_state": { + "default": "mdi:radar" + } + }, + "switch": { + "tracker_buzzer": { + "default": "mdi:volume-high", + "state": { + "off": "mdi:volume-off" + } + }, + "tracker_led": { + "default": "mdi:led-on", + "state": { + "off": "mdi:led-off" + } + }, + "live_tracking": { + "default": "mdi:map-marker-path", + "state": { + "off": "mdi:map-marker-off" + } + } + } + } +} diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index ab9dad88e06..b563f536e21 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -111,7 +111,6 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( translation_key="tracker_state", signal_prefix=TRACKER_HARDWARE_STATUS_UPDATED, hardware_sensor=True, - icon="mdi:radar", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, options=[ @@ -124,7 +123,6 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( TractiveSensorEntityDescription( key=ATTR_MINUTES_ACTIVE, translation_key="activity_time", - icon="mdi:clock-time-eight-outline", native_unit_of_measurement=UnitOfTime.MINUTES, signal_prefix=TRACKER_ACTIVITY_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, @@ -132,7 +130,6 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( TractiveSensorEntityDescription( key=ATTR_MINUTES_REST, translation_key="rest_time", - icon="mdi:clock-time-eight-outline", native_unit_of_measurement=UnitOfTime.MINUTES, signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, @@ -140,7 +137,6 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( TractiveSensorEntityDescription( key=ATTR_CALORIES, translation_key="calories", - icon="mdi:fire", native_unit_of_measurement="kcal", signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, @@ -148,14 +144,12 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( TractiveSensorEntityDescription( key=ATTR_DAILY_GOAL, translation_key="daily_goal", - icon="mdi:flag-checkered", native_unit_of_measurement=UnitOfTime.MINUTES, signal_prefix=TRACKER_ACTIVITY_STATUS_UPDATED, ), TractiveSensorEntityDescription( key=ATTR_MINUTES_DAY_SLEEP, translation_key="minutes_day_sleep", - icon="mdi:sleep", native_unit_of_measurement=UnitOfTime.MINUTES, signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, @@ -163,7 +157,6 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( TractiveSensorEntityDescription( key=ATTR_MINUTES_NIGHT_SLEEP, translation_key="minutes_night_sleep", - icon="mdi:sleep", native_unit_of_measurement=UnitOfTime.MINUTES, signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, @@ -171,7 +164,6 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( TractiveSensorEntityDescription( key=ATTR_SLEEP_LABEL, translation_key="sleep", - icon="mdi:sleep", signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, value_fn=lambda state: state.lower() if isinstance(state, str) else state, device_class=SensorDeviceClass.ENUM, @@ -184,7 +176,6 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( TractiveSensorEntityDescription( key=ATTR_ACTIVITY_LABEL, translation_key="activity", - icon="mdi:run", signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, value_fn=lambda state: state.lower() if isinstance(state, str) else state, device_class=SensorDeviceClass.ENUM, diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index b77c35e6904..4c838e5a468 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -46,21 +46,18 @@ SWITCH_TYPES: tuple[TractiveSwitchEntityDescription, ...] = ( TractiveSwitchEntityDescription( key=ATTR_BUZZER, translation_key="tracker_buzzer", - icon="mdi:volume-high", method="async_set_buzzer", entity_category=EntityCategory.CONFIG, ), TractiveSwitchEntityDescription( key=ATTR_LED, translation_key="tracker_led", - icon="mdi:led-on", method="async_set_led", entity_category=EntityCategory.CONFIG, ), TractiveSwitchEntityDescription( key=ATTR_LIVE_TRACKING, translation_key="live_tracking", - icon="mdi:map-marker-path", method="async_set_live_tracking", entity_category=EntityCategory.CONFIG, ), diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index a383cc2bbee..9acdfb36a5d 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -144,7 +144,7 @@ async def authenticate( key = await api_factory.generate_psk(security_code) except RequestError as err: raise AuthError("invalid_security_code") from err - except asyncio.TimeoutError as err: + except TimeoutError as err: raise AuthError("timeout") from err finally: await api_factory.shutdown() diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index f0f758272f7..7303ba6836b 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -58,10 +58,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False if camera_id := camera_info.camera_id: - entry.version = 2 hass.config_entries.async_update_entry( entry, unique_id=f"{DOMAIN}-{camera_id}", + version=2, ) _LOGGER.debug( "Migrated Trafikverket Camera config entry unique id to %s", @@ -84,7 +84,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False if camera_id := camera_info.camera_id: - entry.version = 3 _LOGGER.debug( "Migrate Trafikverket Camera config entry unique id to %s", camera_id, @@ -92,7 +91,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_data = entry.data.copy() new_data.pop(CONF_LOCATION) new_data[CONF_ID] = camera_id - hass.config_entries.async_update_entry(entry, data=new_data) + hass.config_entries.async_update_entry(entry, data=new_data, version=3) return True _LOGGER.error("Could not migrate the config entry. Camera has no id") return False diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index ea38c117af7..5a6874fb352 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -59,12 +59,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: listener = DeviceListener(hass, manager) manager.add_device_listener(listener) + + # Get all devices from Tuya + try: + await hass.async_add_executor_job(manager.update_device_cache) + except Exception as exc: # pylint: disable=broad-except + # While in general, we should avoid catching broad exceptions, + # we have no other way of detecting this case. + if "sign invalid" in str(exc): + msg = "Authentication failed. Please re-authenticate" + raise ConfigEntryAuthFailed(msg) from exc + raise + + # Connection is successful, store the manager & listener hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantTuyaData( manager=manager, listener=listener ) - # Get devices & clean up device entities - await hass.async_add_executor_job(manager.update_device_cache) + # Cleanup device registry await cleanup_device_registry(hass, manager) # Register known device IDs @@ -102,11 +114,25 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if tuya.manager.mq is not None: tuya.manager.mq.stop() tuya.manager.remove_device_listener(tuya.listener) - await hass.async_add_executor_job(tuya.manager.unload) del hass.data[DOMAIN][entry.entry_id] return unload_ok +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry. + + This will revoke the credentials from Tuya. + """ + manager = Manager( + TUYA_CLIENT_ID, + entry.data[CONF_USER_CODE], + entry.data[CONF_TERMINAL_ID], + entry.data[CONF_ENDPOINT], + entry.data[CONF_TOKEN_INFO], + ) + await hass.async_add_executor_job(manager.unload) + + class DeviceListener(SharingDeviceListener): """Device Update Listener.""" diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 9f20df98370..45adb532705 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -127,6 +127,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): _set_temperature: IntegerTypeData | None = None entity_description: TuyaClimateEntityDescription _attr_name = None + _enable_turn_on_off_backwards_compatibility = False def __init__( self, @@ -277,6 +278,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): if self.find_dpcode(DPCode.SWITCH_VERTICAL, prefer_function=True): self._attr_swing_modes.append(SWING_VERTICAL) + if DPCode.SWITCH in self.device.function: + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON + ) + async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" await super().async_added_to_hass() @@ -476,23 +482,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): def turn_on(self) -> None: """Turn the device on, retaining current HVAC (if supported).""" - if DPCode.SWITCH in self.device.function: - self._send_command([{"code": DPCode.SWITCH, "value": True}]) - return - - # Fake turn on - for mode in (HVACMode.HEAT_COOL, HVACMode.HEAT, HVACMode.COOL): - if mode not in self.hvac_modes: - continue - self.set_hvac_mode(mode) - break + self._send_command([{"code": DPCode.SWITCH, "value": True}]) def turn_off(self) -> None: """Turn the device on, retaining current HVAC (if supported).""" - if DPCode.SWITCH in self.device.function: - self._send_command([{"code": DPCode.SWITCH, "value": False}]) - return - - # Fake turn off - if HVACMode.OFF in self.hvac_modes: - self.set_hvac_mode(HVACMode.OFF) + self._send_command([{"code": DPCode.SWITCH, "value": False}]) diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index 3577a6d6b06..e0ac5375b00 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -2,15 +2,14 @@ from __future__ import annotations from collections.abc import Mapping -from io import BytesIO from typing import Any -import segno from tuya_sharing import LoginControl import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector from .const import ( CONF_ENDPOINT, @@ -33,7 +32,6 @@ class TuyaConfigFlow(ConfigFlow, domain=DOMAIN): __user_code: str __qr_code: str - __qr_image: str __reauth_entry: ConfigEntry | None = None def __init__(self) -> None: @@ -82,9 +80,17 @@ class TuyaConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form( step_id="scan", - description_placeholders={ - TUYA_RESPONSE_QR_CODE: self.__qr_image, - }, + data_schema=vol.Schema( + { + vol.Optional("QR"): selector.QrCodeSelector( + config=selector.QrCodeSelectorConfig( + data=f"tuyaSmart--qrLogin?token={self.__qr_code}", + scale=5, + error_correction_level=selector.QrErrorCorrectionLevel.QUARTILE, + ) + ) + } + ), ) ret, info = await self.hass.async_add_executor_job( @@ -94,11 +100,23 @@ class TuyaConfigFlow(ConfigFlow, domain=DOMAIN): self.__user_code, ) if not ret: + # Try to get a new QR code on failure + await self.__async_get_qr_code(self.__user_code) return self.async_show_form( step_id="scan", errors={"base": "login_error"}, + data_schema=vol.Schema( + { + vol.Optional("QR"): selector.QrCodeSelector( + config=selector.QrCodeSelectorConfig( + data=f"tuyaSmart--qrLogin?token={self.__qr_code}", + scale=5, + error_correction_level=selector.QrErrorCorrectionLevel.QUARTILE, + ) + ) + } + ), description_placeholders={ - TUYA_RESPONSE_QR_CODE: self.__qr_image, TUYA_RESPONSE_MSG: info.get(TUYA_RESPONSE_MSG, "Unknown error"), TUYA_RESPONSE_CODE: info.get(TUYA_RESPONSE_CODE, 0), }, @@ -189,24 +207,4 @@ class TuyaConfigFlow(ConfigFlow, domain=DOMAIN): if success := response.get(TUYA_RESPONSE_SUCCESS, False): self.__user_code = user_code self.__qr_code = response[TUYA_RESPONSE_RESULT][TUYA_RESPONSE_QR_CODE] - self.__qr_image = _generate_qr_code(self.__qr_code) return success, response - - -def _generate_qr_code(data: str) -> str: - """Create an SVG QR code that can be scanned with the Smart Life app.""" - qr_code = segno.make(f"tuyaSmart--qrLogin?token={data}", error="h") - with BytesIO() as buffer: - qr_code.save( - buffer, - kind="svg", - border=5, - scale=5, - xmldecl=False, - svgns=False, - svgclass=None, - lineclass=None, - svgversion=2, - dark="#1abcf2", - ) - return str(buffer.getvalue().decode("ascii")) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 71e43c8d445..305a74160de 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -43,5 +43,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["tuya_iot"], - "requirements": ["tuya-device-sharing-sdk==0.1.9", "segno==1.5.3"] + "requirements": ["tuya-device-sharing-sdk==0.1.9"] } diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 6e4848d9cc0..cfce12273a0 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -14,12 +14,14 @@ } }, "scan": { - "description": "Use Smart Life app or Tuya Smart app to scan the following QR-code to complete the login:\n\n {qrcode} \n\nContinue to the next step once you have completed this step in the app." + "description": "Use Smart Life app or Tuya Smart app to scan the following QR-code to complete the login.\n\nContinue to the next step once you have completed this step in the app." } }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "login_error": "Login error ({code}): {msg}", + "login_error": "Login error ({code}): {msg}" + }, + "abort": { "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, diff --git a/homeassistant/components/twinkly/__init__.py b/homeassistant/components/twinkly/__init__.py index d57a56f489b..3b47a10d499 100644 --- a/homeassistant/components/twinkly/__init__.py +++ b/homeassistant/components/twinkly/__init__.py @@ -1,6 +1,5 @@ """The twinkly component.""" -import asyncio from aiohttp import ClientError from ttls.client import Twinkly @@ -31,7 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: device_info = await client.get_details() software_version = await client.get_firmware_version() - except (asyncio.TimeoutError, ClientError) as exception: + except (TimeoutError, ClientError) as exception: raise ConfigEntryNotReady from exception hass.data[DOMAIN][entry.entry_id] = { diff --git a/homeassistant/components/twinkly/config_flow.py b/homeassistant/components/twinkly/config_flow.py index e37e0fd6170..6d0785f648e 100644 --- a/homeassistant/components/twinkly/config_flow.py +++ b/homeassistant/components/twinkly/config_flow.py @@ -1,7 +1,6 @@ """Config flow to configure the Twinkly integration.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -40,7 +39,7 @@ class TwinklyConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): device_info = await Twinkly( host, async_get_clientsession(self.hass) ).get_details() - except (asyncio.TimeoutError, ClientError): + except (TimeoutError, ClientError): errors[CONF_HOST] = "cannot_connect" else: await self.async_set_unique_id(device_info[DEV_ID]) diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index c4301936088..453ba900706 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -1,7 +1,6 @@ """The Twinkly light component.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -65,6 +64,8 @@ async def async_setup_entry( class TwinklyLight(LightEntity): """Implementation of the light for the Twinkly service.""" + _attr_has_entity_name = True + _attr_name = None _attr_icon = "mdi:string-lights" def __init__( @@ -93,7 +94,7 @@ class TwinklyLight(LightEntity): # Those are saved in the config entry in order to have meaningful values even # if the device is currently offline. # They are expected to be updated using the device_info. - self._name = conf.data[CONF_NAME] + self._name = conf.data[CONF_NAME] or "Twinkly light" self._model = conf.data[CONF_MODEL] self._client = client @@ -107,11 +108,6 @@ class TwinklyLight(LightEntity): # We guess that most devices are "new" and support effects self._attr_supported_features = LightEntityFeature.EFFECT - @property - def name(self) -> str: - """Name of the device.""" - return self._name if self._name else "Twinkly light" - @property def device_info(self) -> DeviceInfo | None: """Get device specific attributes.""" @@ -119,7 +115,7 @@ class TwinklyLight(LightEntity): identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer="LEDWORKS", model=self._model, - name=self.name, + name=self._name, sw_version=self._software_version, ) @@ -272,6 +268,15 @@ class TwinklyLight(LightEntity): }, ) + device_registry = dr.async_get(self.hass) + device_entry = device_registry.async_get_device( + {(DOMAIN, self._attr_unique_id)} + ) + if device_entry: + device_registry.async_update_device( + device_entry.id, name=self._name, model=self._model + ) + if LightEntityFeature.EFFECT & self.supported_features: await self.async_update_movies() await self.async_update_current_movie() @@ -282,7 +287,7 @@ class TwinklyLight(LightEntity): # We don't use the echo API to track the availability since # we already have to pull the device to get its state. self._attr_available = True - except (asyncio.TimeoutError, ClientError): + except (TimeoutError, ClientError): # We log this as "info" as it's pretty common that the Christmas # light are not reachable in July if self._attr_available: diff --git a/homeassistant/components/ukraine_alarm/config_flow.py b/homeassistant/components/ukraine_alarm/config_flow.py index 4f1e1c5cf23..db17b55b2e9 100644 --- a/homeassistant/components/ukraine_alarm/config_flow.py +++ b/homeassistant/components/ukraine_alarm/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Ukraine Alarm.""" from __future__ import annotations -import asyncio import logging import aiohttp @@ -50,7 +49,7 @@ class UkraineAlarmConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except aiohttp.ClientError as ex: reason = "unknown" unknown_err_msg = str(ex) - except asyncio.TimeoutError: + except TimeoutError: reason = "timeout" if not reason and not regions: diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index eb127a5dfd9..5873fa92cf7 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -409,7 +409,7 @@ class UniFiController: async_dispatcher_send(self.hass, self.signal_reachable) except ( - asyncio.TimeoutError, + TimeoutError, aiounifi.BadGateway, aiounifi.ServiceUnavailable, aiounifi.AiounifiException, @@ -516,7 +516,7 @@ async def get_unifi_controller( raise AuthenticationRequired from err except ( - asyncio.TimeoutError, + TimeoutError, aiounifi.BadGateway, aiounifi.Forbidden, aiounifi.ServiceUnavailable, diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index f69dffc2d57..f3092811227 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==70"], + "requirements": ["aiounifi==71"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 28db9abb94f..a0cd3a7f1e7 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -430,10 +430,10 @@ class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity): def _make_disconnected(self, *_: core_Event) -> None: """No heart beat by device. - Reset sensor value to 0 when client device is disconnected + Set sensor as unavailable when client device is disconnected """ - if self._attr_native_value != 0: - self._attr_native_value = 0 + if self._attr_available: + self._attr_available = False self.async_write_ha_state() @callback diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 174f60fd135..8639b0becdc 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -1,7 +1,6 @@ """UniFi Protect Platform.""" from __future__ import annotations -import asyncio from datetime import timedelta import logging @@ -64,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: nvr_info = await protect.get_nvr() except NotAuthorized as err: raise ConfigEntryAuthFailed(err) from err - except (asyncio.TimeoutError, ClientError, ServerDisconnectedError) as err: + except (TimeoutError, ClientError, ServerDisconnectedError) as err: raise ConfigEntryNotReady from err if nvr_info.version < MIN_REQUIRED_PROTECT_V: diff --git a/homeassistant/components/upb/config_flow.py b/homeassistant/components/upb/config_flow.py index 318ba44f557..6d85febed9f 100644 --- a/homeassistant/components/upb/config_flow.py +++ b/homeassistant/components/upb/config_flow.py @@ -43,7 +43,7 @@ async def _validate_input(data): upb.connect(_connected_callback) - with suppress(asyncio.TimeoutError): + with suppress(TimeoutError): async with asyncio.timeout(VALIDATE_TIMEOUT): await connected_event.wait() diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 6af9d85bc87..2e546f8893f 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -72,7 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: async with asyncio.timeout(10): await device_discovered_event.wait() - except asyncio.TimeoutError as err: + except TimeoutError as err: raise ConfigEntryNotReady(f"Device not discovered: {usn}") from err finally: cancel_discovered_callback() diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 8ce32158016..edfde84a2ac 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.38.1", "getmac==0.9.4"], + "requirements": ["async-upnp-client==0.38.2", "getmac==0.9.4"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/usgs_earthquakes_feed/manifest.json b/homeassistant/components/usgs_earthquakes_feed/manifest.json index 6dbe43cb4e3..ffb9412703f 100644 --- a/homeassistant/components/usgs_earthquakes_feed/manifest.json +++ b/homeassistant/components/usgs_earthquakes_feed/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aio_geojson_usgs_earthquakes"], - "requirements": ["aio-geojson-usgs-earthquakes==0.2"] + "requirements": ["aio-geojson-usgs-earthquakes==0.3"] } diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 4b99611684a..a3b489dc55c 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -266,8 +266,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if config_entry.version == 1: new = {**config_entry.options} new[CONF_METER_PERIODICALLY_RESETTING] = True - config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry, options=new) + hass.config_entries.async_update_entry(config_entry, options=new, version=2) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index 6e1cabac509..4f62925069d 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -1,8 +1,6 @@ """Constants for the utility meter component.""" DOMAIN = "utility_meter" -TARIFF_ICON = "mdi:clock-outline" - QUARTER_HOURLY = "quarter-hourly" HOURLY = "hourly" DAILY = "daily" diff --git a/homeassistant/components/utility_meter/icons.json b/homeassistant/components/utility_meter/icons.json new file mode 100644 index 00000000000..7260fbfbe96 --- /dev/null +++ b/homeassistant/components/utility_meter/icons.json @@ -0,0 +1,14 @@ +{ + "entity": { + "sensor": { + "utility_meter": { + "default": "mdi:counter" + } + }, + "select": { + "tariff": { + "default": "mdi:clock-outline" + } + } + } +} diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index 64b271d4200..86433ca77f8 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -13,13 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ( - CONF_METER, - CONF_SOURCE_SENSOR, - CONF_TARIFFS, - DATA_UTILITY, - TARIFF_ICON, -) +from .const import CONF_METER, CONF_SOURCE_SENSOR, CONF_TARIFFS, DATA_UTILITY _LOGGER = logging.getLogger(__name__) @@ -100,6 +94,8 @@ async def async_setup_platform( class TariffSelect(SelectEntity, RestoreEntity): """Representation of a Tariff selector.""" + _attr_translation_key = "tariff" + def __init__( self, name, @@ -113,7 +109,6 @@ class TariffSelect(SelectEntity, RestoreEntity): self._attr_device_info = device_info self._current_tariff: str | None = None self._tariffs = tariffs - self._attr_icon = TARIFF_ICON self._attr_should_poll = False @property diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index ee0d5f85b3b..e9ad7a1ba30 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -362,7 +362,7 @@ class UtilitySensorExtraStoredData(SensorExtraStoredData): class UtilityMeterSensor(RestoreSensor): """Representation of an utility meter sensor.""" - _attr_icon = "mdi:counter" + _attr_translation_key = "utility_meter" _attr_should_poll = False def __init__( diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index c23c1d5924e..609823b1310 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -211,7 +211,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if os.path.isdir(cache_path): await hass.async_add_executor_job(shutil.rmtree, cache_path) # set the new version - config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, version=2) _LOGGER.debug("Migration to version %s successful", config_entry.version) return True diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index d6a5f540c06..64da09b7ac2 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -1,54 +1,68 @@ """Support for VELUX KLF 200 devices.""" -import logging - from pyvlx import Node, PyVLX, PyVLXException import voluptuous as vol -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - EVENT_HOMEASSISTANT_STOP, - Platform, -) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType -DOMAIN = "velux" -DATA_VELUX = "data_velux" -PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SCENE] -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, LOGGER, PLATFORMS CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - {vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string} - ) - }, - extra=vol.ALLOW_EXTRA, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, + ) ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the velux component.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the velux component.""" + module = VeluxModule(hass, entry.data) try: - hass.data[DATA_VELUX] = VeluxModule(hass, config[DOMAIN]) - hass.data[DATA_VELUX].setup() - await hass.data[DATA_VELUX].async_start() + module.setup() + await module.async_start() except PyVLXException as ex: - _LOGGER.exception("Can't connect to velux interface: %s", ex) + LOGGER.exception("Can't connect to velux interface: %s", ex) return False - for platform in PLATFORMS: - hass.async_create_task( - discovery.async_load_platform(hass, platform, DOMAIN, {}, config) - ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = module + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + class VeluxModule: """Abstraction for velux component.""" @@ -63,7 +77,7 @@ class VeluxModule: async def on_hass_stop(event): """Close connection when hass stops.""" - _LOGGER.debug("Velux interface terminated") + LOGGER.debug("Velux interface terminated") await self.pyvlx.disconnect() async def async_reboot_gateway(service_call: ServiceCall) -> None: @@ -80,7 +94,7 @@ class VeluxModule: async def async_start(self): """Start velux component.""" - _LOGGER.debug("Velux interface started") + LOGGER.debug("Velux interface started") await self.pyvlx.load_scenes() await self.pyvlx.load_nodes() diff --git a/homeassistant/components/velux/config_flow.py b/homeassistant/components/velux/config_flow.py new file mode 100644 index 00000000000..57791ea01dd --- /dev/null +++ b/homeassistant/components/velux/config_flow.py @@ -0,0 +1,112 @@ +"""Config flow for Velux integration.""" +from typing import Any + +from pyvlx import PyVLX, PyVLXException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DOMAIN, LOGGER + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } +) + + +class VeluxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for velux.""" + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a config entry.""" + + def create_repair(error: str | None = None) -> None: + if error: + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_yaml_import_issue_{error}", + breaks_in_ha_version="2024.9.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{error}", + ) + else: + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.9.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Velux", + }, + ) + + for entry in self._async_current_entries(): + if entry.data[CONF_HOST] == config[CONF_HOST]: + create_repair() + return self.async_abort(reason="already_configured") + + pyvlx = PyVLX(host=config[CONF_HOST], password=config[CONF_PASSWORD]) + try: + await pyvlx.connect() + await pyvlx.disconnect() + except (PyVLXException, ConnectionError): + create_repair("cannot_connect") + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + create_repair("unknown") + return self.async_abort(reason="unknown") + + create_repair() + return self.async_create_entry( + title=config[CONF_HOST], + data=config, + ) + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + + pyvlx = PyVLX( + host=user_input[CONF_HOST], password=user_input[CONF_PASSWORD] + ) + try: + await pyvlx.connect() + await pyvlx.disconnect() + except (PyVLXException, ConnectionError) as err: + errors["base"] = "cannot_connect" + LOGGER.debug("Cannot connect: %s", err) + except Exception as err: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception: %s", err) + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + ) + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/velux/const.py b/homeassistant/components/velux/const.py new file mode 100644 index 00000000000..9a686adf920 --- /dev/null +++ b/homeassistant/components/velux/const.py @@ -0,0 +1,8 @@ +"""Constants for the Velux integration.""" +from logging import getLogger + +from homeassistant.const import Platform + +DOMAIN = "velux" +PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SCENE] +LOGGER = getLogger(__package__) diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index c8fb2aafb96..2162e63096a 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -13,24 +13,22 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) +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 -from . import DATA_VELUX, VeluxEntity +from . import DOMAIN, VeluxEntity PARALLEL_UPDATES = 1 -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up cover(s) for Velux platform.""" entities = [] - for node in hass.data[DATA_VELUX].pyvlx.nodes: + module = hass.data[DOMAIN][config.entry_id] + for node in module.pyvlx.nodes: if isinstance(node, OpeningDevice): entities.append(VeluxCover(node)) async_add_entities(entities) diff --git a/homeassistant/components/velux/light.py b/homeassistant/components/velux/light.py index a6d63436ecf..dae38f3d9bf 100644 --- a/homeassistant/components/velux/light.py +++ b/homeassistant/components/velux/light.py @@ -6,25 +6,24 @@ from typing import Any from pyvlx import Intensity, LighteningDevice from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +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 -from . import DATA_VELUX, VeluxEntity +from . import DOMAIN, VeluxEntity PARALLEL_UPDATES = 1 -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up light(s) for Velux platform.""" + module = hass.data[DOMAIN][config.entry_id] + async_add_entities( VeluxLight(node) - for node in hass.data[DATA_VELUX].pyvlx.nodes + for node in module.pyvlx.nodes if isinstance(node, LighteningDevice) ) diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index 901034aa387..c3576aca925 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -1,7 +1,8 @@ { "domain": "velux", "name": "Velux", - "codeowners": ["@Julius2342"], + "codeowners": ["@Julius2342", "@DeerMaximum"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/velux", "iot_class": "local_polling", "loggers": ["pyvlx"], diff --git a/homeassistant/components/velux/scene.py b/homeassistant/components/velux/scene.py index 20f94c74f0b..956663c23f1 100644 --- a/homeassistant/components/velux/scene.py +++ b/homeassistant/components/velux/scene.py @@ -4,23 +4,22 @@ from __future__ import annotations from typing import Any from homeassistant.components.scene import Scene +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 -from . import _LOGGER, DATA_VELUX +from . import DOMAIN PARALLEL_UPDATES = 1 -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the scenes for Velux platform.""" - entities = [VeluxScene(scene) for scene in hass.data[DATA_VELUX].pyvlx.scenes] + module = hass.data[DOMAIN][config.entry_id] + + entities = [VeluxScene(scene) for scene in module.pyvlx.scenes] async_add_entities(entities) @@ -29,7 +28,6 @@ class VeluxScene(Scene): def __init__(self, scene): """Init velux scene.""" - _LOGGER.info("Adding Velux scene: %s", scene) self.scene = scene @property diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index 6a7e8c6e1ec..3964c22efe2 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -1,4 +1,32 @@ { + "config": { + "step": { + "user": { + "title": "Setup Velux", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Velux YAML configuration import cannot connect to server", + "description": "Configuring Velux using YAML is being removed but there was an connection error importing your YAML configuration.\n\nMake sure your home assistant can reach the KLF 200." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The Velux YAML configuration import failed with unknown error raised by pyvlx", + "description": "Configuring Velux using YAML is being removed but there was an unknown error importing your YAML configuration.\n\nCheck your configuration or have a look at the documentation of the integration." + } + }, "services": { "reboot_gateway": { "name": "Reboot gateway", diff --git a/homeassistant/components/verisure/__init__.py b/homeassistant/components/verisure/__init__.py index dfd9d9cdc04..7d2ea7b7d6d 100644 --- a/homeassistant/components/verisure/__init__.py +++ b/homeassistant/components/verisure/__init__.py @@ -108,7 +108,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, options=new_options) - entry.version = 2 + hass.config_entries.async_update_entry(entry, version=2) LOGGER.info("Migration to version %s successful", entry.version) diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index 4043cc865c7..ce439b9e628 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -84,7 +84,7 @@ async def async_http_request(hass, uri): return {"error": req.status} json_response = await req.json() return json_response - except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + except (TimeoutError, aiohttp.ClientError) as exc: _LOGGER.error("Cannot connect to ViaggiaTreno API endpoint: %s", exc) except ValueError: _LOGGER.error("Received non-JSON data from ViaggiaTreno API endpoint") diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index a2b2f3ac769..eec5f097535 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -1,15 +1,13 @@ """The ViCare integration.""" from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Mapping from contextlib import suppress -from dataclasses import dataclass import logging import os from typing import Any from PyViCare.PyViCare import PyViCare -from PyViCare.PyViCareDevice import Device from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareUtils import ( PyViCareInvalidConfigurationError, @@ -22,36 +20,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.storage import STORAGE_DIR -from .const import ( - CONF_HEATING_TYPE, - DEFAULT_SCAN_INTERVAL, - DOMAIN, - HEATING_TYPE_TO_CREATOR_METHOD, - PLATFORMS, - VICARE_API, - VICARE_DEVICE_CONFIG, - VICARE_DEVICE_CONFIG_LIST, - HeatingType, -) +from .const import DEFAULT_CACHE_DURATION, DEVICE_LIST, DOMAIN, PLATFORMS +from .types import ViCareDevice +from .utils import get_device _LOGGER = logging.getLogger(__name__) _TOKEN_FILENAME = "vicare_token.save" -@dataclass(frozen=True) -class ViCareRequiredKeysMixin: - """Mixin for required keys.""" - - value_getter: Callable[[Device], Any] - - -@dataclass(frozen=True) -class ViCareRequiredKeysMixinWithSet(ViCareRequiredKeysMixin): - """Mixin for required keys with setter.""" - - value_setter: Callable[[Device], bool] - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from config entry.""" _LOGGER.debug("Setting up ViCare component") @@ -69,10 +45,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def vicare_login(hass: HomeAssistant, entry_data: Mapping[str, Any]) -> PyViCare: +def vicare_login( + hass: HomeAssistant, + entry_data: Mapping[str, Any], + cache_duration=DEFAULT_CACHE_DURATION, +) -> PyViCare: """Login via PyVicare API.""" vicare_api = PyViCare() - vicare_api.setCacheDuration(DEFAULT_SCAN_INTERVAL) + vicare_api.setCacheDuration(cache_duration) vicare_api.initWithCredentials( entry_data[CONF_USERNAME], entry_data[CONF_PASSWORD], @@ -87,20 +67,25 @@ def setup_vicare_api(hass: HomeAssistant, entry: ConfigEntry) -> None: vicare_api = vicare_login(hass, entry.data) device_config_list = get_supported_devices(vicare_api.devices) + if (number_of_devices := len(device_config_list)) > 1: + cache_duration = DEFAULT_CACHE_DURATION * number_of_devices + _LOGGER.debug( + "Found %s devices, adjusting cache duration to %s", + number_of_devices, + cache_duration, + ) + vicare_api = vicare_login(hass, entry.data, cache_duration) + device_config_list = get_supported_devices(vicare_api.devices) for device in device_config_list: _LOGGER.debug( "Found device: %s (online: %s)", device.getModel(), str(device.isOnline()) ) - # Currently we only support a single device - device = device_config_list[0] - hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG_LIST] = device_config_list - hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG] = device - hass.data[DOMAIN][entry.entry_id][VICARE_API] = getattr( - device, - HEATING_TYPE_TO_CREATOR_METHOD[HeatingType(entry.data[CONF_HEATING_TYPE])], - )() + hass.data[DOMAIN][entry.entry_id][DEVICE_LIST] = [ + ViCareDevice(config=device_config, api=get_device(entry, device_config)) + for device_config in device_config_list + ] async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index f3cf585b470..a78b1fe5dab 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -27,9 +27,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ViCareRequiredKeysMixin -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG +from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity +from .types import ViCareDevice, ViCareRequiredKeysMixin from .utils import get_burners, get_circuits, get_compressors, is_supported _LOGGER = logging.getLogger(__name__) @@ -111,29 +111,28 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( def _build_entities( - device: PyViCareDevice, - device_config: PyViCareDeviceConfig, + device_list: list[ViCareDevice], ) -> list[ViCareBinarySensor]: """Create ViCare binary sensor entities for a device.""" - entities: list[ViCareBinarySensor] = _build_entities_for_device( - device, device_config - ) - entities.extend( - _build_entities_for_component( - get_circuits(device), device_config, CIRCUIT_SENSORS + entities: list[ViCareBinarySensor] = [] + for device in device_list: + entities.extend(_build_entities_for_device(device.api, device.config)) + entities.extend( + _build_entities_for_component( + get_circuits(device.api), device.config, CIRCUIT_SENSORS + ) ) - ) - entities.extend( - _build_entities_for_component( - get_burners(device), device_config, BURNER_SENSORS + entities.extend( + _build_entities_for_component( + get_burners(device.api), device.config, BURNER_SENSORS + ) ) - ) - entities.extend( - _build_entities_for_component( - get_compressors(device), device_config, COMPRESSOR_SENSORS + entities.extend( + _build_entities_for_component( + get_compressors(device.api), device.config, COMPRESSOR_SENSORS + ) ) - ) return entities @@ -179,14 +178,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare binary sensor devices.""" - api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] - device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] + device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] async_add_entities( await hass.async_add_executor_job( _build_entities, - api, - device_config, + device_list, ) ) diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index 8f11fdf0ac5..ae32e66dff3 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -20,9 +20,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ViCareRequiredKeysMixinWithSet -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG +from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity +from .types import ViCareDevice, ViCareRequiredKeysMixinWithSet from .utils import is_supported _LOGGER = logging.getLogger(__name__) @@ -48,19 +48,19 @@ BUTTON_DESCRIPTIONS: tuple[ViCareButtonEntityDescription, ...] = ( def _build_entities( - api: PyViCareDevice, - device_config: PyViCareDeviceConfig, + device_list: list[ViCareDevice], ) -> list[ViCareButton]: """Create ViCare button entities for a device.""" return [ ViCareButton( - api, - device_config, + device.api, + device.config, description, ) + for device in device_list for description in BUTTON_DESCRIPTIONS - if is_supported(description.key, description, api) + if is_supported(description.key, description, device.api) ] @@ -70,14 +70,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare button entities.""" - api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] - device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] + device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] async_add_entities( await hass.async_add_executor_job( _build_entities, - api, - device_config, + device_list, ) ) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index fab496f63a6..6b6c727cf43 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -40,8 +40,9 @@ from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, Program +from .const import DEVICE_LIST, DOMAIN, Program from .entity import ViCareEntity +from .types import ViCareDevice from .utils import get_burners, get_circuits, get_compressors _LOGGER = logging.getLogger(__name__) @@ -90,18 +91,18 @@ HA_TO_VICARE_PRESET_HEATING = { def _build_entities( - api: PyViCareDevice, - device_config: PyViCareDeviceConfig, + device_list: list[ViCareDevice], ) -> list[ViCareClimate]: """Create ViCare climate entities for a device.""" return [ ViCareClimate( - api, + device.api, circuit, - device_config, + device.config, "heating", ) - for circuit in get_circuits(api) + for device in device_list + for circuit in get_circuits(device.api) ] @@ -111,8 +112,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ViCare climate platform.""" - api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] - device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] platform = entity_platform.async_get_current_platform() @@ -122,11 +121,12 @@ async def async_setup_entry( "set_vicare_mode", ) + device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] + async_add_entities( await hass.async_add_executor_job( _build_entities, - api, - device_config, + device_list, ) ) diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index 7dd91a3e134..0b1fde95d0d 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -14,15 +14,13 @@ PLATFORMS = [ Platform.WATER_HEATER, ] -VICARE_DEVICE_CONFIG = "device_conf" -VICARE_DEVICE_CONFIG_LIST = "device_config_list" -VICARE_API = "api" +DEVICE_LIST = "device_list" VICARE_NAME = "ViCare" CONF_CIRCUIT = "circuit" CONF_HEATING_TYPE = "heating_type" -DEFAULT_SCAN_INTERVAL = 60 +DEFAULT_CACHE_DURATION = 60 VICARE_CUBIC_METER = "cubicMeter" VICARE_KWH = "kilowattHour" diff --git a/homeassistant/components/vicare/diagnostics.py b/homeassistant/components/vicare/diagnostics.py index aa5d08f92d8..23a3c8640c5 100644 --- a/homeassistant/components/vicare/diagnostics.py +++ b/homeassistant/components/vicare/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN, VICARE_DEVICE_CONFIG_LIST +from .const import DEVICE_LIST, DOMAIN TO_REDACT = {CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME} @@ -18,10 +18,11 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - # Currently we only support a single device data = [] - for device in hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG_LIST]: - data.append(json.loads(await hass.async_add_executor_job(device.dump_secure))) + for device in hass.data[DOMAIN][entry.entry_id][DEVICE_LIST]: + data.append( + json.loads(await hass.async_add_executor_job(device.config.dump_secure)) + ) return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), "data": data, diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index a4675314c7b..32d314a51c6 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -29,9 +29,9 @@ from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ViCareRequiredKeysMixin -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, Program +from .const import DEVICE_LIST, DOMAIN, Program from .entity import ViCareEntity +from .types import ViCareDevice, ViCareRequiredKeysMixin from .utils import get_circuits, is_supported _LOGGER = logging.getLogger(__name__) @@ -189,18 +189,18 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( def _build_entities( - api: PyViCareDevice, - device_config: PyViCareDeviceConfig, + device_list: list[ViCareDevice], ) -> list[ViCareNumber]: - """Create ViCare number entities for a component.""" + """Create ViCare number entities for a device.""" return [ ViCareNumber( circuit, - device_config, + device.config, description, ) - for circuit in get_circuits(api) + for device in device_list + for circuit in get_circuits(device.api) for description in CIRCUIT_ENTITY_DESCRIPTIONS if is_supported(description.key, description, circuit) ] @@ -212,14 +212,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare number devices.""" - api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] - device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] + device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] async_add_entities( await hass.async_add_executor_job( _build_entities, - api, - device_config, + device_list, ) ) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index a8a21c7e787..cb76c910255 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -38,16 +38,15 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ViCareRequiredKeysMixin from .const import ( + DEVICE_LIST, DOMAIN, - VICARE_API, VICARE_CUBIC_METER, - VICARE_DEVICE_CONFIG, VICARE_KWH, VICARE_UNIT_TO_UNIT_OF_MEASUREMENT, ) from .entity import ViCareEntity +from .types import ViCareDevice, ViCareRequiredKeysMixin from .utils import get_burners, get_circuits, get_compressors, is_supported _LOGGER = logging.getLogger(__name__) @@ -601,6 +600,7 @@ BURNER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( translation_key="burner_starts", icon="mdi:counter", value_getter=lambda api: api.getStarts(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( @@ -609,6 +609,7 @@ BURNER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHours(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( @@ -627,6 +628,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( translation_key="compressor_starts", icon="mdi:counter", value_getter=lambda api: api.getStarts(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( @@ -635,6 +637,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHours(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( @@ -643,6 +646,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass1(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), @@ -652,6 +656,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass2(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), @@ -661,6 +666,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass3(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), @@ -670,6 +676,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass4(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), @@ -679,6 +686,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass5(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), @@ -693,27 +701,28 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( def _build_entities( - device: PyViCareDevice, - device_config: PyViCareDeviceConfig, + device_list: list[ViCareDevice], ) -> list[ViCareSensor]: """Create ViCare sensor entities for a device.""" - entities: list[ViCareSensor] = _build_entities_for_device(device, device_config) - entities.extend( - _build_entities_for_component( - get_circuits(device), device_config, CIRCUIT_SENSORS + entities: list[ViCareSensor] = [] + for device in device_list: + entities.extend(_build_entities_for_device(device.api, device.config)) + entities.extend( + _build_entities_for_component( + get_circuits(device.api), device.config, CIRCUIT_SENSORS + ) ) - ) - entities.extend( - _build_entities_for_component( - get_burners(device), device_config, BURNER_SENSORS + entities.extend( + _build_entities_for_component( + get_burners(device.api), device.config, BURNER_SENSORS + ) ) - ) - entities.extend( - _build_entities_for_component( - get_compressors(device), device_config, COMPRESSOR_SENSORS + entities.extend( + _build_entities_for_component( + get_compressors(device.api), device.config, COMPRESSOR_SENSORS + ) ) - ) return entities @@ -759,16 +768,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare sensor devices.""" - api: PyViCareDevice = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] - device_config: PyViCareDeviceConfig = hass.data[DOMAIN][config_entry.entry_id][ - VICARE_DEVICE_CONFIG - ] + device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] async_add_entities( await hass.async_add_executor_job( _build_entities, - api, - device_config, + device_list, ) ) diff --git a/homeassistant/components/vicare/types.py b/homeassistant/components/vicare/types.py new file mode 100644 index 00000000000..dcb6036d919 --- /dev/null +++ b/homeassistant/components/vicare/types.py @@ -0,0 +1,29 @@ +"""Types for the ViCare integration.""" +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from PyViCare.PyViCareDevice import Device as PyViCareDevice +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig + + +@dataclass(frozen=True) +class ViCareDevice: + """Dataclass holding the device api and config.""" + + config: PyViCareDeviceConfig + api: PyViCareDevice + + +@dataclass(frozen=True) +class ViCareRequiredKeysMixin: + """Mixin for required keys.""" + + value_getter: Callable[[PyViCareDevice], Any] + + +@dataclass(frozen=True) +class ViCareRequiredKeysMixinWithSet(ViCareRequiredKeysMixin): + """Mixin for required keys with setter.""" + + value_setter: Callable[[PyViCareDevice], bool] diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index a084eee383b..649b1859442 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -2,16 +2,30 @@ import logging from PyViCare.PyViCareDevice import Device as PyViCareDevice +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareHeatingDevice import ( HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent, ) from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError -from . import ViCareRequiredKeysMixin +from homeassistant.config_entries import ConfigEntry + +from .const import CONF_HEATING_TYPE, HEATING_TYPE_TO_CREATOR_METHOD, HeatingType +from .types import ViCareRequiredKeysMixin _LOGGER = logging.getLogger(__name__) +def get_device( + entry: ConfigEntry, device_config: PyViCareDeviceConfig +) -> PyViCareDevice: + """Get device for device config.""" + return getattr( + device_config, + HEATING_TYPE_TO_CREATOR_METHOD[HeatingType(entry.data[CONF_HEATING_TYPE])], + )() + + def is_supported( name: str, entity_description: ViCareRequiredKeysMixin, diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 66a90ca065b..9a8fb7eb092 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -24,8 +24,9 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemper from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG +from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity +from .types import ViCareDevice from .utils import get_circuits _LOGGER = logging.getLogger(__name__) @@ -61,18 +62,19 @@ HA_TO_VICARE_HVAC_DHW = { def _build_entities( - api: PyViCareDevice, - device_config: PyViCareDeviceConfig, + device_list: list[ViCareDevice], ) -> list[ViCareWater]: """Create ViCare domestic hot water entities for a device.""" + return [ ViCareWater( - api, + device.api, circuit, - device_config, + device.config, "domestic_hot_water", ) - for circuit in get_circuits(api) + for device in device_list + for circuit in get_circuits(device.api) ] @@ -82,14 +84,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ViCare water heater platform.""" - api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] - device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] + device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] async_add_entities( await hass.async_add_executor_job( _build_entities, - api, - device_config, + device_list, ) ) diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index e3de3caa99d..db3995772d4 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -24,7 +24,7 @@ from homeassistant.const import ( CONF_NAME, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_platform +from homeassistant.helpers import device_registry as dr, entity_platform from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( @@ -131,6 +131,10 @@ async def async_setup_entry( class VizioDevice(MediaPlayerEntity): """Media Player implementation which performs REST requests to device.""" + _attr_has_entity_name = True + _attr_name = None + _received_device_info = False + def __init__( self, config_entry: ConfigEntry, @@ -154,7 +158,7 @@ class VizioDevice(MediaPlayerEntity): CONF_ADDITIONAL_CONFIGS, [] ) self._device = device - self._max_volume = float(self._device.get_max_volume()) + self._max_volume = float(device.get_max_volume()) # Entity class attributes that will change with each update (we only include # the ones that are initialized differently from the defaults) @@ -162,10 +166,16 @@ class VizioDevice(MediaPlayerEntity): self._attr_supported_features = SUPPORTED_COMMANDS[device_class] # Entity class attributes that will not change - self._attr_name = name self._attr_icon = ICON[device_class] - self._attr_unique_id = self._config_entry.unique_id + unique_id = config_entry.unique_id + assert unique_id + self._attr_unique_id = unique_id self._attr_device_class = device_class + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="VIZIO", + name=name, + ) def _apps_list(self, apps: list[str]) -> list[str]: """Return process apps list based on configured filters.""" @@ -195,15 +205,19 @@ class VizioDevice(MediaPlayerEntity): ) self._attr_available = True - if not self._attr_device_info: - assert self._attr_unique_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._attr_unique_id)}, - manufacturer="VIZIO", - model=await self._device.get_model_name(log_api_exception=False), - name=self._attr_name, - sw_version=await self._device.get_version(log_api_exception=False), + if not self._received_device_info: + device_reg = dr.async_get(self.hass) + assert self._config_entry.unique_id + device = device_reg.async_get_device( + identifiers={(DOMAIN, self._config_entry.unique_id)} ) + if device: + device_reg.async_update_device( + device.id, + model=await self._device.get_model_name(log_api_exception=False), + sw_version=await self._device.get_version(log_api_exception=False), + ) + self._received_device_info = True if not is_on: self._attr_state = MediaPlayerState.OFF diff --git a/homeassistant/components/voicerss/tts.py b/homeassistant/components/voicerss/tts.py index 5bdc8bee3ac..4ac2aae0a71 100644 --- a/homeassistant/components/voicerss/tts.py +++ b/homeassistant/components/voicerss/tts.py @@ -209,7 +209,7 @@ class VoiceRSSProvider(Provider): _LOGGER.error("Error receive %s from VoiceRSS", str(data, "utf-8")) return (None, None) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.error("Timeout for VoiceRSS API") return (None, None) diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 11f70c631f1..a41f0965e8f 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -259,7 +259,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): if self.processing_tone_enabled: await self._play_processing_tone() - except asyncio.TimeoutError: + except TimeoutError: # Expected after caller hangs up _LOGGER.debug("Audio timeout") self._session_id = None @@ -304,7 +304,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): _LOGGER.debug("Pipeline finished") except PipelineNotFound: _LOGGER.warning("Pipeline not found") - except asyncio.TimeoutError: + except TimeoutError: # Expected after caller hangs up _LOGGER.debug("Pipeline timeout") self._session_id = None @@ -444,7 +444,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): async with asyncio.timeout(tts_seconds + self.tts_extra_timeout): # TTS audio is 16Khz 16-bit mono await self._async_send_audio(audio_bytes) - except asyncio.TimeoutError as err: + except TimeoutError as err: _LOGGER.warning("TTS timeout") raise err finally: diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index 8c8fb85b8b3..bf502023e2b 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -153,7 +153,7 @@ async def websocket_entity_info( try: async with asyncio.timeout(TIMEOUT_FETCH_WAKE_WORDS): wake_words = await entity.get_supported_wake_words() - except asyncio.TimeoutError: + except TimeoutError: connection.send_error( msg["id"], websocket_api.const.ERR_TIMEOUT, "Timeout fetching wake words" ) diff --git a/homeassistant/components/wallbox/manifest.json b/homeassistant/components/wallbox/manifest.json index a6e284ff22b..ce9008ef8bb 100644 --- a/homeassistant/components/wallbox/manifest.json +++ b/homeassistant/components/wallbox/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wallbox", "iot_class": "cloud_polling", "loggers": ["wallbox"], - "requirements": ["wallbox==0.4.14"] + "requirements": ["wallbox==0.6.0"] } diff --git a/homeassistant/components/weatherflow/config_flow.py b/homeassistant/components/weatherflow/config_flow.py index 5ce737810b0..d4ee319e70b 100644 --- a/homeassistant/components/weatherflow/config_flow.py +++ b/homeassistant/components/weatherflow/config_flow.py @@ -36,7 +36,7 @@ async def _async_can_discover_devices() -> bool: try: client.on(EVENT_DEVICE_DISCOVERED, _async_found) await future_event - except asyncio.TimeoutError: + except TimeoutError: return False return True diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index 830c0a4134a..84675196d86 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -32,6 +32,6 @@ WEBOSTV_EXCEPTIONS = ( ConnectionClosedOK, ConnectionRefusedError, WebOsTvCommandError, - asyncio.TimeoutError, + TimeoutError, asyncio.CancelledError, ) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 9152739852e..ed8e1a6cc6e 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "loggers": ["aiowebostv"], "quality_scale": "platinum", - "requirements": ["aiowebostv==0.3.3"], + "requirements": ["aiowebostv==0.4.0"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 554d5e0b1d6..aefb6e77444 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -474,7 +474,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): content = None websession = async_get_clientsession(self.hass) - with suppress(asyncio.TimeoutError): + with suppress(TimeoutError): async with asyncio.timeout(10): response = await websession.get(url, ssl=False) if response.status == HTTPStatus.OK: diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index c088acc6e00..c2f7a8ce669 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -356,7 +356,9 @@ def _send_handle_get_states_response( ) -> None: """Send handle get states response.""" connection.send_message( - construct_result_message(msg_id, b"[" + b",".join(serialized_states) + b"]") + construct_result_message( + msg_id, b"".join((b"[", b",".join(serialized_states), b"]")) + ) ) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index e4540dfac35..280ff41c56e 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -1,7 +1,6 @@ """Connection session.""" from __future__ import annotations -import asyncio from collections.abc import Callable, Hashable from contextvars import ContextVar from typing import TYPE_CHECKING, Any @@ -266,7 +265,7 @@ class ActiveConnection: elif isinstance(err, vol.Invalid): code = const.ERR_INVALID_FORMAT err_message = vol.humanize.humanize_error(msg, err) - elif isinstance(err, asyncio.TimeoutError): + elif isinstance(err, TimeoutError): code = const.ERR_TIMEOUT err_message = "Timeout" elif isinstance(err, HomeAssistantError): diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 416573d493c..77e645a7314 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -282,7 +282,7 @@ class WebSocketHandler: try: async with asyncio.timeout(10): await wsock.prepare(request) - except asyncio.TimeoutError: + except TimeoutError: self._logger.warning("Timeout preparing request from %s", request.remote) return wsock @@ -310,7 +310,7 @@ class WebSocketHandler: # Auth Phase try: msg = await wsock.receive(10) - except asyncio.TimeoutError as err: + except TimeoutError as err: disconnect_warn = "Did not receive auth message within 10 seconds" raise Disconnect from err diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 42ffe7dd77e..10b26801c10 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -1,5 +1,4 @@ """The Whirlpool Appliances integration.""" -import asyncio from dataclasses import dataclass import logging @@ -35,7 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: await auth.do_auth(store=False) - except (ClientError, asyncio.TimeoutError) as ex: + except (ClientError, TimeoutError) as ex: raise ConfigEntryNotReady("Cannot connect") from ex if not auth.is_access_token_valid(): diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index fbbb670b6da..dbd3f9b6fd4 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Whirlpool Appliances integration.""" from __future__ import annotations -import asyncio from collections.abc import Mapping import logging from typing import Any @@ -48,7 +47,7 @@ async def validate_input( auth = Auth(backend_selector, data[CONF_USERNAME], data[CONF_PASSWORD], session) try: await auth.do_auth() - except (asyncio.TimeoutError, ClientError) as exc: + except (TimeoutError, ClientError) as exc: raise CannotConnect from exc if not auth.is_access_token_valid(): @@ -92,7 +91,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await validate_input(self.hass, data) except InvalidAuth: errors["base"] = "invalid_auth" - except (CannotConnect, asyncio.TimeoutError): + except (CannotConnect, TimeoutError): errors["base"] = "cannot_connect" else: self.hass.config_entries.async_update_entry( diff --git a/homeassistant/components/wiz/light.py b/homeassistant/components/wiz/light.py index 216c5d9335e..3a6ba2a9f5b 100644 --- a/homeassistant/components/wiz/light.py +++ b/homeassistant/components/wiz/light.py @@ -16,6 +16,7 @@ from homeassistant.components.light import ( ColorMode, LightEntity, LightEntityFeature, + filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -72,21 +73,24 @@ class WizBulbEntity(WizToggleEntity, LightEntity): """Representation of WiZ Light bulb.""" _attr_name = None + _fixed_color_mode: ColorMode | None = None def __init__(self, wiz_data: WizData, name: str) -> None: """Initialize an WiZLight.""" super().__init__(wiz_data, name) bulb_type: BulbType = self._device.bulbtype features: Features = bulb_type.features - self._attr_supported_color_modes: set[ColorMode | str] = set() + color_modes = {ColorMode.ONOFF} if features.color: - self._attr_supported_color_modes.add( - RGB_WHITE_CHANNELS_COLOR_MODE[bulb_type.white_channels] - ) + color_modes.add(RGB_WHITE_CHANNELS_COLOR_MODE[bulb_type.white_channels]) if features.color_tmp: - self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) - if not self._attr_supported_color_modes and features.brightness: - self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) + color_modes.add(ColorMode.COLOR_TEMP) + if features.brightness: + color_modes.add(ColorMode.BRIGHTNESS) + self._attr_supported_color_modes = filter_supported_color_modes(color_modes) + if len(self._attr_supported_color_modes) == 1: + # If the light supports only a single color mode, set it now + self._attr_color_mode = next(iter(self._attr_supported_color_modes)) self._attr_effect_list = wiz_data.scenes if bulb_type.bulb_type != BulbClass.DW: kelvin = bulb_type.kelvin_range @@ -117,8 +121,6 @@ class WizBulbEntity(WizToggleEntity, LightEntity): elif ColorMode.RGBW in color_modes and (rgbw := state.get_rgbw()) is not None: self._attr_rgbw_color = rgbw self._attr_color_mode = ColorMode.RGBW - else: - self._attr_color_mode = ColorMode.BRIGHTNESS self._attr_effect = state.get_scene() super()._async_update_attrs() diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index bda3a576563..04a3a2544c1 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -1,4 +1,5 @@ """Sensor to indicate whether the current day is a workday.""" + from __future__ import annotations from datetime import date, timedelta @@ -53,7 +54,7 @@ def validate_dates(holiday_list: list[str]) -> list[str]: continue _range: timedelta = d2 - d1 for i in range(_range.days + 1): - day = d1 + timedelta(days=i) + day: date = d1 + timedelta(days=i) calc_holidays.append(day.strftime("%Y-%m-%d")) continue calc_holidays.append(add_date) @@ -123,25 +124,46 @@ async def async_setup_entry( LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) except KeyError as unmatched: LOGGER.warning("No holiday found matching %s", unmatched) - async_create_issue( - hass, - DOMAIN, - f"bad_named_holiday-{entry.entry_id}-{slugify(remove_holiday)}", - is_fixable=True, - is_persistent=False, - severity=IssueSeverity.WARNING, - translation_key="bad_named_holiday", - translation_placeholders={ - CONF_COUNTRY: country if country else "-", - "title": entry.title, - CONF_REMOVE_HOLIDAYS: remove_holiday, - }, - data={ - "entry_id": entry.entry_id, - "country": country, - "named_holiday": remove_holiday, - }, - ) + if dt_util.parse_date(remove_holiday): + async_create_issue( + hass, + DOMAIN, + f"bad_date_holiday-{entry.entry_id}-{slugify(remove_holiday)}", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="bad_date_holiday", + translation_placeholders={ + CONF_COUNTRY: country if country else "-", + "title": entry.title, + CONF_REMOVE_HOLIDAYS: remove_holiday, + }, + data={ + "entry_id": entry.entry_id, + "country": country, + "named_holiday": remove_holiday, + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + f"bad_named_holiday-{entry.entry_id}-{slugify(remove_holiday)}", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="bad_named_holiday", + translation_placeholders={ + CONF_COUNTRY: country if country else "-", + "title": entry.title, + CONF_REMOVE_HOLIDAYS: remove_holiday, + }, + data={ + "entry_id": entry.entry_id, + "country": country, + "named_holiday": remove_holiday, + }, + ) LOGGER.debug("Found the following holidays for your configuration:") for holiday_date, name in sorted(obj_holidays.items()): diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 27d440d4832..05026ae6e99 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.41"] + "requirements": ["holidays==0.42"] } diff --git a/homeassistant/components/workday/repairs.py b/homeassistant/components/workday/repairs.py index 905434f76ac..1221514da42 100644 --- a/homeassistant/components/workday/repairs.py +++ b/homeassistant/components/workday/repairs.py @@ -125,9 +125,9 @@ class HolidayFixFlow(RepairsFlow): self, user_input: dict[str, str] | None = None ) -> data_entry_flow.FlowResult: """Handle the first step of a fix flow.""" - return await self.async_step_named_holiday() + return await self.async_step_fix_remove_holiday() - async def async_step_named_holiday( + async def async_step_fix_remove_holiday( self, user_input: dict[str, Any] | None = None ) -> data_entry_flow.FlowResult: """Handle the options step of a fix flow.""" @@ -168,7 +168,7 @@ class HolidayFixFlow(RepairsFlow): {CONF_REMOVE_HOLIDAYS: removed_named_holiday}, ) return self.async_show_form( - step_id="named_holiday", + step_id="fix_remove_holiday", data_schema=new_schema, description_placeholders={ CONF_COUNTRY: self.country if self.country else "-", diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index bbb76676f96..0e618beaf82 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -137,7 +137,7 @@ "title": "Configured named holiday {remove_holidays} for {title} does not exist", "fix_flow": { "step": { - "named_holiday": { + "fix_remove_holiday": { "title": "[%key:component::workday::issues::bad_named_holiday::title%]", "description": "Remove named holiday `{remove_holidays}` as it can't be found in country {country}.", "data": { @@ -152,6 +152,26 @@ "remove_holiday_error": "[%key:component::workday::config::error::remove_holiday_error%]" } } + }, + "bad_date_holiday": { + "title": "Configured holiday date {remove_holidays} for {title} does not exist", + "fix_flow": { + "step": { + "fix_remove_holiday": { + "title": "[%key:component::workday::issues::bad_date_holiday::title%]", + "description": "Remove holiday date `{remove_holidays}` as it can't be found in country {country}.", + "data": { + "remove_holidays": "[%key:component::workday::config::step::options::data::remove_holidays%]" + }, + "data_description": { + "remove_holidays": "[%key:component::workday::config::step::options::data_description::remove_holidays%]" + } + } + }, + "error": { + "remove_holiday_error": "[%key:component::workday::config::error::remove_holiday_error%]" + } + } } }, "entity": { diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py index 111acc5fff6..16073a3d862 100644 --- a/homeassistant/components/worxlandroid/sensor.py +++ b/homeassistant/components/worxlandroid/sensor.py @@ -97,7 +97,7 @@ class WorxLandroidSensor(SensorEntity): async with asyncio.timeout(self.timeout): auth = aiohttp.helpers.BasicAuth("admin", self.pin) mower_response = await session.get(self.url, auth=auth) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): if self.allow_unreachable is False: _LOGGER.error("Error connecting to mower at %s", self.url) diff --git a/homeassistant/components/wyoming/data.py b/homeassistant/components/wyoming/data.py index ea58181a707..adcb472d5e0 100644 --- a/homeassistant/components/wyoming/data.py +++ b/homeassistant/components/wyoming/data.py @@ -107,7 +107,7 @@ async def load_wyoming_info( if wyoming_info is not None: break # for - except (asyncio.TimeoutError, OSError, WyomingError): + except (TimeoutError, OSError, WyomingError): # Sleep and try again await asyncio.sleep(retry_wait) diff --git a/homeassistant/components/xiaomi_ble/binary_sensor.py b/homeassistant/components/xiaomi_ble/binary_sensor.py index 2894b8d2f3f..cd6f7b453bb 100644 --- a/homeassistant/components/xiaomi_ble/binary_sensor.py +++ b/homeassistant/components/xiaomi_ble/binary_sensor.py @@ -29,6 +29,10 @@ from .coordinator import ( from .device import device_key_to_bluetooth_entity_key BINARY_SENSOR_DESCRIPTIONS = { + XiaomiBinarySensorDeviceClass.BATTERY: BinarySensorEntityDescription( + key=XiaomiBinarySensorDeviceClass.BATTERY, + device_class=BinarySensorDeviceClass.BATTERY, + ), XiaomiBinarySensorDeviceClass.DOOR: BinarySensorEntityDescription( key=XiaomiBinarySensorDeviceClass.DOOR, device_class=BinarySensorDeviceClass.DOOR, @@ -49,6 +53,10 @@ BINARY_SENSOR_DESCRIPTIONS = { key=XiaomiBinarySensorDeviceClass.OPENING, device_class=BinarySensorDeviceClass.OPENING, ), + XiaomiBinarySensorDeviceClass.POWER: BinarySensorEntityDescription( + key=XiaomiBinarySensorDeviceClass.POWER, + device_class=BinarySensorDeviceClass.POWER, + ), XiaomiBinarySensorDeviceClass.SMOKE: BinarySensorEntityDescription( key=XiaomiBinarySensorDeviceClass.SMOKE, device_class=BinarySensorDeviceClass.SMOKE, diff --git a/homeassistant/components/xiaomi_ble/config_flow.py b/homeassistant/components/xiaomi_ble/config_flow.py index a0c03581eee..576d49296e9 100644 --- a/homeassistant/components/xiaomi_ble/config_flow.py +++ b/homeassistant/components/xiaomi_ble/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Xiaomi Bluetooth integration.""" from __future__ import annotations -import asyncio from collections.abc import Mapping import dataclasses from typing import Any @@ -96,7 +95,7 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN): self._discovery_info = await self._async_wait_for_full_advertisement( discovery_info, device ) - except asyncio.TimeoutError: + except TimeoutError: # This device might have a really long advertising interval # So create a config entry for it, and if we discover it has # encryption later, we can do a reauth @@ -220,7 +219,7 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN): self._discovery_info = await self._async_wait_for_full_advertisement( discovery.discovery_info, discovery.device ) - except asyncio.TimeoutError: + except TimeoutError: # This device might have a really long advertising interval # So create a config entry for it, and if we discover # it has encryption later, we can do a reauth diff --git a/homeassistant/components/xiaomi_ble/const.py b/homeassistant/components/xiaomi_ble/const.py index 1accfd9dc55..5f9dea9eb45 100644 --- a/homeassistant/components/xiaomi_ble/const.py +++ b/homeassistant/components/xiaomi_ble/const.py @@ -19,14 +19,23 @@ EVENT_PROPERTIES: Final = "event_properties" XIAOMI_BLE_EVENT: Final = "xiaomi_ble_event" EVENT_CLASS_BUTTON: Final = "button" +EVENT_CLASS_DIMMER: Final = "dimmer" EVENT_CLASS_MOTION: Final = "motion" +EVENT_CLASS_CUBE: Final = "cube" BUTTON: Final = "button" +CUBE: Final = "cube" +DIMMER: Final = "dimmer" DOUBLE_BUTTON: Final = "double_button" TRIPPLE_BUTTON: Final = "tripple_button" +REMOTE: Final = "remote" +REMOTE_FAN: Final = "remote_fan" +REMOTE_VENFAN: Final = "remote_ventilator_fan" +REMOTE_BATHROOM: Final = "remote_bathroom" MOTION: Final = "motion" BUTTON_PRESS: Final = "button_press" +BUTTON_PRESS_LONG: Final = "button_press_long" BUTTON_PRESS_DOUBLE_LONG: Final = "button_press_double_long" DOUBLE_BUTTON_PRESS_DOUBLE_LONG: Final = "double_button_press_double_long" TRIPPLE_BUTTON_PRESS_DOUBLE_LONG: Final = "tripple_button_press_double_long" diff --git a/homeassistant/components/xiaomi_ble/device_trigger.py b/homeassistant/components/xiaomi_ble/device_trigger.py index 6d29af9ac11..8d281ddc8a9 100644 --- a/homeassistant/components/xiaomi_ble/device_trigger.py +++ b/homeassistant/components/xiaomi_ble/device_trigger.py @@ -24,16 +24,25 @@ from .const import ( BUTTON, BUTTON_PRESS, BUTTON_PRESS_DOUBLE_LONG, + BUTTON_PRESS_LONG, CONF_SUBTYPE, + CUBE, + DIMMER, DOMAIN, DOUBLE_BUTTON, DOUBLE_BUTTON_PRESS_DOUBLE_LONG, EVENT_CLASS, EVENT_CLASS_BUTTON, + EVENT_CLASS_CUBE, + EVENT_CLASS_DIMMER, EVENT_CLASS_MOTION, EVENT_TYPE, MOTION, MOTION_DEVICE, + REMOTE, + REMOTE_BATHROOM, + REMOTE_FAN, + REMOTE_VENFAN, TRIPPLE_BUTTON, TRIPPLE_BUTTON_PRESS_DOUBLE_LONG, XIAOMI_BLE_EVENT, @@ -41,14 +50,61 @@ from .const import ( TRIGGERS_BY_TYPE = { BUTTON_PRESS: ["press"], + BUTTON_PRESS_LONG: ["press", "long_press"], BUTTON_PRESS_DOUBLE_LONG: ["press", "double_press", "long_press"], + CUBE: ["rotate_left", "rotate_right"], + DIMMER: [ + "press", + "long_press", + "rotate_left", + "rotate_right", + "rotate_left_pressed", + "rotate_right_pressed", + ], MOTION_DEVICE: ["motion_detected"], } EVENT_TYPES = { BUTTON: ["button"], + CUBE: ["cube"], + DIMMER: ["dimmer"], DOUBLE_BUTTON: ["button_left", "button_right"], TRIPPLE_BUTTON: ["button_left", "button_middle", "button_right"], + REMOTE: [ + "button_on", + "button_off", + "button_brightness", + "button_plus", + "button_min", + "button_m", + ], + REMOTE_BATHROOM: [ + "button_heat", + "button_air_exchange", + "button_dry", + "button_fan", + "button_swing", + "button_decrease_speed", + "button_increase_speed", + "button_stop", + "button_light", + ], + REMOTE_FAN: [ + "button_fan", + "button_light", + "button_wind_speed", + "button_wind_mode", + "button_brightness", + "button_color_temperature", + ], + REMOTE_VENFAN: [ + "button_swing", + "button_power", + "button_timer_30_minutes", + "button_timer_60_minutes", + "button_increase_wind_speed", + "button_decrease_wind_speed", + ], MOTION: ["motion"], } @@ -78,11 +134,41 @@ TRIGGER_MODEL_DATA = { event_types=EVENT_TYPES[DOUBLE_BUTTON], triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], ), + CUBE: TriggerModelData( + event_class=EVENT_CLASS_CUBE, + event_types=EVENT_TYPES[CUBE], + triggers=TRIGGERS_BY_TYPE[CUBE], + ), + DIMMER: TriggerModelData( + event_class=EVENT_CLASS_DIMMER, + event_types=EVENT_TYPES[DIMMER], + triggers=TRIGGERS_BY_TYPE[DIMMER], + ), TRIPPLE_BUTTON_PRESS_DOUBLE_LONG: TriggerModelData( event_class=EVENT_CLASS_BUTTON, event_types=EVENT_TYPES[TRIPPLE_BUTTON], triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], ), + REMOTE: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[REMOTE], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_LONG], + ), + REMOTE_BATHROOM: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[REMOTE_BATHROOM], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_LONG], + ), + REMOTE_FAN: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[REMOTE_FAN], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_LONG], + ), + REMOTE_VENFAN: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[REMOTE_VENFAN], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_LONG], + ), MOTION_DEVICE: TriggerModelData( event_class=EVENT_CLASS_MOTION, event_types=EVENT_TYPES[MOTION], @@ -103,7 +189,13 @@ MODEL_DATA = { "XMWXKG01YL": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG], "K9B-2BTN": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG], "K9B-3BTN": TRIGGER_MODEL_DATA[TRIPPLE_BUTTON_PRESS_DOUBLE_LONG], + "YLYK01YL": TRIGGER_MODEL_DATA[REMOTE], + "YLYK01YL-FANRC": TRIGGER_MODEL_DATA[REMOTE_FAN], + "YLYK01YL-VENFAN": TRIGGER_MODEL_DATA[REMOTE_VENFAN], + "YLYK01YL-BHFRC": TRIGGER_MODEL_DATA[REMOTE_BATHROOM], "MUE4094RT": TRIGGER_MODEL_DATA[MOTION_DEVICE], + "XMMF01JQD": TRIGGER_MODEL_DATA[CUBE], + "YLKG07YL/YLKG08YL": TRIGGER_MODEL_DATA[DIMMER], } diff --git a/homeassistant/components/xiaomi_ble/event.py b/homeassistant/components/xiaomi_ble/event.py index 1d5b08fb8f9..2c1550dc5d7 100644 --- a/homeassistant/components/xiaomi_ble/event.py +++ b/homeassistant/components/xiaomi_ble/event.py @@ -18,6 +18,8 @@ from . import format_discovered_event_class, format_event_dispatcher_name from .const import ( DOMAIN, EVENT_CLASS_BUTTON, + EVENT_CLASS_CUBE, + EVENT_CLASS_DIMMER, EVENT_CLASS_MOTION, EVENT_PROPERTIES, EVENT_TYPE, @@ -36,10 +38,31 @@ DESCRIPTIONS_BY_EVENT_CLASS = { ], device_class=EventDeviceClass.BUTTON, ), + EVENT_CLASS_CUBE: EventEntityDescription( + key=EVENT_CLASS_CUBE, + translation_key="cube", + event_types=[ + "rotate_left", + "rotate_right", + ], + ), + EVENT_CLASS_DIMMER: EventEntityDescription( + key=EVENT_CLASS_DIMMER, + translation_key="dimmer", + event_types=[ + "press", + "long_press", + "rotate_left", + "rotate_right", + "rotate_left_pressed", + "rotate_right_pressed", + ], + ), EVENT_CLASS_MOTION: EventEntityDescription( key=EVENT_CLASS_MOTION, translation_key="motion", event_types=["motion_detected"], + device_class=EventDeviceClass.MOTION, ), } diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index f11b2426f96..a380ecb8e94 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.23.1"] + "requirements": ["xiaomi-ble==0.25.2"] } diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index c7cbe43bd94..d764a436f4c 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -44,14 +44,43 @@ "press": "Press", "double_press": "Double Press", "long_press": "Long Press", - "motion_detected": "Motion Detected" + "motion_detected": "Motion Detected", + "rotate_left": "Rotate Left", + "rotate_right": "Rotate Right", + "rotate_left_pressed": "Rotate Left (Pressed)", + "rotate_right_pressed": "Rotate Right (Pressed)" }, "trigger_type": { "button": "Button \"{subtype}\"", "button_left": "Button Left \"{subtype}\"", "button_middle": "Button Middle \"{subtype}\"", "button_right": "Button Right \"{subtype}\"", - "motion": "{subtype}" + "button_on": "Button On \"{subtype}\"", + "button_off": "Button Off \"{subtype}\"", + "button_brightness": "Button Brightness \"{subtype}\"", + "button_plus": "Button Plus \"{subtype}\"", + "button_min": "Button Min \"{subtype}\"", + "button_m": "Button M \"{subtype}\"", + "button_heat": "Button Heat \"{subtype}\"", + "button_air_exchange": "Button Air Exchange \"{subtype}\"", + "button_dry": "Button Dry \"{subtype}\"", + "button_fan": "Button Fan \"{subtype}\"", + "button_swing": "Button Swing \"{subtype}\"", + "button_decrease_speed": "Button Decrease Speed \"{subtype}\"", + "button_increase_speed": "Button Inrease Speed \"{subtype}\"", + "button_stop": "Button Stop \"{subtype}\"", + "button_light": "Button Light \"{subtype}\"", + "button_wind_speed": "Button Wind Speed \"{subtype}\"", + "button_wind_mode": "Button Wind Mode \"{subtype}\"", + "button_color_temperature": "Button Color Temperature \"{subtype}\"", + "button_power": "Button Power \"{subtype}\"", + "button_timer_30_minutes": "Button Timer 30 Minutes \"{subtype}\"", + "button_timer_60_minutes": "Button Timer 30 Minutes \"{subtype}\"", + "button_increase_wind_speed": "Button Increase Wind Speed \"{subtype}\"", + "button_decrease_wind_speed": "Button Decrease Wind Speed \"{subtype}\"", + "dimmer": "{subtype}", + "motion": "{subtype}", + "cube": "{subtype}" } }, "entity": { @@ -67,6 +96,30 @@ } } }, + "cube": { + "state_attributes": { + "event_type": { + "state": { + "rotate_left": "Rotate left", + "rotate_right": "Rotate right" + } + } + } + }, + "dimmer": { + "state_attributes": { + "event_type": { + "state": { + "press": "Press", + "long_press": "Long press", + "rotate_left": "Rotate left", + "rotate_right": "Rotate right", + "rotate_left_pressed": "Rotate left (pressed)", + "rotate_right_pressed": "Rotate right (pressed)" + } + } + } + }, "motion": { "state_attributes": { "event_type": { diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index 830d8d9f69e..dac5a98d738 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -64,7 +64,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, options=new_options) - entry.version = 2 + hass.config_entries.async_update_entry(entry, version=2) LOGGER.info("Migration to version %s successful", entry.version) diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index b5683777c24..9d2679d79d3 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -1,8 +1,6 @@ """The Yale Access Bluetooth integration.""" from __future__ import annotations -import asyncio - from yalexs_ble import ( AuthError, ConnectionInfo, @@ -89,7 +87,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await push_lock.wait_for_first_update(DEVICE_TIMEOUT) except AuthError as ex: raise ConfigEntryAuthFailed(str(ex)) from ex - except (YaleXSBLEError, asyncio.TimeoutError) as ex: + except (YaleXSBLEError, TimeoutError) as ex: raise ConfigEntryNotReady( f"{ex}; Try moving the Bluetooth adapter closer to {local_name}" ) from ex diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index dcd7e57ce1f..c9ed4bc6a8f 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.4.0"] + "requirements": ["yalexs-ble==2.4.1"] } diff --git a/homeassistant/components/yandextts/tts.py b/homeassistant/components/yandextts/tts.py index 481678100de..ca4f8400022 100644 --- a/homeassistant/components/yandextts/tts.py +++ b/homeassistant/components/yandextts/tts.py @@ -139,7 +139,7 @@ class YandexSpeechKitProvider(Provider): return (None, None) data = await request.read() - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.error("Timeout for yandex speech kit API") return (None, None) diff --git a/homeassistant/components/yardian/coordinator.py b/homeassistant/components/yardian/coordinator.py index e7102f9c74b..b0c8a882474 100644 --- a/homeassistant/components/yardian/coordinator.py +++ b/homeassistant/components/yardian/coordinator.py @@ -64,7 +64,7 @@ class YardianUpdateCoordinator(DataUpdateCoordinator[YardianDeviceState]): async with asyncio.timeout(10): return await self.controller.fetch_device_state() - except asyncio.TimeoutError as e: + except TimeoutError as e: raise UpdateFailed("Communication with Device was time out") from e except NotAuthorizedException as e: raise UpdateFailed("Invalid access token") from e diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index cc9faa33194..f77e4d08dc9 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -1,7 +1,6 @@ """Support for Xiaomi Yeelight WiFi color bulb.""" from __future__ import annotations -import asyncio import logging import voluptuous as vol @@ -214,7 +213,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: device = await _async_get_device(hass, entry.data[CONF_HOST], entry) await _async_initialize(hass, entry, device) - except (asyncio.TimeoutError, OSError, BulbException) as ex: + except (TimeoutError, OSError, BulbException) as ex: raise ConfigEntryNotReady from ex found_unique_id = device.unique_id diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 23a2a131913..3130d844767 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Yeelight integration.""" from __future__ import annotations -import asyncio import logging from urllib.parse import urlparse @@ -268,7 +267,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await bulb.async_listen(lambda _: True) await bulb.async_get_properties() await bulb.async_stop_listening() - except (asyncio.TimeoutError, yeelight.BulbException) as err: + except (TimeoutError, yeelight.BulbException) as err: _LOGGER.debug("Failed to get properties from %s: %s", host, err) raise CannotConnect from err _LOGGER.debug("Get properties: %s", bulb.last_properties) diff --git a/homeassistant/components/yeelight/device.py b/homeassistant/components/yeelight/device.py index 811a1904b04..bb5159c0b3b 100644 --- a/homeassistant/components/yeelight/device.py +++ b/homeassistant/components/yeelight/device.py @@ -1,7 +1,6 @@ """Support for Xiaomi Yeelight WiFi color bulb.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -176,7 +175,7 @@ class YeelightDevice: self._available = True if not self._initialized: self._initialized = True - except asyncio.TimeoutError as ex: + except TimeoutError as ex: _LOGGER.debug( "timed out while trying to update device %s, %s: %s", self._host, diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index a9834823f5e..abc17b8abd8 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -1,7 +1,6 @@ """Light platform support for yeelight.""" from __future__ import annotations -import asyncio from collections.abc import Callable, Coroutine import logging import math @@ -255,7 +254,7 @@ def _async_cmd( try: _LOGGER.debug("Calling %s with %s %s", func, args, kwargs) return await func(self, *args, **kwargs) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: # The wifi likely dropped, so we want to retry once since # python-yeelight will auto reconnect if attempts == 0: diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index f2a11aaf1fe..20f8ed3ed4d 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "quality_scale": "platinum", - "requirements": ["yeelight==0.7.14", "async-upnp-client==0.38.1"], + "requirements": ["yeelight==0.7.14", "async-upnp-client==0.38.2"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py index 43e976eeeac..8fa41bb92b1 100644 --- a/homeassistant/components/yeelight/scanner.py +++ b/homeassistant/components/yeelight/scanner.py @@ -155,7 +155,7 @@ class YeelightScanner: for listener in self._listeners: listener.async_search((host, SSDP_TARGET[1])) - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): async with asyncio.timeout(DISCOVERY_TIMEOUT): await host_event.wait() diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 473c85d563a..270bd550038 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -19,8 +19,10 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, + config_validation as cv, device_registry as dr, ) +from homeassistant.helpers.typing import ConfigType from . import api from .const import DOMAIN, YOLINK_EVENT @@ -30,6 +32,8 @@ from .services import async_register_services SCAN_INTERVAL = timedelta(minutes=5) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + PLATFORMS = [ Platform.BINARY_SENSOR, @@ -96,6 +100,14 @@ class YoLinkHomeStore: device_coordinators: dict[str, YoLinkCoordinator] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up YoLink.""" + + async_register_services(hass) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up yolink from a config entry.""" hass.data.setdefault(DOMAIN, {}) @@ -118,7 +130,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) except YoLinkAuthFailError as yl_auth_err: raise ConfigEntryAuthFailed from yl_auth_err - except (YoLinkClientError, asyncio.TimeoutError) as err: + except (YoLinkClientError, TimeoutError) as err: raise ConfigEntryNotReady from err device_coordinators = {} @@ -147,8 +159,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async_register_services(hass, entry) - async def async_yolink_unload(event) -> None: """Unload yolink.""" await yolink_home.async_unload() diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index 0650cc3a203..0762a3b5c60 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -63,7 +63,7 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( YoLinkBinarySensorEntityDescription( key="leak_state", device_class=BinarySensorDeviceClass.MOISTURE, - value=lambda value: value == "alert" if value is not None else None, + value=lambda value: value in ("alert", "full") if value is not None else None, exists_fn=lambda device: device.device_type == ATTR_DEVICE_LEAK_SENSOR, ), YoLinkBinarySensorEntityDescription( diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 6fd62ce571c..aae5be3f9d3 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.3.6"] + "requirements": ["yolink-api==0.3.7"] } diff --git a/homeassistant/components/yolink/number.py b/homeassistant/components/yolink/number.py index 1ec20cd4d17..a7ba89e1f6c 100644 --- a/homeassistant/components/yolink/number.py +++ b/homeassistant/components/yolink/number.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import Any from yolink.client_request import ClientRequest from yolink.const import ATTR_DEVICE_SPEAKER_HUB @@ -30,6 +31,7 @@ class YoLinkNumberTypeConfigEntityDescription(NumberEntityDescription): """YoLink NumberEntity description.""" exists_fn: Callable[[YoLinkDevice], bool] + should_update_entity: Callable value: Callable @@ -37,6 +39,14 @@ NUMBER_TYPE_CONF_SUPPORT_DEVICES = [ATTR_DEVICE_SPEAKER_HUB] SUPPORT_SET_VOLUME_DEVICES = [ATTR_DEVICE_SPEAKER_HUB] + +def get_volume_value(state: dict[str, Any]) -> int | None: + """Get volume option.""" + if (options := state.get("options")) is not None: + return options.get("volume") + return None + + DEVICE_CONFIG_DESCRIPTIONS: tuple[YoLinkNumberTypeConfigEntityDescription, ...] = ( YoLinkNumberTypeConfigEntityDescription( key=OPTIONS_VALUME, @@ -48,7 +58,8 @@ DEVICE_CONFIG_DESCRIPTIONS: tuple[YoLinkNumberTypeConfigEntityDescription, ...] native_unit_of_measurement=None, icon="mdi:volume-high", exists_fn=lambda device: device.device_type in SUPPORT_SET_VOLUME_DEVICES, - value=lambda state: state["options"]["volume"], + should_update_entity=lambda value: value is not None, + value=get_volume_value, ), ) @@ -98,7 +109,10 @@ class YoLinkNumberTypeConfigEntity(YoLinkEntity, NumberEntity): @callback def update_entity_state(self, state: dict) -> None: """Update HA Entity State.""" - attr_val = self.entity_description.value(state) + if ( + attr_val := self.entity_description.value(state) + ) is None and self.entity_description.should_update_entity(attr_val) is False: + return self._attr_native_value = attr_val self.async_write_ha_state() diff --git a/homeassistant/components/yolink/services.py b/homeassistant/components/yolink/services.py index bb2c660ef56..e41e3dce260 100644 --- a/homeassistant/components/yolink/services.py +++ b/homeassistant/components/yolink/services.py @@ -3,8 +3,9 @@ import voluptuous as vol from yolink.client_request import ClientRequest -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ( @@ -19,7 +20,7 @@ from .const import ( SERVICE_PLAY_ON_SPEAKER_HUB = "play_on_speaker_hub" -def async_register_services(hass: HomeAssistant, entry: ConfigEntry) -> None: +def async_register_services(hass: HomeAssistant) -> None: """Register services for YoLink integration.""" async def handle_speaker_hub_play_call(service_call: ServiceCall) -> None: @@ -28,6 +29,17 @@ def async_register_services(hass: HomeAssistant, entry: ConfigEntry) -> None: device_registry = dr.async_get(hass) device_entry = device_registry.async_get(service_data[ATTR_TARGET_DEVICE]) if device_entry is not None: + for entry_id in device_entry.config_entries: + if (entry := hass.config_entries.async_get_entry(entry_id)) is None: + continue + if entry.domain == DOMAIN: + break + if entry is None or entry.state == ConfigEntryState.NOT_LOADED: + raise ServiceValidationError( + "Config entry not found or not loaded!", + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + ) home_store = hass.data[DOMAIN][entry.entry_id] for identifier in device_entry.identifiers: if ( diff --git a/homeassistant/components/yolink/services.yaml b/homeassistant/components/yolink/services.yaml index 939eba3e7f5..5f7a3ec3122 100644 --- a/homeassistant/components/yolink/services.yaml +++ b/homeassistant/components/yolink/services.yaml @@ -7,9 +7,7 @@ play_on_speaker_hub: device: filter: - integration: yolink - manufacturer: YoLink model: SpeakerHub - message: required: true example: hello, yolink diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index 9661abe096c..83e712328f9 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -37,6 +37,11 @@ "button_4_long_press": "Button_4 (long press)" } }, + "exceptions": { + "invalid_config_entry": { + "message": "Config entry not found or not loaded!" + } + }, "entity": { "switch": { "usb_ports": { "name": "USB ports" }, diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 1eb3369c1be..c6666911ef9 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -272,8 +272,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if data[CONF_RADIO_TYPE] != RadioType.deconz and baudrate in BAUD_RATES: data[CONF_DEVICE][CONF_BAUDRATE] = baudrate - config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry, data=data) + hass.config_entries.async_update_entry(config_entry, data=data, version=2) if config_entry.version == 2: data = {**config_entry.data} @@ -281,8 +280,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if data[CONF_RADIO_TYPE] == "ti_cc": data[CONF_RADIO_TYPE] = "znp" - config_entry.version = 3 - hass.config_entries.async_update_entry(config_entry, data=data) + hass.config_entries.async_update_entry(config_entry, data=data, version=3) if config_entry.version == 3: data = {**config_entry.data} @@ -299,8 +297,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if not data[CONF_DEVICE].get(CONF_FLOW_CONTROL): data[CONF_DEVICE][CONF_FLOW_CONTROL] = None - config_entry.version = 4 - hass.config_entries.async_update_entry(config_entry, data=data) + hass.config_entries.async_update_entry(config_entry, data=data, version=4) _LOGGER.info("Migration to version %s successful", config_entry.version) return True diff --git a/homeassistant/components/zha/core/cluster_handlers/__init__.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py index 6c65a993e95..94cd1f49ca8 100644 --- a/homeassistant/components/zha/core/cluster_handlers/__init__.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -1,7 +1,6 @@ """Cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations -import asyncio from collections.abc import Awaitable, Callable, Coroutine, Iterator import contextlib from enum import Enum @@ -62,7 +61,7 @@ def wrap_zigpy_exceptions() -> Iterator[None]: """Wrap zigpy exceptions in `HomeAssistantError` exceptions.""" try: yield - except asyncio.TimeoutError as exc: + except TimeoutError as exc: raise HomeAssistantError( "Failed to send request: device did not respond" ) from exc @@ -214,7 +213,7 @@ class ClusterHandler(LogMixin): }, }, ) - except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: self.debug( "Failed to bind '%s' cluster: %s", self.cluster.ep_attribute, @@ -275,7 +274,7 @@ class ClusterHandler(LogMixin): try: res = await self.cluster.configure_reporting_multiple(reports, **kwargs) self._configure_reporting_status(reports, res[0], event_data) - except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: self.debug( "failed to set reporting on '%s' cluster for: %s", self.cluster.ep_attribute, @@ -518,7 +517,7 @@ class ClusterHandler(LogMixin): manufacturer=manufacturer, ) result.update(read) - except (asyncio.TimeoutError, zigpy.exceptions.ZigbeeException) as ex: + except (TimeoutError, zigpy.exceptions.ZigbeeException) as ex: self.debug( "failed to get attributes '%s' on '%s' cluster: %s", chunk, diff --git a/homeassistant/components/zha/core/cluster_handlers/lightlink.py b/homeassistant/components/zha/core/cluster_handlers/lightlink.py index e2ed36bdc83..85ec6905069 100644 --- a/homeassistant/components/zha/core/cluster_handlers/lightlink.py +++ b/homeassistant/components/zha/core/cluster_handlers/lightlink.py @@ -1,5 +1,4 @@ """Lightlink cluster handlers module for Zigbee Home Automation.""" -import asyncio import zigpy.exceptions from zigpy.zcl.clusters.lightlink import LightLink @@ -32,7 +31,7 @@ class LightLinkClusterHandler(ClusterHandler): try: rsp = await self.cluster.get_group_identifiers(0) - except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as exc: + except (zigpy.exceptions.ZigbeeException, TimeoutError) as exc: self.warning("Couldn't get list of groups: %s", str(exc)) return diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index dd5a39115ae..1fba6631bb9 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -870,7 +870,7 @@ class ZHADevice(LogMixin): # store it, so we cannot rely on it existing after being written. This is # only done to make the ZCL command valid. await self._zigpy_device.add_to_group(group_id, name=f"0x{group_id:04X}") - except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: self.debug( "Failed to add device '%s' to group: 0x%04x ex: %s", self._zigpy_device.ieee, @@ -882,7 +882,7 @@ class ZHADevice(LogMixin): """Remove this device from the provided zigbee group.""" try: await self._zigpy_device.remove_from_group(group_id) - except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: self.debug( "Failed to remove device '%s' from group: 0x%04x ex: %s", self._zigpy_device.ieee, @@ -898,7 +898,7 @@ class ZHADevice(LogMixin): await self._zigpy_device.endpoints[endpoint_id].add_to_group( group_id, name=f"0x{group_id:04X}" ) - except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: self.debug( "Failed to add endpoint: %s for device: '%s' to group: 0x%04x ex: %s", endpoint_id, @@ -913,7 +913,7 @@ class ZHADevice(LogMixin): """Remove the device endpoint from the provided zigbee group.""" try: await self._zigpy_device.endpoints[endpoint_id].remove_from_group(group_id) - except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: self.debug( ( "Failed to remove endpoint: %s for device '%s' from group: 0x%04x" diff --git a/homeassistant/components/zha/core/group.py b/homeassistant/components/zha/core/group.py index 519668052e0..fc7f1f8758f 100644 --- a/homeassistant/components/zha/core/group.py +++ b/homeassistant/components/zha/core/group.py @@ -112,7 +112,7 @@ class ZHAGroupMember(LogMixin): await self._zha_device.device.endpoints[ self._endpoint_id ].remove_from_group(self._zha_group.group_id) - except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: self.debug( ( "Failed to remove endpoint: %s for device '%s' from group: 0x%04x" diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 9e09e20819f..e9ab98fa6bf 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,16 +21,16 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.37.6", + "bellows==0.38.0", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.110", - "zigpy-deconz==0.22.4", - "zigpy==0.61.0", + "zha-quirks==0.0.111", + "zigpy-deconz==0.23.0", + "zigpy==0.62.3", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", - "universal-silabs-flasher==0.0.15", + "universal-silabs-flasher==0.0.18", "pyserial-asyncio-fast==0.11" ], "usb": [ diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 15985922ccd..167edc935d0 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -838,6 +838,27 @@ class SmartEnergySummationReceived(PolledSmartEnergySummation): _unique_id_suffix = "summation_received" _attr_translation_key: str = "summation_received" + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + This attribute only started to be initialized in HA 2024.2.0, + so the entity would be created on the first HA start after the + upgrade for existing devices, as the initialization to see if + an attribute is unsupported happens later in the background. + To avoid creating unnecessary entities for existing devices, + wait until the attribute was properly initialized once for now. + """ + if cluster_handlers[0].cluster.get(cls._attribute_name) is None: + return None + return super().create_entity(unique_id, zha_device, cluster_handlers, **kwargs) + @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_PRESSURE) # pylint: disable-next=hass-invalid-inheritance # needs fixing diff --git a/homeassistant/components/zondergas/__init__.py b/homeassistant/components/zondergas/__init__.py new file mode 100644 index 00000000000..150414e001f --- /dev/null +++ b/homeassistant/components/zondergas/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: ZonderGas.""" diff --git a/homeassistant/components/zondergas/manifest.json b/homeassistant/components/zondergas/manifest.json new file mode 100644 index 00000000000..09292e9d330 --- /dev/null +++ b/homeassistant/components/zondergas/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "zondergas", + "name": "ZonderGas", + "integration_type": "virtual", + "supported_by": "energyzero" +} diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 01ec041e9d8..86593c36737 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -135,7 +135,7 @@ def async_active_zone( is None # Skip zone that are outside the radius aka the # lat/long is outside the zone - or not (zone_dist - (radius := zone_attrs[ATTR_RADIUS]) < radius) + or not (zone_dist - (zone_radius := zone_attrs[ATTR_RADIUS]) < radius) ): continue @@ -144,7 +144,7 @@ def async_active_zone( zone_dist < min_dist or ( # If same distance, prefer smaller zone - zone_dist == min_dist and radius < closest.attributes[ATTR_RADIUS] + zone_dist == min_dist and zone_radius < closest.attributes[ATTR_RADIUS] ) ): continue diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 1321ef36f85..1e2a17fdf63 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -168,7 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: translation_key="invalid_server_version", ) raise ConfigEntryNotReady(f"Invalid server version: {err}") from err - except (asyncio.TimeoutError, BaseZwaveJSServerError) as err: + except (TimeoutError, BaseZwaveJSServerError) as err: raise ConfigEntryNotReady(f"Failed to connect: {err}") from err async_delete_issue(hass, DOMAIN, "invalid_server_version") diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 8d14c8ed5b6..5aa27ada977 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -2202,8 +2202,8 @@ class FirmwareUploadView(HomeAssistantView): node = async_get_node_from_device_id(hass, device_id, self._dev_reg) except ValueError as err: if "not loaded" in err.args[0]: - raise web_exceptions.HTTPBadRequest - raise web_exceptions.HTTPNotFound + raise web_exceptions.HTTPBadRequest from err + raise web_exceptions.HTTPNotFound from err # If this was not true, we wouldn't have been able to get the node from the # device ID above diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index f5ad8ce36cd..2f84b52b7da 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -139,10 +139,19 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): self._hvac_modes: dict[HVACMode, int | None] = {} self._hvac_presets: dict[str, int | None] = {} self._unit_value: ZwaveValue | None = None + self._last_hvac_mode_id_before_off: int | None = None self._current_mode = self.get_zwave_value( THERMOSTAT_MODE_PROPERTY, command_class=CommandClass.THERMOSTAT_MODE ) + self._supports_resume: bool = bool( + self._current_mode + and ( + str(ThermostatMode.RESUME_ON.value) + in self._current_mode.metadata.states + ) + ) + self._setpoint_values: dict[ThermostatSetpointType, ZwaveValue | None] = {} for enum in ThermostatSetpointType: self._setpoint_values[enum] = self.get_zwave_value( @@ -196,13 +205,9 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE if HVACMode.OFF in self._hvac_modes: self._attr_supported_features |= ClimateEntityFeature.TURN_OFF - # We can only support turn on if we are able to turn the device off, # otherwise the device can be considered always on - if len(self._hvac_modes) == 2 or any( - mode in self._hvac_modes - for mode in (HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL) - ): + if len(self._hvac_modes) > 1: self._attr_supported_features |= ClimateEntityFeature.TURN_ON # If any setpoint value exists, we can assume temperature # can be set @@ -496,8 +501,54 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): # Thermostat(valve) has no support for setting a mode, so we make it a no-op return + # When turning the HVAC off from an on state, store the last HVAC mode ID so we + # can set it again when turning the device back on. + if hvac_mode == HVACMode.OFF and self._current_mode.value != ThermostatMode.OFF: + self._last_hvac_mode_id_before_off = self._current_mode.value await self._async_set_value(self._current_mode, hvac_mode_id) + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self.async_set_hvac_mode(HVACMode.OFF) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + # If current mode is not off, do nothing + if self.hvac_mode != HVACMode.OFF: + return + + # We can safely assert here because this function can only be called if the + # device can be turned off and on which would require the device to have the + # current mode Z-Wave Value + assert self._current_mode + + # If the device supports resume, use resume to get to the right mode + if self._supports_resume: + await self._async_set_value(self._current_mode, ThermostatMode.RESUME_ON) + return + + # If we have an HVAC mode ID from before the device was turned off, set it to + # that mode + if self._last_hvac_mode_id_before_off is not None: + await self._async_set_value( + self._current_mode, self._last_hvac_mode_id_before_off + ) + self._last_hvac_mode_id_before_off = None + return + + # Attempt to set the device to the first available mode among heat_cool, heat, + # and cool to mirror previous behavior. If none of those are available, set it + # to the first available mode that is not off. + try: + hvac_mode = next( + mode + for mode in (HVACMode.HEAT_COOL, HVACMode.HEAT, HVACMode.COOL) + if mode in self._hvac_modes + ) + except StopIteration: + hvac_mode = next(mode for mode in self._hvac_modes if mode != HVACMode.OFF) + await self.async_set_hvac_mode(hvac_mode) + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new target preset mode.""" assert self._current_mode is not None diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index e252a2ad693..c8baacfaf3f 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -118,7 +118,7 @@ async def async_get_version_info(hass: HomeAssistant, ws_address: str) -> Versio version_info: VersionInfo = await get_server_version( ws_address, async_get_clientsession(hass) ) - except (asyncio.TimeoutError, aiohttp.ClientError) as err: + except (TimeoutError, aiohttp.ClientError) as err: # We don't want to spam the log if the add-on isn't started # or takes a long time to start. _LOGGER.debug("Failed to connect to Z-Wave JS server: %s", err) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 0240725ca2d..af3bc8a622e 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime -from typing import Any, cast +from typing import Any import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient @@ -19,7 +19,6 @@ from zwave_js_server.model.controller.statistics import ControllerStatisticsData from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.node.statistics import NodeStatisticsDataType -from zwave_js_server.model.value import ConfigurationValue from zwave_js_server.util.command_class.meter import get_meter_type from homeassistant.components.sensor import ( @@ -799,7 +798,6 @@ class ZWaveConfigParameterSensor(ZWaveListSensor): super().__init__( config_entry, driver, info, entity_description, unit_of_measurement ) - self._primary_value = cast(ConfigurationValue, self.info.primary_value) property_key_name = self.info.primary_value.property_key_name # Entity class attributes diff --git a/homeassistant/config.py b/homeassistant/config.py index 8a868018adf..3e593a564a2 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -212,9 +212,11 @@ def _filter_bad_internal_external_urls(conf: dict) -> dict: return conf -PACKAGES_CONFIG_SCHEMA = cv.schema_with_slug_keys( # Package names are slugs - vol.Schema({cv.string: vol.Any(dict, list, None)}) # Component config -) +# Schema for all packages element +PACKAGES_CONFIG_SCHEMA = vol.Schema({cv.string: vol.Any(dict, list)}) + +# Schema for individual package definition +PACKAGE_DEFINITION_SCHEMA = vol.Schema({cv.string: vol.Any(dict, list, None)}) CUSTOMIZE_DICT_SCHEMA = vol.Schema( { @@ -499,7 +501,17 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> dict: config.pop(invalid_domain) core_config = config.get(CONF_CORE, {}) - await merge_packages_config(hass, config, core_config.get(CONF_PACKAGES, {})) + try: + await merge_packages_config(hass, config, core_config.get(CONF_PACKAGES, {})) + except vol.Invalid as exc: + suffix = "" + if annotation := find_annotation(config, [CONF_CORE, CONF_PACKAGES] + exc.path): + suffix = f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" + _LOGGER.error( + "Invalid package configuration '%s'%s: %s", CONF_PACKAGES, suffix, exc + ) + core_config[CONF_PACKAGES] = {} + return config @@ -938,7 +950,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non def _log_pkg_error( - hass: HomeAssistant, package: str, component: str, config: dict, message: str + hass: HomeAssistant, package: str, component: str | None, config: dict, message: str ) -> None: """Log an error while merging packages.""" message_prefix = f"Setup of package '{package}'" @@ -996,6 +1008,12 @@ def _identify_config_schema(module: ComponentProtocol) -> str | None: return None +def _validate_package_definition(name: str, conf: Any) -> None: + """Validate basic package definition properties.""" + cv.slug(name) + PACKAGE_DEFINITION_SCHEMA(conf) + + def _recursive_merge(conf: dict[str, Any], package: dict[str, Any]) -> str | None: """Merge package into conf, recursively.""" duplicate_key: str | None = None @@ -1023,12 +1041,33 @@ async def merge_packages_config( config: dict, packages: dict[str, Any], _log_pkg_error: Callable[ - [HomeAssistant, str, str, dict, str], None + [HomeAssistant, str, str | None, dict, str], None ] = _log_pkg_error, ) -> dict: - """Merge packages into the top-level configuration. Mutate config.""" + """Merge packages into the top-level configuration. + + Ignores packages that cannot be setup. Mutates config. Raises + vol.Invalid if whole package config is invalid. + """ + PACKAGES_CONFIG_SCHEMA(packages) + + invalid_packages = [] for pack_name, pack_conf in packages.items(): + try: + _validate_package_definition(pack_name, pack_conf) + except vol.Invalid as exc: + _log_pkg_error( + hass, + pack_name, + None, + config, + f"Invalid package definition '{pack_name}': {str(exc)}. Package " + f"will not be initialized", + ) + invalid_packages.append(pack_name) + continue + for comp_name, comp_conf in pack_conf.items(): if comp_name == CONF_CORE: continue @@ -1123,6 +1162,9 @@ async def merge_packages_config( f"integration '{comp_name}' has duplicate key '{duplicate_key}'", ) + for pack_name in invalid_packages: + packages.pop(pack_name, {}) + return config diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index b0a8f952b1b..8f8d9132137 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -7,6 +7,7 @@ from collections.abc import ( Callable, Coroutine, Generator, + Hashable, Iterable, Mapping, ValuesView, @@ -49,6 +50,7 @@ from .helpers.event import ( ) from .helpers.frame import report from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType +from .loader import async_suggest_report_issue from .setup import DATA_SETUP_DONE, async_process_deps_reqs, async_setup_component from .util import uuid as uuid_util from .util.decorator import Registry @@ -383,12 +385,12 @@ class ConfigEntry: if self.source == SOURCE_IGNORE or self.disabled_by: return - if integration is None: + if integration is None and not (integration := self._integration_for_domain): integration = await loader.async_get_integration(hass, self.domain) self._integration_for_domain = integration # Only store setup result as state if it was not forwarded. - if self.domain == integration.domain: + if domain_is_integration := self.domain == integration.domain: self._async_set_state(hass, ConfigEntryState.SETUP_IN_PROGRESS, None) if self.supports_unload is None: @@ -407,13 +409,13 @@ class ConfigEntry: self.domain, err, ) - if self.domain == integration.domain: + if domain_is_integration: self._async_set_state( hass, ConfigEntryState.SETUP_ERROR, "Import error" ) return - if self.domain == integration.domain: + if domain_is_integration: try: integration.get_platform("config_flow") except ImportError as err: @@ -473,12 +475,12 @@ class ConfigEntry: self.async_start_reauth(hass) result = False except ConfigEntryNotReady as exc: - self._async_set_state(hass, ConfigEntryState.SETUP_RETRY, str(exc) or None) + message = str(exc) + self._async_set_state(hass, ConfigEntryState.SETUP_RETRY, message or None) wait_time = 2 ** min(self._tries, 4) * 5 + ( randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) / 1000000 ) self._tries += 1 - message = str(exc) ready_message = f"ready yet: {message}" if message else "ready yet" _LOGGER.debug( ( @@ -511,7 +513,7 @@ class ConfigEntry: result = False # Only store setup result as state if it was not forwarded. - if self.domain != integration.domain: + if not domain_is_integration: return # @@ -1124,9 +1126,10 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): - domain -> unique_id -> ConfigEntry """ - def __init__(self) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize the container.""" super().__init__() + self._hass = hass self._domain_index: dict[str, list[ConfigEntry]] = {} self._domain_unique_id_index: dict[str, dict[str, ConfigEntry]] = {} @@ -1143,10 +1146,33 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): _LOGGER.error("An entry with the id %s already exists", entry_id) self._unindex_entry(entry_id) data[entry_id] = entry + self._index_entry(entry) + + def _index_entry(self, entry: ConfigEntry) -> None: + """Index an entry.""" self._domain_index.setdefault(entry.domain, []).append(entry) if entry.unique_id is not None: + unique_id_hash = entry.unique_id + # Guard against integrations using unhashable unique_id + # In HA Core 2024.9, we should remove the guard and instead fail + if not isinstance(entry.unique_id, Hashable): + unique_id_hash = str(entry.unique_id) # type: ignore[unreachable] + report_issue = async_suggest_report_issue( + self._hass, integration_domain=entry.domain + ) + _LOGGER.error( + ( + "Config entry '%s' from integration %s has an invalid unique_id" + " '%s', please %s" + ), + entry.title, + entry.domain, + entry.unique_id, + report_issue, + ) + self._domain_unique_id_index.setdefault(entry.domain, {})[ - entry.unique_id + unique_id_hash ] = entry def _unindex_entry(self, entry_id: str) -> None: @@ -1157,6 +1183,9 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): if not self._domain_index[domain]: del self._domain_index[domain] if (unique_id := entry.unique_id) is not None: + # Check type first to avoid expensive isinstance call + if type(unique_id) is not str and not isinstance(unique_id, Hashable): # noqa: E721 + unique_id = str(entry.unique_id) # type: ignore[unreachable] del self._domain_unique_id_index[domain][unique_id] if not self._domain_unique_id_index[domain]: del self._domain_unique_id_index[domain] @@ -1166,6 +1195,16 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): self._unindex_entry(entry_id) super().__delitem__(entry_id) + def update_unique_id(self, entry: ConfigEntry, new_unique_id: str | None) -> None: + """Update unique id for an entry. + + This method mutates the entry with the new unique id and updates the indexes. + """ + entry_id = entry.entry_id + self._unindex_entry(entry_id) + entry.unique_id = new_unique_id + self._index_entry(entry) + def get_entries_for_domain(self, domain: str) -> list[ConfigEntry]: """Get entries for a domain.""" return self._domain_index.get(domain, []) @@ -1174,6 +1213,9 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): self, domain: str, unique_id: str ) -> ConfigEntry | None: """Get entry by domain and unique id.""" + # Check type first to avoid expensive isinstance call + if type(unique_id) is not str and not isinstance(unique_id, Hashable): # noqa: E721 + unique_id = str(unique_id) # type: ignore[unreachable] return self._domain_unique_id_index.get(domain, {}).get(unique_id) @@ -1189,7 +1231,7 @@ class ConfigEntries: self.flow = ConfigEntriesFlowManager(hass, self, hass_config) self.options = OptionsFlowManager(hass) self._hass_config = hass_config - self._entries = ConfigEntryItems() + self._entries = ConfigEntryItems(hass) self._store = storage.Store[dict[str, list[dict[str, Any]]]]( hass, STORAGE_VERSION, STORAGE_KEY ) @@ -1314,10 +1356,10 @@ class ConfigEntries: self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_shutdown) if config is None: - self._entries = ConfigEntryItems() + self._entries = ConfigEntryItems(self.hass) return - entries: ConfigEntryItems = ConfigEntryItems() + entries: ConfigEntryItems = ConfigEntryItems(self.hass) for entry in config["entries"]: pref_disable_new_entities = entry.get("pref_disable_new_entities") @@ -1468,12 +1510,14 @@ class ConfigEntries: self, entry: ConfigEntry, *, - unique_id: str | None | UndefinedType = UNDEFINED, - title: str | UndefinedType = UNDEFINED, data: Mapping[str, Any] | UndefinedType = UNDEFINED, + minor_version: int | UndefinedType = UNDEFINED, options: Mapping[str, Any] | UndefinedType = UNDEFINED, pref_disable_new_entities: bool | UndefinedType = UNDEFINED, pref_disable_polling: bool | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + unique_id: str | None | UndefinedType = UNDEFINED, + version: int | UndefinedType = UNDEFINED, ) -> bool: """Update a config entry. @@ -1487,16 +1531,15 @@ class ConfigEntries: if unique_id is not UNDEFINED and entry.unique_id != unique_id: # Reindex the entry if the unique_id has changed - entry_id = entry.entry_id - del self._entries[entry_id] - entry.unique_id = unique_id - self._entries[entry_id] = entry + self._entries.update_unique_id(entry, unique_id) changed = True for attr, value in ( - ("title", title), + ("minor_version", minor_version), ("pref_disable_new_entities", pref_disable_new_entities), ("pref_disable_polling", pref_disable_polling), + ("title", title), + ("version", version), ): if value is UNDEFINED or getattr(entry, attr) == value: continue @@ -2249,7 +2292,7 @@ async def _load_integration( domain, err, ) - raise data_entry_flow.UnknownHandler + raise data_entry_flow.UnknownHandler from err async def _async_get_flow_handler( diff --git a/homeassistant/core.py b/homeassistant/core.py index 4c59e88e840..6e8d45bd0bd 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -22,6 +22,7 @@ from dataclasses import dataclass import datetime import enum import functools +import inspect import logging import os import pathlib @@ -875,7 +876,7 @@ class HomeAssistant: tasks.append(task_or_none) if tasks: await asyncio.gather(*tasks, return_exceptions=True) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning( "Timed out waiting for shutdown jobs to complete, the shutdown will" " continue" @@ -906,7 +907,7 @@ class HomeAssistant: try: async with self.timeout.async_timeout(STOP_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning( "Timed out waiting for integrations to stop, the shutdown will" " continue" @@ -919,7 +920,7 @@ class HomeAssistant: try: async with self.timeout.async_timeout(FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning( "Timed out waiting for final writes to complete, the shutdown will" " continue" @@ -951,7 +952,7 @@ class HomeAssistant: await task except asyncio.CancelledError: pass - except asyncio.TimeoutError: + except TimeoutError: # Task may be shielded from cancellation. _LOGGER.exception( "Task %s could not be canceled during final shutdown stage", task @@ -971,7 +972,7 @@ class HomeAssistant: try: async with self.timeout.async_timeout(CLOSE_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning( "Timed out waiting for close event to be processed, the shutdown will" " continue" @@ -1077,9 +1078,7 @@ class Event: self.origin = origin self.time_fired = time_fired or dt_util.utcnow() if not context: - context = Context( - id=ulid_at_time(dt_util.utc_to_timestamp(self.time_fired)) - ) + context = Context(id=ulid_at_time(self.time_fired.timestamp())) self.context = context if not context.origin_event: context.origin_event = self @@ -1160,7 +1159,7 @@ class _OneTimeListener: remove: CALLBACK_TYPE | None = None @callback - def async_call(self, event: Event) -> None: + def __call__(self, event: Event) -> None: """Remove listener from event bus and then fire listener.""" if not self.remove: # If the listener was already removed, we don't need to do anything @@ -1169,6 +1168,13 @@ class _OneTimeListener: self.remove = None self.hass.async_run_job(self.listener, event) + def __repr__(self) -> str: + """Return the representation of the listener and source module.""" + module = inspect.getmodule(self.listener) + if module: + return f"<_OneTimeListener {module.__name__}:{self.listener}>" + return f"<_OneTimeListener {self.listener}>" + class EventBus: """Allow the firing of and listening for events.""" @@ -1366,7 +1372,7 @@ class EventBus: event_type, ( HassJob( - one_time_listener.async_call, + one_time_listener, f"onetime listen {event_type} {listener}", job_type=HassJobType.Callback, ), @@ -1757,7 +1763,9 @@ class StateMachine: Async friendly. """ - return self._states_data.get(entity_id.lower()) + return self._states_data.get(entity_id) or self._states_data.get( + entity_id.lower() + ) def is_state(self, entity_id: str, state: str) -> bool: """Test if entity exists and is in specified state. @@ -1795,7 +1803,6 @@ class StateMachine: self._bus.async_fire( EVENT_STATE_CHANGED, {"entity_id": entity_id, "old_state": old_state, "new_state": None}, - EventOrigin.local, context=context, ) return True @@ -1870,10 +1877,16 @@ class StateMachine: This method must be run in the event loop. """ - entity_id = entity_id.lower() new_state = str(new_state) attributes = attributes or {} - if (old_state := self._states_data.get(entity_id)) is None: + old_state = self._states_data.get(entity_id) + if old_state is None: + # If the state is missing, try to convert the entity_id to lowercase + # and try again. + entity_id = entity_id.lower() + old_state = self._states_data.get(entity_id) + + if old_state is None: same_state = False same_attr = False last_changed = None @@ -1924,8 +1937,7 @@ class StateMachine: self._bus.async_fire( EVENT_STATE_CHANGED, {"entity_id": entity_id, "old_state": old_state, "new_state": state}, - EventOrigin.local, - context, + context=context, time_fired=now, ) diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 586aa64ce18..851474d8481 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -13,6 +13,7 @@ APPLICATION_CREDENTIALS = [ "google_sheets", "google_tasks", "home_connect", + "husqvarna_automower", "lametric", "lyric", "myuplink", diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 7d32dbfe963..c0b21c0a81d 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -340,6 +340,33 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "manufacturer_id": 89, "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", }, + { + "connectable": False, + "domain": "mopeka", + "manufacturer_data_start": [ + 4, + ], + "manufacturer_id": 89, + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + }, + { + "connectable": False, + "domain": "mopeka", + "manufacturer_data_start": [ + 5, + ], + "manufacturer_id": 89, + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + }, + { + "connectable": False, + "domain": "mopeka", + "manufacturer_data_start": [ + 6, + ], + "manufacturer_id": 89, + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + }, { "connectable": False, "domain": "mopeka", @@ -349,6 +376,33 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "manufacturer_id": 89, "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", }, + { + "connectable": False, + "domain": "mopeka", + "manufacturer_data_start": [ + 9, + ], + "manufacturer_id": 89, + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + }, + { + "connectable": False, + "domain": "mopeka", + "manufacturer_data_start": [ + 10, + ], + "manufacturer_id": 89, + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + }, + { + "connectable": False, + "domain": "mopeka", + "manufacturer_data_start": [ + 11, + ], + "manufacturer_id": 89, + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + }, { "connectable": False, "domain": "mopeka", @@ -548,6 +602,11 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "thermopro", "local_name": "TP96*", }, + { + "connectable": False, + "domain": "thermopro", + "local_name": "TP97*", + }, { "domain": "tilt_ble", "manufacturer_data_start": [ diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index aa3efde99bc..4d909f40736 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -52,6 +52,7 @@ FLOWS = { "aosmith", "apcupsd", "apple_tv", + "aprilaire", "aranet", "arcam_fmj", "aseko_pool_live", @@ -228,6 +229,7 @@ FLOWS = { "hue", "huisbaasje", "hunterdouglas_powerview", + "husqvarna_automower", "huum", "hvv_departures", "hydrawise", @@ -563,6 +565,7 @@ FLOWS = { "v2c", "vallox", "velbus", + "velux", "venstar", "vera", "verisure", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c49882f4394..81b3b3b8192 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -383,6 +383,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "aprilaire": { + "name": "Aprilaire", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "aprs": { "name": "APRS", "integration_type": "hub", @@ -2618,6 +2624,12 @@ "integration_type": "virtual", "supported_by": "motion_blinds" }, + "husqvarna_automower": { + "name": "Husqvarna Automower", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "huum": { "name": "Huum", "integration_type": "hub", @@ -5080,6 +5092,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "samsam": { + "name": "SamSam", + "integration_type": "virtual", + "supported_by": "energyzero" + }, "samsung": { "name": "Samsung", "integrations": { @@ -6161,6 +6178,12 @@ "config_flow": false, "iot_class": "local_polling", "name": "TP-Link LTE" + }, + "tplink_tapo": { + "integration_type": "virtual", + "config_flow": false, + "supported_by": "tplink", + "name": "Tapo" } }, "iot_standards": [ @@ -6434,7 +6457,7 @@ "velux": { "name": "Velux", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "venstar": { @@ -6952,6 +6975,11 @@ "config_flow": true, "iot_class": "calculated" }, + "zondergas": { + "name": "ZonderGas", + "integration_type": "virtual", + "supported_by": "energyzero" + }, "zoneminder": { "name": "ZoneMinder", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index a66efa6dded..0f16977097d 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -620,6 +620,11 @@ ZEROCONF = { "domain": "plugwise", }, ], + "_powerview-g3._tcp.local.": [ + { + "domain": "hunterdouglas_powerview", + }, + ], "_powerview._tcp.local.": [ { "domain": "hunterdouglas_powerview", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 74527a5922f..cc0be0d5515 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -187,7 +187,7 @@ async def async_aiohttp_proxy_web( # The user cancelled the request return None - except asyncio.TimeoutError as err: + except TimeoutError as err: # Timeout trying to start the web request raise HTTPGatewayTimeout() from err @@ -219,7 +219,7 @@ async def async_aiohttp_proxy_stream( await response.prepare(request) # Suppressing something went wrong fetching data, closed connection - with suppress(asyncio.TimeoutError, aiohttp.ClientError): + with suppress(TimeoutError, aiohttp.ClientError): while hass.is_running: async with asyncio.timeout(timeout): data = await stream.read(buffer_size) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 1c8efadfdc5..b362d68ad55 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -96,13 +96,13 @@ async def async_check_ha_config_file( # noqa: C901 def _pack_error( hass: HomeAssistant, package: str, - component: str, + component: str | None, config: ConfigType, message: str, ) -> None: """Handle errors from packages.""" message = f"Setup of package '{package}' failed: {message}" - domain = f"homeassistant.packages.{package}.{component}" + domain = f"homeassistant.packages.{package}{'.' + component if component is not None else ''}" pack_config = core_config[CONF_PACKAGES].get(package, config) result.add_warning(message, domain, pack_config) @@ -157,10 +157,15 @@ async def async_check_ha_config_file( # noqa: C901 return result.add_error(f"Error loading {config_path}: {err}") # Extract and validate core [homeassistant] config + core_config = config.pop(CONF_CORE, {}) try: - core_config = config.pop(CONF_CORE, {}) core_config = CORE_CONFIG_SCHEMA(core_config) result[CONF_CORE] = core_config + + # Merge packages + await merge_packages_config( + hass, config, core_config.get(CONF_PACKAGES, {}), _pack_error + ) except vol.Invalid as err: result.add_error( format_schema_error(hass, err, CONF_CORE, core_config), @@ -168,11 +173,6 @@ async def async_check_ha_config_file( # noqa: C901 core_config, ) core_config = {} - - # Merge packages - await merge_packages_config( - hass, config, core_config.get(CONF_PACKAGES, {}), _pack_error - ) core_config.pop(CONF_PACKAGES, None) # Filter out repeating config sections diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 5b4b803a8d4..d99cc1d4f76 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -209,7 +209,10 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation): error_code = error_response.get("error", "unknown") error_description = error_response.get("error_description", "unknown error") _LOGGER.error( - "Token request failed (%s): %s", error_code, error_description + "Token request for %s failed (%s): %s", + self.domain, + error_code, + error_description, ) resp.raise_for_status() return cast(dict, await resp.json()) @@ -294,7 +297,7 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): try: async with asyncio.timeout(OAUTH_AUTHORIZE_URL_TIMEOUT_SEC): url = await self.async_generate_authorize_url() - except asyncio.TimeoutError as err: + except TimeoutError as err: _LOGGER.error("Timeout generating authorize url: %s", err) return self.async_abort(reason="authorize_url_timeout") except NoURLAvailableError: @@ -320,10 +323,11 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): token = await self.flow_impl.async_resolve_external_data( self.external_data ) - except asyncio.TimeoutError as err: + except TimeoutError as err: _LOGGER.error("Timeout resolving OAuth token: %s", err) return self.async_abort(reason="oauth_timeout") except (ClientResponseError, ClientError) as err: + _LOGGER.error("Error resolving OAuth token: %s", err) if ( isinstance(err, ClientResponseError) and err.status == HTTPStatus.UNAUTHORIZED diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 7cf7ab62495..9b05e4939ba 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -57,6 +57,7 @@ SLOW_ADD_MIN_TIMEOUT = 500 PLATFORM_NOT_READY_RETRIES = 10 DATA_ENTITY_PLATFORM = "entity_platform" DATA_DOMAIN_ENTITIES = "domain_entities" +DATA_DOMAIN_PLATFORM_ENTITIES = "domain_platform_entities" PLATFORM_NOT_READY_BASE_WAIT_TIME = 30 # seconds _LOGGER = getLogger(__name__) @@ -124,6 +125,8 @@ class EntityPlatform: self.scan_interval = scan_interval self.entity_namespace = entity_namespace self.config_entry: config_entries.ConfigEntry | None = None + # Storage for entities for this specific platform only + # which are indexed by entity_id self.entities: dict[str, Entity] = {} self.component_translations: dict[str, Any] = {} self.platform_translations: dict[str, Any] = {} @@ -145,9 +148,24 @@ class EntityPlatform: # which powers entity_component.add_entities self.parallel_updates_created = platform is None - self.domain_entities: dict[str, Entity] = hass.data.setdefault( + # Storage for entities indexed by domain + # with the child dict indexed by entity_id + # + # This is usually media_player, light, switch, etc. + domain_entities: dict[str, dict[str, Entity]] = hass.data.setdefault( DATA_DOMAIN_ENTITIES, {} - ).setdefault(domain, {}) + ) + self.domain_entities = domain_entities.setdefault(domain, {}) + + # Storage for entities indexed by domain and platform + # with the child dict indexed by entity_id + # + # This is usually media_player.yamaha, light.hue, switch.tplink, etc. + domain_platform_entities: dict[ + tuple[str, str], dict[str, Entity] + ] = hass.data.setdefault(DATA_DOMAIN_PLATFORM_ENTITIES, {}) + key = (domain, platform_name) + self.domain_platform_entities = domain_platform_entities.setdefault(key, {}) def __repr__(self) -> str: """Represent an EntityPlatform.""" @@ -370,7 +388,7 @@ class EntityPlatform: EVENT_HOMEASSISTANT_STARTED, setup_again ) return False - except asyncio.TimeoutError: + except TimeoutError: logger.error( ( "Setup of platform %s is taking longer than %s seconds." @@ -513,7 +531,7 @@ class EntityPlatform: try: async with self.hass.timeout.async_timeout(timeout, self.domain): await asyncio.gather(*tasks) - except asyncio.TimeoutError: + except TimeoutError: self.logger.warning( "Timed out adding entities for domain %s with platform %s after %ds", self.domain, @@ -743,6 +761,7 @@ class EntityPlatform: entity_id = entity.entity_id self.entities[entity_id] = entity self.domain_entities[entity_id] = entity + self.domain_platform_entities[entity_id] = entity if not restored: # Reserve the state in the state machine @@ -756,6 +775,7 @@ class EntityPlatform: """Remove entity from entities dict.""" self.entities.pop(entity_id) self.domain_entities.pop(entity_id) + self.domain_platform_entities.pop(entity_id) entity.async_on_remove(remove_entity_cb) @@ -852,7 +872,7 @@ class EntityPlatform: partial( service.entity_service_call, self.hass, - self.domain_entities, + self.domain_platform_entities, service_func, required_features=required_features, ), diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index b6790ff0dc3..f72aece4c70 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -449,8 +449,9 @@ class EntityRegistryItems(UserDict[str, RegistryEntry]): super().__init__() self._entry_ids: dict[str, RegistryEntry] = {} self._index: dict[tuple[str, str, str], str] = {} - self._config_entry_id_index: dict[str, list[str]] = {} - self._device_id_index: dict[str, list[str]] = {} + self._config_entry_id_index: dict[str, list[RegistryEntry]] = {} + self._device_id_index: dict[str, list[RegistryEntry]] = {} + self._area_id_index: dict[str, list[RegistryEntry]] = {} def values(self) -> ValuesView[RegistryEntry]: """Return the underlying values to avoid __iter__ overhead.""" @@ -465,25 +466,39 @@ class EntityRegistryItems(UserDict[str, RegistryEntry]): self._entry_ids[entry.id] = entry self._index[(entry.domain, entry.platform, entry.unique_id)] = entry.entity_id if (config_entry_id := entry.config_entry_id) is not None: - self._config_entry_id_index.setdefault(config_entry_id, []).append(key) + self._config_entry_id_index.setdefault(config_entry_id, []).append(entry) if (device_id := entry.device_id) is not None: - self._device_id_index.setdefault(device_id, []).append(key) + self._device_id_index.setdefault(device_id, []).append(entry) + if (area_id := entry.area_id) is not None: + self._area_id_index.setdefault(area_id, []).append(entry) + + def _unindex_entry_value( + self, entry: RegistryEntry, value: str, index: dict[str, list[RegistryEntry]] + ) -> None: + """Unindex an entry value. + + entry is the entry + value is the value to unindex such as config_entry_id or device_id. + index is the index to unindex from. + """ + entries = index[value] + entries.remove(entry) + if not entries: + del index[value] def _unindex_entry(self, key: str) -> None: """Unindex an entry.""" entry = self.data[key] del self._entry_ids[entry.id] del self._index[(entry.domain, entry.platform, entry.unique_id)] - if (config_entry_id := entry.config_entry_id) is not None: - entries = self._config_entry_id_index[config_entry_id] - entries.remove(key) - if not entries: - del self._config_entry_id_index[config_entry_id] - if (device_id := entry.device_id) is not None: - entries = self._device_id_index[device_id] - entries.remove(key) - if not entries: - del self._device_id_index[device_id] + if config_entry_id := entry.config_entry_id: + self._unindex_entry_value( + entry, config_entry_id, self._config_entry_id_index + ) + if device_id := entry.device_id: + self._unindex_entry_value(entry, device_id, self._device_id_index) + if area_id := entry.area_id: + self._unindex_entry_value(entry, area_id, self._area_id_index) def __delitem__(self, key: str) -> None: """Remove an item.""" @@ -498,18 +513,25 @@ class EntityRegistryItems(UserDict[str, RegistryEntry]): """Get entry from id.""" return self._entry_ids.get(key) - def get_entries_for_device_id(self, device_id: str) -> list[RegistryEntry]: + def get_entries_for_device_id( + self, device_id: str, include_disabled_entities: bool = False + ) -> list[RegistryEntry]: """Get entries for device.""" - return [self.data[key] for key in self._device_id_index.get(device_id, ())] + return [ + entry + for entry in self._device_id_index.get(device_id, ()) + if not entry.disabled_by or include_disabled_entities + ] def get_entries_for_config_entry_id( self, config_entry_id: str ) -> list[RegistryEntry]: """Get entries for config entry.""" - return [ - self.data[key] - for key in self._config_entry_id_index.get(config_entry_id, ()) - ] + return list(self._config_entry_id_index.get(config_entry_id, ())) + + def get_entries_for_area_id(self, area_id: str) -> list[RegistryEntry]: + """Get entries for area.""" + return list(self._area_id_index.get(area_id, ())) class EntityRegistry: @@ -1193,9 +1215,8 @@ class EntityRegistry: """Clear config entry from registry entries.""" now_time = time.time() for entity_id in [ - entity_id - for entity_id, entry in self.entities.items() - if config_entry_id == entry.config_entry_id + entry.entity_id + for entry in self.entities.get_entries_for_config_entry_id(config_entry_id) ]: self.async_remove(entity_id) for key, deleted_entity in list(self.deleted_entities.items()): @@ -1226,9 +1247,8 @@ class EntityRegistry: @callback def async_clear_area_id(self, area_id: str) -> None: """Clear area id from registry entries.""" - for entity_id, entry in self.entities.items(): - if area_id == entry.area_id: - self.async_update_entity(entity_id, area_id=None) + for entry in self.entities.get_entries_for_area_id(area_id): + self.async_update_entity(entry.entity_id, area_id=None) @callback @@ -1249,11 +1269,9 @@ def async_entries_for_device( registry: EntityRegistry, device_id: str, include_disabled_entities: bool = False ) -> list[RegistryEntry]: """Return entries that match a device.""" - return [ - entry - for entry in registry.entities.get_entries_for_device_id(device_id) - if (not entry.disabled_by or include_disabled_entities) - ] + return registry.entities.get_entries_for_device_id( + device_id, include_disabled_entities + ) @callback @@ -1261,7 +1279,7 @@ def async_entries_for_area( registry: EntityRegistry, area_id: str ) -> list[RegistryEntry]: """Return entries that match an area.""" - return [entry for entry in registry.entities.values() if entry.area_id == area_id] + return registry.entities.get_entries_for_area_id(area_id) @callback @@ -1379,16 +1397,12 @@ async def async_migrate_entries( Can also be used to remove duplicated entity registry entries. """ ent_reg = async_get(hass) - - for entry in list(ent_reg.entities.values()): - if entry.config_entry_id != config_entry_id: - continue - if not ent_reg.entities.get_entry(entry.id): - continue - - updates = entry_callback(entry) - - if updates is not None: + entities = ent_reg.entities + for entry in entities.get_entries_for_config_entry_id(config_entry_id): + if ( + entities.get_entry(entry.id) + and (updates := entry_callback(entry)) is not None + ): ent_reg.async_update_entity(entry.entity_id, **updates) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index d3f4144a293..b109ce16698 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -291,7 +291,7 @@ def _async_dispatch_entity_id_event( """Dispatch to listeners.""" if not (callbacks_list := callbacks.get(event.data["entity_id"])): return - for job in callbacks_list[:]: + for job in callbacks_list.copy(): try: hass.async_run_hass_job(job, event) except Exception: # pylint: disable=broad-except @@ -428,7 +428,7 @@ def _async_dispatch_old_entity_id_or_entity_id_event( ) ): return - for job in callbacks_list[:]: + for job in callbacks_list.copy(): try: hass.async_run_hass_job(job, event) except Exception: # pylint: disable=broad-except @@ -499,7 +499,7 @@ def _async_dispatch_device_id_event( """Dispatch to listeners.""" if not (callbacks_list := callbacks.get(event.data["device_id"])): return - for job in callbacks_list[:]: + for job in callbacks_list.copy(): try: hass.async_run_hass_job(job, event) except Exception: # pylint: disable=broad-except @@ -1442,7 +1442,7 @@ def async_track_point_in_utc_time( """ # Ensure point_in_time is UTC utc_point_in_time = dt_util.as_utc(point_in_time) - expected_fire_timestamp = dt_util.utc_to_timestamp(utc_point_in_time) + expected_fire_timestamp = utc_point_in_time.timestamp() job = ( action if isinstance(action, HassJob) diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py index dd216a78648..5d4dbe531cb 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -110,7 +110,7 @@ class _IconsCache: ) integrations: dict[str, Integration] = {} - domains = list({loaded.rpartition(".")[-1] for loaded in components}) + domains = {loaded.rpartition(".")[-1] for loaded in components} ints_or_excs = await async_get_integrations(self._hass, domains) for domain, int_or_exc in ints_or_excs.items(): if isinstance(int_or_exc, Exception): diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index fe399659a56..5217a55bec5 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -155,6 +155,17 @@ class NoStatesMatchedError(IntentError): self.device_classes = device_classes +class DuplicateNamesMatchedError(IntentError): + """Error when two or more entities with the same name matched.""" + + def __init__(self, name: str, area: str | None) -> None: + """Initialize error.""" + super().__init__() + + self.name = name + self.area = area + + def _is_device_class( state: State, entity: entity_registry.RegistryEntry | None, @@ -318,8 +329,6 @@ def async_match_states( for state, entity in states_and_entities: if _has_name(state, entity, name): yield state - break - else: # Not filtered by name for state, _entity in states_and_entities: @@ -403,11 +412,11 @@ class ServiceIntentHandler(IntentHandler): slots = self.async_validate_slots(intent_obj.slots) name_slot = slots.get("name", {}) - entity_id: str | None = name_slot.get("value") - entity_name: str | None = name_slot.get("text") - if entity_id == "all": + entity_name: str | None = name_slot.get("value") + entity_text: str | None = name_slot.get("text") + if entity_name == "all": # Don't match on name if targeting all entities - entity_id = None + entity_name = None # Look up area first to fail early area_slot = slots.get("area", {}) @@ -416,9 +425,7 @@ class ServiceIntentHandler(IntentHandler): area: area_registry.AreaEntry | None = None if area_id is not None: areas = area_registry.async_get(hass) - area = areas.async_get_area(area_id) or areas.async_get_area_by_name( - area_name - ) + area = areas.async_get_area(area_id) if area is None: raise IntentHandleError(f"No area named {area_name}") @@ -436,7 +443,7 @@ class ServiceIntentHandler(IntentHandler): states = list( async_match_states( hass, - name=entity_id, + name=entity_name, area=area, domains=domains, device_classes=device_classes, @@ -447,14 +454,24 @@ class ServiceIntentHandler(IntentHandler): if not states: # No states matched constraints raise NoStatesMatchedError( - name=entity_name or entity_id, + name=entity_text or entity_name, area=area_name or area_id, domains=domains, device_classes=device_classes, ) + if entity_name and (len(states) > 1): + # Multiple entities matched for the same name + raise DuplicateNamesMatchedError( + name=entity_text or entity_name, + area=area_name or area_id, + ) + response = await self.async_handle_states(intent_obj, states, area) + # Make the matched states available in the response + response.async_set_states(matched_states=states, unmatched_states=[]) + return response async def async_handle_states( @@ -542,7 +559,7 @@ class ServiceIntentHandler(IntentHandler): """ try: await asyncio.wait({task}, timeout=self.service_timeout) - except asyncio.TimeoutError: + except TimeoutError: pass except asyncio.CancelledError: # Task calling us was cancelled, so cancel service call task, and wait for diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index d1546528ef2..f2eee513790 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -595,7 +595,7 @@ class _ScriptRun: try: async with asyncio.timeout(delay): await self._stop.wait() - except asyncio.TimeoutError: + except TimeoutError: trace_set_result(delay=delay, done=True) async def _async_wait_template_step(self): @@ -643,7 +643,7 @@ class _ScriptRun: try: async with asyncio.timeout(timeout) as to_context: await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: self._variables["wait"]["remaining"] = 0.0 if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True): self._log(_TIMEOUT_MSG) @@ -1023,7 +1023,7 @@ class _ScriptRun: try: async with asyncio.timeout(timeout) as to_context: await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: self._variables["wait"]["remaining"] = 0.0 if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True): self._log(_TIMEOUT_MSG) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 30516e3a099..b170026f375 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -487,33 +487,46 @@ def async_extract_referenced_entity_ids( # Find devices for targeted areas selected.referenced_devices.update(selector.device_ids) - for device_entry in dev_reg.devices.values(): - if device_entry.area_id in selector.area_ids: - selected.referenced_devices.add(device_entry.id) + + if selector.area_ids: + for device_entry in dev_reg.devices.values(): + if device_entry.area_id in selector.area_ids: + selected.referenced_devices.add(device_entry.id) if not selector.area_ids and not selected.referenced_devices: return selected - for ent_entry in ent_reg.entities.values(): + entities = ent_reg.entities + # Add indirectly referenced by area + selected.indirectly_referenced.update( + entry.entity_id + for area_id in selector.area_ids + # The entity's area matches a targeted area + for entry in entities.get_entries_for_area_id(area_id) + # Do not add entities which are hidden or which are config + # or diagnostic entities. + if entry.entity_category is None and entry.hidden_by is None + ) + # Add indirectly referenced by device + selected.indirectly_referenced.update( + entry.entity_id + for device_id in selected.referenced_devices + for entry in entities.get_entries_for_device_id(device_id) # Do not add entities which are hidden or which are config # or diagnostic entities. - if ent_entry.entity_category is not None or ent_entry.hidden_by is not None: - continue - if ( - # The entity's area matches a targeted area - ent_entry.area_id in selector.area_ids - # The entity's device matches a device referenced by an area and the entity - # has no explicitly set area - or ( - not ent_entry.area_id - and ent_entry.device_id in selected.referenced_devices + entry.entity_category is None + and entry.hidden_by is None + and ( + # The entity's device matches a device referenced + # by an area and the entity + # has no explicitly set area + not entry.area_id + # The entity's device matches a targeted device + or device_id in selector.device_ids ) - # The entity's device matches a targeted device - or ent_entry.device_id in selector.device_ids - ): - selected.indirectly_referenced.add(ent_entry.entity_id) - + ) + ) return selected @@ -640,7 +653,7 @@ async def async_get_all_descriptions( descriptions[domain] = {} domain_descriptions = descriptions[domain] - for service_name in services_map: + for service_name, service in services_map.items(): cache_key = (domain, service_name) description = descriptions_cache.get(cache_key) if description is not None: @@ -695,11 +708,10 @@ async def async_get_all_descriptions( if "target" in yaml_description: description["target"] = yaml_description["target"] - if ( - response := hass.services.supports_response(domain, service_name) - ) != SupportsResponse.NONE: + response = service.supports_response + if response is not SupportsResponse.NONE: description["response"] = { - "optional": response == SupportsResponse.OPTIONAL, + "optional": response is SupportsResponse.OPTIONAL, } descriptions_cache[cache_key] = description diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index f789aeb37e4..2a175f76182 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -9,7 +9,7 @@ import inspect from json import JSONDecodeError, JSONEncoder import logging import os -from typing import Any, Generic, TypeVar +from typing import TYPE_CHECKING, Any, Generic, TypeVar from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE from homeassistant.core import ( @@ -28,6 +28,12 @@ from homeassistant.util.file import WriteError from . import json as json_helper +if TYPE_CHECKING: + from functools import cached_property +else: + from ..backports.functools import cached_property + + # mypy: allow-untyped-calls, allow-untyped-defs, no-warn-return-any # mypy: no-check-untyped-defs @@ -110,7 +116,7 @@ class Store(Generic[_T]): self._atomic_writes = atomic_writes self._read_only = read_only - @property + @cached_property def path(self): """Return the config path.""" return self.hass.config.path(STORAGE_DIR, self.key) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 1bb7220f784..86e3385a21b 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -80,6 +80,7 @@ from homeassistant.util.thread import ThreadWithException from . import area_registry, device_registry, entity_registry, location as loc_helper from .singleton import singleton +from .translation import async_translate_state from .typing import TemplateVarsType # mypy: allow-untyped-defs, no-check-untyped-defs @@ -665,7 +666,7 @@ class Template: await finish_event.wait() if self._exc_info: raise TemplateError(self._exc_info[1].with_traceback(self._exc_info[2])) - except asyncio.TimeoutError: + except TimeoutError: template_render_thread.raise_exc(TimeoutError) return True finally: @@ -894,6 +895,36 @@ class AllStates: return "