Merge branch 'dev' into heatpump

This commit is contained in:
Christopher Fenner
2024-02-16 09:02:38 +01:00
committed by GitHub
1357 changed files with 29453 additions and 8638 deletions

View File

@@ -73,6 +73,10 @@ omit =
homeassistant/components/apple_tv/browse_media.py homeassistant/components/apple_tv/browse_media.py
homeassistant/components/apple_tv/media_player.py homeassistant/components/apple_tv/media_player.py
homeassistant/components/apple_tv/remote.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/aqualogic/*
homeassistant/components/aquostv/media_player.py homeassistant/components/aquostv/media_player.py
homeassistant/components/arcam_fmj/__init__.py homeassistant/components/arcam_fmj/__init__.py
@@ -484,6 +488,7 @@ omit =
homeassistant/components/gpsd/sensor.py homeassistant/components/gpsd/sensor.py
homeassistant/components/greenwave/light.py homeassistant/components/greenwave/light.py
homeassistant/components/growatt_server/__init__.py homeassistant/components/growatt_server/__init__.py
homeassistant/components/growatt_server/const.py
homeassistant/components/growatt_server/sensor.py homeassistant/components/growatt_server/sensor.py
homeassistant/components/growatt_server/sensor_types/* homeassistant/components/growatt_server/sensor_types/*
homeassistant/components/gstreamer/media_player.py homeassistant/components/gstreamer/media_player.py
@@ -872,6 +877,7 @@ omit =
homeassistant/components/notion/__init__.py homeassistant/components/notion/__init__.py
homeassistant/components/notion/binary_sensor.py homeassistant/components/notion/binary_sensor.py
homeassistant/components/notion/sensor.py homeassistant/components/notion/sensor.py
homeassistant/components/notion/util.py
homeassistant/components/nsw_fuel_station/sensor.py homeassistant/components/nsw_fuel_station/sensor.py
homeassistant/components/nuki/__init__.py homeassistant/components/nuki/__init__.py
homeassistant/components/nuki/binary_sensor.py homeassistant/components/nuki/binary_sensor.py
@@ -1533,6 +1539,7 @@ omit =
homeassistant/components/vicare/entity.py homeassistant/components/vicare/entity.py
homeassistant/components/vicare/number.py homeassistant/components/vicare/number.py
homeassistant/components/vicare/sensor.py homeassistant/components/vicare/sensor.py
homeassistant/components/vicare/types.py
homeassistant/components/vicare/utils.py homeassistant/components/vicare/utils.py
homeassistant/components/vicare/water_heater.py homeassistant/components/vicare/water_heater.py
homeassistant/components/vilfo/__init__.py homeassistant/components/vilfo/__init__.py
@@ -1691,8 +1698,10 @@ omit =
homeassistant/components/myuplink/__init__.py homeassistant/components/myuplink/__init__.py
homeassistant/components/myuplink/api.py homeassistant/components/myuplink/api.py
homeassistant/components/myuplink/application_credentials.py homeassistant/components/myuplink/application_credentials.py
homeassistant/components/myuplink/binary_sensor.py
homeassistant/components/myuplink/coordinator.py homeassistant/components/myuplink/coordinator.py
homeassistant/components/myuplink/entity.py homeassistant/components/myuplink/entity.py
homeassistant/components/myuplink/helpers.py
homeassistant/components/myuplink/sensor.py homeassistant/components/myuplink/sensor.py

View File

@@ -1079,7 +1079,7 @@ jobs:
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
- name: Upload coverage to Codecov (full coverage) - name: Upload coverage to Codecov (full coverage)
if: needs.info.outputs.test_full_suite == 'true' if: needs.info.outputs.test_full_suite == 'true'
uses: Wandalen/wretry.action@v1.3.0 uses: Wandalen/wretry.action@v1.4.4
with: with:
action: codecov/codecov-action@v3.1.3 action: codecov/codecov-action@v3.1.3
with: | with: |
@@ -1090,7 +1090,7 @@ jobs:
attempt_delay: 30000 attempt_delay: 30000
- name: Upload coverage to Codecov (partial coverage) - name: Upload coverage to Codecov (partial coverage)
if: needs.info.outputs.test_full_suite == 'false' if: needs.info.outputs.test_full_suite == 'false'
uses: Wandalen/wretry.action@v1.3.0 uses: Wandalen/wretry.action@v1.4.4
with: with:
action: codecov/codecov-action@v3.1.3 action: codecov/codecov-action@v3.1.3
with: | with: |

View File

@@ -29,11 +29,11 @@ jobs:
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3.23.2 uses: github/codeql-action/init@v3.24.1
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.23.2 uses: github/codeql-action/analyze@v3.24.1
with: with:
category: "/language:python" category: "/language:python"

View File

@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.15 rev: v0.2.1
hooks: hooks:
- id: ruff - id: ruff
args: args:

View File

@@ -80,6 +80,7 @@ homeassistant.components.anthemav.*
homeassistant.components.apache_kafka.* homeassistant.components.apache_kafka.*
homeassistant.components.apcupsd.* homeassistant.components.apcupsd.*
homeassistant.components.api.* homeassistant.components.api.*
homeassistant.components.apple_tv.*
homeassistant.components.apprise.* homeassistant.components.apprise.*
homeassistant.components.aprs.* homeassistant.components.aprs.*
homeassistant.components.aqualogic.* homeassistant.components.aqualogic.*

View File

@@ -104,6 +104,8 @@ build.json @home-assistant/supervisor
/tests/components/application_credentials/ @home-assistant/core /tests/components/application_credentials/ @home-assistant/core
/homeassistant/components/apprise/ @caronc /homeassistant/components/apprise/ @caronc
/tests/components/apprise/ @caronc /tests/components/apprise/ @caronc
/homeassistant/components/aprilaire/ @chamberlain2007
/tests/components/aprilaire/ @chamberlain2007
/homeassistant/components/aprs/ @PhilRW /homeassistant/components/aprs/ @PhilRW
/tests/components/aprs/ @PhilRW /tests/components/aprs/ @PhilRW
/homeassistant/components/aranet/ @aschmitz @thecode /homeassistant/components/aranet/ @aschmitz @thecode
@@ -584,6 +586,8 @@ build.json @home-assistant/supervisor
/tests/components/humidifier/ @home-assistant/core @Shulyaka /tests/components/humidifier/ @home-assistant/core @Shulyaka
/homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock /homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
/tests/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 /homeassistant/components/huum/ @frwickst
/tests/components/huum/ @frwickst /tests/components/huum/ @frwickst
/homeassistant/components/hvv_departures/ @vigonotion /homeassistant/components/hvv_departures/ @vigonotion
@@ -786,8 +790,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/media_source/ @hunterjm /homeassistant/components/media_source/ @hunterjm
/tests/components/media_source/ @hunterjm /tests/components/media_source/ @hunterjm
/homeassistant/components/mediaroom/ @dgomes /homeassistant/components/mediaroom/ @dgomes
/homeassistant/components/melcloud/ @vilppuvuorinen
/tests/components/melcloud/ @vilppuvuorinen
/homeassistant/components/melissa/ @kennedyshead /homeassistant/components/melissa/ @kennedyshead
/tests/components/melissa/ @kennedyshead /tests/components/melissa/ @kennedyshead
/homeassistant/components/melnor/ @vanstinator /homeassistant/components/melnor/ @vanstinator
@@ -1460,7 +1462,8 @@ build.json @home-assistant/supervisor
/tests/components/valve/ @home-assistant/core /tests/components/valve/ @home-assistant/core
/homeassistant/components/velbus/ @Cereal2nd @brefra /homeassistant/components/velbus/ @Cereal2nd @brefra
/tests/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 /homeassistant/components/venstar/ @garbled1 @jhollowe
/tests/components/venstar/ @garbled1 @jhollowe /tests/components/venstar/ @garbled1 @jhollowe
/homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/versasense/ @imstevenxyz

View File

@@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant image: ghcr.io/home-assistant/{arch}-homeassistant
build_from: build_from:
aarch64: ghcr.io/home-assistant/aarch64-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.0 armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.02.1
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.02.0 armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.02.1
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.02.0 amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.02.1
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.02.0 i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.02.1
codenotary: codenotary:
signer: notary@home-assistant.io signer: notary@home-assistant.io
base_image: notary@home-assistant.io base_image: notary@home-assistant.io

View File

@@ -35,6 +35,7 @@ from .helpers import (
recorder, recorder,
restore_state, restore_state,
template, template,
translation,
) )
from .helpers.dispatcher import async_dispatcher_send from .helpers.dispatcher import async_dispatcher_send
from .helpers.typing import ConfigType 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 # Ask integrations to shut down. It's messy but we can't
# do a clean stop without knowing what is broken # 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): async with hass.timeout.async_timeout(10):
await hass.async_stop() 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 platform.uname().processor # pylint: disable=expression-not-assigned
# Load the registries and cache the result of platform.uname().processor # Load the registries and cache the result of platform.uname().processor
translation.async_setup(hass)
entity.async_setup(hass) entity.async_setup(hass)
template.async_setup(hass) template.async_setup(hass)
await asyncio.gather( await asyncio.gather(
@@ -738,7 +740,7 @@ async def _async_set_up_integrations(
STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME
): ):
await async_setup_multi_components(hass, stage_1_domains, config) 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") _LOGGER.warning("Setup timed out for stage 1 - moving forward")
# Add after dependencies when setting up stage 2 domains # 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 STAGE_2_TIMEOUT, cool_down=COOLDOWN_TIME
): ):
await async_setup_multi_components(hass, stage_2_domains, config) 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") _LOGGER.warning("Setup timed out for stage 2 - moving forward")
# Wrap up startup # Wrap up startup
@@ -759,7 +761,7 @@ async def _async_set_up_integrations(
try: try:
async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME): async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME):
await hass.async_block_till_done() await hass.async_block_till_done()
except asyncio.TimeoutError: except TimeoutError:
_LOGGER.warning("Setup timed out for bootstrap - moving forward") _LOGGER.warning("Setup timed out for bootstrap - moving forward")
watch_task.cancel() watch_task.cancel()

View File

@@ -1,6 +1,6 @@
{ {
"domain": "tplink", "domain": "tplink",
"name": "TP-Link", "name": "TP-Link",
"integrations": ["tplink", "tplink_omada", "tplink_lte"], "integrations": ["tplink", "tplink_omada", "tplink_lte", "tplink_tapo"],
"iot_standards": ["matter"] "iot_standards": ["matter"]
} }

View File

@@ -1,7 +1,6 @@
"""Adds config flow for AccuWeather.""" """Adds config flow for AccuWeather."""
from __future__ import annotations from __future__ import annotations
import asyncio
from asyncio import timeout from asyncio import timeout
from typing import Any from typing import Any
@@ -61,7 +60,7 @@ class AccuWeatherFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
longitude=user_input[CONF_LONGITUDE], longitude=user_input[CONF_LONGITUDE],
) )
await accuweather.async_get_location() await accuweather.async_get_location()
except (ApiError, ClientConnectorError, asyncio.TimeoutError, ClientError): except (ApiError, ClientConnectorError, TimeoutError, ClientError):
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except InvalidApiKeyError: except InvalidApiKeyError:
errors[CONF_API_KEY] = "invalid_api_key" errors[CONF_API_KEY] = "invalid_api_key"

View File

@@ -1,7 +1,6 @@
"""Config flow for Rollease Acmeda Automate Pulse Hub.""" """Config flow for Rollease Acmeda Automate Pulse Hub."""
from __future__ import annotations from __future__ import annotations
import asyncio
from asyncio import timeout from asyncio import timeout
from contextlib import suppress from contextlib import suppress
from typing import Any from typing import Any
@@ -42,7 +41,7 @@ class AcmedaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
} }
hubs: list[aiopulse.Hub] = [] hubs: list[aiopulse.Hub] = []
with suppress(asyncio.TimeoutError): with suppress(TimeoutError):
async with timeout(5): async with timeout(5):
async for hub in aiopulse.Hub.discover(): async for hub in aiopulse.Hub.discover():
if hub.id not in already_configured: if hub.id not in already_configured:

View File

@@ -303,7 +303,7 @@ class AdsEntity(Entity):
try: try:
async with timeout(10): async with timeout(10):
await self._event.wait() await self._event.wait()
except asyncio.TimeoutError: except TimeoutError:
_LOGGER.debug("Variable %s: Timeout during first update", ads_var) _LOGGER.debug("Variable %s: Timeout during first update", ads_var)
@property @property

View File

@@ -77,9 +77,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
new_data = entry.data.copy() new_data = entry.data.copy()
del new_data[CONF_RADIUS] del new_data[CONF_RADIUS]
entry.version = 2
hass.config_entries.async_update_entry( 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) _LOGGER.info("Migration to version %s successful", entry.version)

View File

@@ -88,9 +88,13 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
_attr_name = None _attr_name = None
_attr_supported_features = ( _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 _attr_temperature_unit = UnitOfTemperature.CELSIUS
_enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator, ac_number, info): def __init__(self, coordinator, ac_number, info):
"""Initialize the climate device.""" """Initialize the climate device."""
@@ -192,9 +196,14 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_name = None _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_temperature_unit = UnitOfTemperature.CELSIUS
_attr_hvac_modes = AT_GROUP_MODES _attr_hvac_modes = AT_GROUP_MODES
_enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator, group_number, info): def __init__(self, coordinator, group_number, info):
"""Initialize the climate device.""" """Initialize the climate device."""

View File

@@ -120,15 +120,12 @@ class Airtouch5ClimateEntity(ClimateEntity, Airtouch5Entity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_target_temperature_step = 1 _attr_target_temperature_step = 1
_attr_name = None _attr_name = None
_enable_turn_on_off_backwards_compatibility = False
class Airtouch5AC(Airtouch5ClimateEntity): class Airtouch5AC(Airtouch5ClimateEntity):
"""Representation of the AC unit. Used to control the overall HVAC Mode.""" """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: def __init__(self, client: Airtouch5SimpleClient, ability: AcAbility) -> None:
"""Initialise the Climate Entity.""" """Initialise the Climate Entity."""
super().__init__(client) super().__init__(client)
@@ -152,6 +149,14 @@ class Airtouch5AC(Airtouch5ClimateEntity):
if ability.supports_mode_heat: if ability.supports_mode_heat:
self._attr_hvac_modes.append(HVACMode.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 = [] self._attr_fan_modes = []
if ability.supports_fan_speed_quiet: if ability.supports_fan_speed_quiet:
self._attr_fan_modes.append(FAN_DIFFUSE) self._attr_fan_modes.append(FAN_DIFFUSE)
@@ -262,7 +267,10 @@ class Airtouch5Zone(Airtouch5ClimateEntity):
_attr_hvac_modes = [HVACMode.OFF, HVACMode.FAN_ONLY] _attr_hvac_modes = [HVACMode.OFF, HVACMode.FAN_ONLY]
_attr_preset_modes = [PRESET_NONE, PRESET_BOOST] _attr_preset_modes = [PRESET_NONE, PRESET_BOOST]
_attr_supported_features = ( _attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
) )
def __init__( def __init__(

View File

@@ -242,7 +242,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# 1 -> 2: One geography per config entry # 1 -> 2: One geography per config entry
if version == 1: if version == 1:
version = entry.version = 2 version = 2
# Update the config entry to only include the first geography (there is always # Update the config entry to only include the first geography (there is always
# guaranteed to be at least one): # guaranteed to be at least one):
@@ -255,6 +255,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unique_id=first_id, unique_id=first_id,
title=f"Cloud API ({first_id})", title=f"Cloud API ({first_id})",
data={CONF_API_KEY: entry.data[CONF_API_KEY], **first_geography}, 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: # 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: else:
entry.version = version hass.config_entries.async_update_entry(entry, version=version)
LOGGER.info("Migration to version %s successful", version) LOGGER.info("Migration to version %s successful", version)

View File

@@ -144,11 +144,6 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity):
"""Define an Airzone Cloud climate.""" """Define an Airzone Cloud climate."""
_attr_name = None _attr_name = None
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
_enable_turn_on_off_backwards_compatibility = False _enable_turn_on_off_backwards_compatibility = False
@@ -180,6 +175,12 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity):
class AirzoneDeviceClimate(AirzoneClimate): class AirzoneDeviceClimate(AirzoneClimate):
"""Define an Airzone Cloud Device base class.""" """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: async def async_turn_on(self) -> None:
"""Turn the entity on.""" """Turn the entity on."""
params = { params = {
@@ -217,6 +218,12 @@ class AirzoneDeviceClimate(AirzoneClimate):
class AirzoneDeviceGroupClimate(AirzoneClimate): class AirzoneDeviceGroupClimate(AirzoneClimate):
"""Define an Airzone Cloud DeviceGroup base class.""" """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: async def async_turn_on(self) -> None:
"""Turn the entity on.""" """Turn the entity on."""
params = { params = {

View File

@@ -1,5 +1,4 @@
"""The aladdin_connect component.""" """The aladdin_connect component."""
import asyncio
import logging import logging
from typing import Final from typing import Final
@@ -29,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) )
try: try:
await acc.login() 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 raise ConfigEntryNotReady("Can not connect to host") from ex
except Aladdin.InvalidPasswordError as ex: except Aladdin.InvalidPasswordError as ex:
raise ConfigEntryAuthFailed("Incorrect Password") from ex raise ConfigEntryAuthFailed("Incorrect Password") from ex

View File

@@ -1,7 +1,6 @@
"""Config flow for Aladdin Connect cover integration.""" """Config flow for Aladdin Connect cover integration."""
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any from typing import Any
@@ -42,7 +41,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
) )
try: try:
await acc.login() await acc.login()
except (ClientError, asyncio.TimeoutError, Aladdin.ConnectionError) as ex: except (ClientError, TimeoutError, Aladdin.ConnectionError) as ex:
raise ex raise ex
except Aladdin.InvalidPasswordError as ex: except Aladdin.InvalidPasswordError as ex:
@@ -81,7 +80,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
except InvalidAuth: except InvalidAuth:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except (ClientError, asyncio.TimeoutError, Aladdin.ConnectionError): except (ClientError, TimeoutError, Aladdin.ConnectionError):
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
else: else:
@@ -117,7 +116,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
except InvalidAuth: except InvalidAuth:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except (ClientError, asyncio.TimeoutError, Aladdin.ConnectionError): except (ClientError, TimeoutError, Aladdin.ConnectionError):
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
else: else:

View File

@@ -122,7 +122,7 @@ class Auth:
allow_redirects=True, allow_redirects=True,
) )
except (asyncio.TimeoutError, aiohttp.ClientError): except (TimeoutError, aiohttp.ClientError):
_LOGGER.error("Timeout calling LWA to get auth token") _LOGGER.error("Timeout calling LWA to get auth token")
return None return None

View File

@@ -29,12 +29,20 @@ class AbstractConfig(ABC):
"""Initialize abstract config.""" """Initialize abstract config."""
self.hass = hass self.hass = hass
self._enable_proactive_mode_lock = asyncio.Lock() self._enable_proactive_mode_lock = asyncio.Lock()
self._on_deinitialize: list[CALLBACK_TYPE] = []
async def async_initialize(self) -> None: async def async_initialize(self) -> None:
"""Perform async initialization of config.""" """Perform async initialization of config."""
self._store = AlexaConfigStore(self.hass) self._store = AlexaConfigStore(self.hass)
await self._store.async_load() 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 @property
def supports_auth(self) -> bool: def supports_auth(self) -> bool:
"""Return if config supports auth.""" """Return if config supports auth."""

View File

@@ -1,7 +1,6 @@
"""Alexa state report code.""" """Alexa state report code."""
from __future__ import annotations from __future__ import annotations
import asyncio
from asyncio import timeout from asyncio import timeout
from http import HTTPStatus from http import HTTPStatus
import json import json
@@ -375,7 +374,7 @@ async def async_send_changereport_message(
allow_redirects=True, 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) _LOGGER.error("Timeout sending report to Alexa for %s", alexa_entity.entity_id)
return return
@@ -531,7 +530,7 @@ async def async_send_doorbell_event_message(
allow_redirects=True, 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) _LOGGER.error("Timeout sending report to Alexa for %s", alexa_entity.entity_id)
return return

View File

@@ -3,18 +3,46 @@ from __future__ import annotations
import amberelectric import amberelectric
from amberelectric.api import amber_api from amberelectric.api import amber_api
from amberelectric.model.site import Site from amberelectric.model.site import Site, SiteStatus
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import CONF_API_TOKEN from homeassistant.const import CONF_API_TOKEN
from homeassistant.data_entry_flow import FlowResult 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" 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): class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow.""" """Handle a config flow."""
@@ -31,7 +59,7 @@ class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
api: amber_api.AmberApi = amber_api.AmberApi.create(configuration) api: amber_api.AmberApi = amber_api.AmberApi.create(configuration)
try: try:
sites: list[Site] = api.get_sites() sites: list[Site] = filter_sites(api.get_sites())
if len(sites) == 0: if len(sites) == 0:
self._errors[CONF_API_TOKEN] = "no_site" self._errors[CONF_API_TOKEN] = "no_site"
return None return None
@@ -86,38 +114,31 @@ class AmberElectricConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
assert self._sites is not None assert self._sites is not None
assert self._api_token is not None assert self._api_token is not None
api_token = self._api_token
if user_input is not None: if user_input is not None:
site_nmi = user_input[CONF_SITE_NMI] site_id = user_input[CONF_SITE_ID]
sites = [site for site in self._sites if site.nmi == site_nmi]
site = sites[0]
site_id = site.id
name = user_input.get(CONF_SITE_NAME, site_id) name = user_input.get(CONF_SITE_NAME, site_id)
return self.async_create_entry( return self.async_create_entry(
title=name, title=name,
data={ data={CONF_SITE_ID: site_id, CONF_API_TOKEN: self._api_token},
CONF_SITE_ID: site_id,
CONF_API_TOKEN: api_token,
CONF_SITE_NMI: site.nmi,
},
) )
user_input = {
CONF_API_TOKEN: api_token,
CONF_SITE_NMI: "",
CONF_SITE_NAME: "",
}
return self.async_show_form( return self.async_show_form(
step_id="site", step_id="site",
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required( vol.Required(CONF_SITE_ID): SelectSelector(
CONF_SITE_NMI, default=user_input[CONF_SITE_NMI] SelectSelectorConfig(
): vol.In([site.nmi for site in self._sites]), options=[
vol.Optional( SelectOptionDict(
CONF_SITE_NAME, default=user_input[CONF_SITE_NAME] value=site.id,
): str, label=generate_site_selector_name(site),
)
for site in self._sites
],
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_SITE_NAME): str,
} }
), ),
errors=self._errors, errors=self._errors,

View File

@@ -6,7 +6,6 @@ from homeassistant.const import Platform
DOMAIN = "amberelectric" DOMAIN = "amberelectric"
CONF_SITE_NAME = "site_name" CONF_SITE_NAME = "site_name"
CONF_SITE_ID = "site_id" CONF_SITE_ID = "site_id"
CONF_SITE_NMI = "site_nmi"
ATTRIBUTION = "Data provided by Amber Electric" ATTRIBUTION = "Data provided by Amber Electric"

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/amberelectric", "documentation": "https://www.home-assistant.io/integrations/amberelectric",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["amberelectric"], "loggers": ["amberelectric"],
"requirements": ["amberelectric==1.0.4"] "requirements": ["amberelectric==1.1.0"]
} }

View File

@@ -111,7 +111,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
en_reg = er.async_get(hass) en_reg = er.async_get(hass)
en_reg.async_clear_config_entry(entry.entry_id) 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) LOGGER.info("Migration to version %s successful", version)

View File

@@ -329,7 +329,7 @@ class Analytics:
response.status, response.status,
self.endpoint, self.endpoint,
) )
except asyncio.TimeoutError: except TimeoutError:
LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL) LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL)
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
LOGGER.error( LOGGER.error(

View File

@@ -3,11 +3,15 @@ from __future__ import annotations
from dataclasses import dataclass 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.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_TRACKED_INTEGRATIONS, DOMAIN 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.""" """Set up Homeassistant Analytics from a config entry."""
client = HomeassistantAnalyticsClient(session=async_get_clientsession(hass)) 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 = {} names = {}
for integration in entry.options[CONF_TRACKED_INTEGRATIONS]: for integration in entry.options[CONF_TRACKED_INTEGRATIONS]:

View File

@@ -53,10 +53,25 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
) -> FlowResult: ) -> FlowResult:
"""Handle the initial step.""" """Handle the initial step."""
self._async_abort_entries_match() self._async_abort_entries_match()
if user_input: errors: dict[str, str] = {}
return self.async_create_entry( if user_input is not None:
title="Home Assistant Analytics Insights", data={}, options=user_input 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( client = HomeassistantAnalyticsClient(
session=async_get_clientsession(self.hass) session=async_get_clientsession(self.hass)
@@ -78,16 +93,17 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
] ]
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
errors=errors,
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required(CONF_TRACKED_INTEGRATIONS): SelectSelector( vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector(
SelectSelectorConfig( SelectSelectorConfig(
options=options, options=options,
multiple=True, multiple=True,
sort=True, sort=True,
) )
), ),
vol.Required(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector( vol.Optional(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector(
SelectSelectorConfig( SelectSelectorConfig(
options=list(custom_integrations), options=list(custom_integrations),
multiple=True, multiple=True,
@@ -106,8 +122,24 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> FlowResult:
"""Manage the options.""" """Manage the options."""
if user_input: errors: dict[str, str] = {}
return self.async_create_entry(title="", data=user_input) 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( client = HomeassistantAnalyticsClient(
session=async_get_clientsession(self.hass) session=async_get_clientsession(self.hass)
@@ -129,17 +161,18 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry):
] ]
return self.async_show_form( return self.async_show_form(
step_id="init", step_id="init",
errors=errors,
data_schema=self.add_suggested_values_to_schema( data_schema=self.add_suggested_values_to_schema(
vol.Schema( vol.Schema(
{ {
vol.Required(CONF_TRACKED_INTEGRATIONS): SelectSelector( vol.Optional(CONF_TRACKED_INTEGRATIONS): SelectSelector(
SelectSelectorConfig( SelectSelectorConfig(
options=options, options=options,
multiple=True, multiple=True,
sort=True, sort=True,
) )
), ),
vol.Required(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector( vol.Optional(CONF_TRACKED_CUSTOM_INTEGRATIONS): SelectSelector(
SelectSelectorConfig( SelectSelectorConfig(
options=list(custom_integrations), options=list(custom_integrations),
multiple=True, multiple=True,

View File

@@ -10,6 +10,7 @@ from homeassistant.components.sensor import (
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -93,6 +94,7 @@ class HomeassistantAnalyticsSensor(
"""Home Assistant Analytics Sensor.""" """Home Assistant Analytics Sensor."""
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
entity_description: AnalyticsSensorEntityDescription entity_description: AnalyticsSensorEntityDescription

View File

@@ -3,25 +3,41 @@
"step": { "step": {
"user": { "user": {
"data": { "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": { "abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]" "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"no_integration_selected": "You must select at least one integration to track"
} }
}, },
"options": { "options": {
"step": { "step": {
"init": { "init": {
"data": { "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": { "abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"error": {
"no_integration_selected": "[%key:component::analytics_insights::config::error::no_integration_selected%]"
} }
}, },
"entity": { "entity": {

View File

@@ -1,7 +1,6 @@
"""The Android TV Remote integration.""" """The Android TV Remote integration."""
from __future__ import annotations from __future__ import annotations
import asyncio
from asyncio import timeout from asyncio import timeout
import logging import logging
@@ -50,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except InvalidAuth as exc: except InvalidAuth as exc:
# The Android TV is hard reset or the certificate and key files were deleted. # The Android TV is hard reset or the certificate and key files were deleted.
raise ConfigEntryAuthFailed from exc 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 # 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. # later. If device gets a new IP address the zeroconf flow will update the config.
raise ConfigEntryNotReady from exc raise ConfigEntryNotReady from exc

View File

@@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aosmith", "documentation": "https://www.home-assistant.io/integrations/aosmith",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["py-aosmith==1.0.6"] "requirements": ["py-aosmith==1.0.8"]
} }

View File

@@ -1,7 +1,6 @@
"""Config flow for APCUPSd integration.""" """Config flow for APCUPSd integration."""
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
@@ -54,7 +53,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
coordinator = APCUPSdCoordinator(self.hass, host, port) coordinator = APCUPSdCoordinator(self.hass, host, port)
await coordinator.async_request_refresh() await coordinator.async_request_refresh()
if isinstance(coordinator.last_exception, (UpdateFailed, asyncio.TimeoutError)): if isinstance(coordinator.last_exception, (UpdateFailed, TimeoutError)):
errors = {"base": "cannot_connect"} errors = {"base": "cannot_connect"}
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=_SCHEMA, errors=errors step_id="user", data_schema=_SCHEMA, errors=errors

View File

@@ -175,7 +175,7 @@ class APIEventStream(HomeAssistantView):
msg = f"data: {payload}\n\n" msg = f"data: {payload}\n\n"
_LOGGER.debug("STREAM %s WRITING %s", id(stop_obj), msg.strip()) _LOGGER.debug("STREAM %s WRITING %s", id(stop_obj), msg.strip())
await response.write(msg.encode("UTF-8")) await response.write(msg.encode("UTF-8"))
except asyncio.TimeoutError: except TimeoutError:
await to_write.put(STREAM_PING_PAYLOAD) await to_write.put(STREAM_PING_PAYLOAD)
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -222,7 +222,7 @@ class APIStatesView(HomeAssistantView):
if entity_perm(state.entity_id, "read") if entity_perm(state.entity_id, "read")
) )
response = web.Response( response = web.Response(
body=b"[" + b",".join(states) + b"]", body=b"".join((b"[", b",".join(states), b"]")),
content_type=CONTENT_TYPE_JSON, content_type=CONTENT_TYPE_JSON,
zlib_executor_size=32768, zlib_executor_size=32768,
) )

View File

@@ -1,8 +1,10 @@
"""The Apple TV integration.""" """The Apple TV integration."""
from __future__ import annotations
import asyncio import asyncio
import logging import logging
from random import randrange from random import randrange
from typing import TYPE_CHECKING, cast from typing import Any, cast
from pyatv import connect, exceptions, scan from pyatv import connect, exceptions, scan
from pyatv.conf import AppleTV from pyatv.conf import AppleTV
@@ -25,8 +27,8 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
Platform, Platform,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo 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__) _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_LOWER_LIMIT = 15 # seconds
BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes 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) manager = AppleTVManager(hass, entry)
if manager.is_on: if manager.is_on:
await manager.connect_once(raise_missing_credentials=True) address = entry.data[CONF_ADDRESS]
if not manager.atv:
address = entry.data[CONF_ADDRESS] try:
raise ConfigEntryNotReady(f"Not found at {address}, waiting for discovery") 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 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.""" """Stop push updates when hass stops."""
await manager.disconnect() await manager.disconnect()
@@ -94,33 +122,29 @@ class AppleTVEntity(Entity):
_attr_should_poll = False _attr_should_poll = False
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_name = None _attr_name = None
atv: AppleTVInterface | None = None
def __init__( def __init__(self, name: str, identifier: str, manager: AppleTVManager) -> None:
self, name: str, identifier: str | None, manager: "AppleTVManager"
) -> None:
"""Initialize device.""" """Initialize device."""
self.atv: AppleTVInterface = None # type: ignore[assignment]
self.manager = manager self.manager = manager
if TYPE_CHECKING:
assert identifier is not None
self._attr_unique_id = identifier self._attr_unique_id = identifier
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, identifier)}, identifiers={(DOMAIN, identifier)},
name=name, 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.""" """Handle when an entity is about to be added to Home Assistant."""
@callback @callback
def _async_connected(atv): def _async_connected(atv: AppleTVInterface) -> None:
"""Handle that a connection was made to a device.""" """Handle that a connection was made to a device."""
self.atv = atv self.atv = atv
self.async_device_connected(atv) self.async_device_connected(atv)
self.async_write_ha_state() self.async_write_ha_state()
@callback @callback
def _async_disconnected(): def _async_disconnected() -> None:
"""Handle that a connection to a device was lost.""" """Handle that a connection to a device was lost."""
self.async_device_disconnected() self.async_device_disconnected()
self.atv = None 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.""" """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.""" """Handle when connection was lost to device."""
@@ -158,22 +182,23 @@ class AppleTVManager(DeviceListener):
in case of problems. 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: def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize power manager.""" """Initialize power manager."""
self.config_entry = config_entry self.config_entry = config_entry
self.hass = hass self.hass = hass
self.atv: AppleTVInterface | None = None
self.is_on = not config_entry.options.get(CONF_START_OFF, False) 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.""" """Initialize power management."""
if self.is_on: if self.is_on:
await self.connect() await self.connect()
def connection_lost(self, _): def connection_lost(self, exception: Exception) -> None:
"""Device was unexpectedly disconnected. """Device was unexpectedly disconnected.
This is a callback function from pyatv.interface.DeviceListener. This is a callback function from pyatv.interface.DeviceListener.
@@ -184,14 +209,14 @@ class AppleTVManager(DeviceListener):
self._connection_was_lost = True self._connection_was_lost = True
self._handle_disconnect() self._handle_disconnect()
def connection_closed(self): def connection_closed(self) -> None:
"""Device connection was (intentionally) closed. """Device connection was (intentionally) closed.
This is a callback function from pyatv.interface.DeviceListener. This is a callback function from pyatv.interface.DeviceListener.
""" """
self._handle_disconnect() self._handle_disconnect()
def _handle_disconnect(self): def _handle_disconnect(self) -> None:
"""Handle that the device disconnected and restart connect loop.""" """Handle that the device disconnected and restart connect loop."""
if self.atv: if self.atv:
self.atv.close() self.atv.close()
@@ -199,12 +224,12 @@ class AppleTVManager(DeviceListener):
self._dispatch_send(SIGNAL_DISCONNECTED) self._dispatch_send(SIGNAL_DISCONNECTED)
self._start_connect_loop() self._start_connect_loop()
async def connect(self): async def connect(self) -> None:
"""Connect to device.""" """Connect to device."""
self.is_on = True self.is_on = True
self._start_connect_loop() self._start_connect_loop()
async def disconnect(self): async def disconnect(self) -> None:
"""Disconnect from device.""" """Disconnect from device."""
_LOGGER.debug("Disconnecting from device") _LOGGER.debug("Disconnecting from device")
self.is_on = False self.is_on = False
@@ -218,7 +243,7 @@ class AppleTVManager(DeviceListener):
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.exception("An error occurred while disconnecting") _LOGGER.exception("An error occurred while disconnecting")
def _start_connect_loop(self): def _start_connect_loop(self) -> None:
"""Start background connect loop to device.""" """Start background connect loop to device."""
if not self._task and self.atv is None and self.is_on: if not self._task and self.atv is None and self.is_on:
self._task = asyncio.create_task(self._connect_loop()) 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 "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: async def connect_once(self, raise_missing_credentials: bool) -> None:
"""Try to connect once.""" """Try to connect once."""
try: try:
if conf := await self._scan(): await self._connect_once(raise_missing_credentials)
await self._connect(conf, raise_missing_credentials)
except exceptions.AuthenticationError: except exceptions.AuthenticationError:
self.config_entry.async_start_reauth(self.hass) self.config_entry.async_start_reauth(self.hass)
await self.disconnect() await self.disconnect()
@@ -244,9 +283,9 @@ class AppleTVManager(DeviceListener):
pass pass
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.exception("Failed to connect") _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.""" """Connect loop background task function."""
_LOGGER.debug("Starting connect loop") _LOGGER.debug("Starting connect loop")
@@ -255,7 +294,8 @@ class AppleTVManager(DeviceListener):
while self.is_on and self.atv is None: while self.is_on and self.atv is None:
await self.connect_once(raise_missing_credentials=False) await self.connect_once(raise_missing_credentials=False)
if self.atv is not None: if self.atv is not None:
break # Calling self.connect_once may have set self.atv
break # type: ignore[unreachable]
self._connection_attempts += 1 self._connection_attempts += 1
backoff = min( backoff = min(
max( max(
@@ -352,13 +392,17 @@ class AppleTVManager(DeviceListener):
self._connection_was_lost = False self._connection_was_lost = False
@callback @callback
def _async_setup_device_registry(self): def _async_setup_device_registry(self) -> None:
attrs = { attrs = {
ATTR_IDENTIFIERS: {(DOMAIN, self.config_entry.unique_id)}, ATTR_IDENTIFIERS: {(DOMAIN, self.config_entry.unique_id)},
ATTR_MANUFACTURER: "Apple", ATTR_MANUFACTURER: "Apple",
ATTR_NAME: self.config_entry.data[CONF_NAME], 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: if self.atv:
dev_info = self.atv.device_info dev_info = self.atv.device_info
@@ -379,18 +423,18 @@ class AppleTVManager(DeviceListener):
) )
@property @property
def is_connecting(self): def is_connecting(self) -> bool:
"""Return true if connection is in progress.""" """Return true if connection is in progress."""
return self._task is not None return self._task is not None
def _address_updated(self, address): def _address_updated(self, address: str) -> None:
"""Update cached address in config entry.""" """Update cached address in config entry."""
_LOGGER.debug("Changing address to %s", address) _LOGGER.debug("Changing address to %s", address)
self.hass.config_entries.async_update_entry( self.hass.config_entries.async_update_entry(
self.config_entry, data={**self.config_entry.data, CONF_ADDRESS: address} 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.""" """Dispatch a signal to all entities managed by this manager."""
async_dispatcher_send( async_dispatcher_send(
self.hass, f"{signal}_{self.config_entry.unique_id}", *args self.hass, f"{signal}_{self.config_entry.unique_id}", *args

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections import deque from collections import deque
from collections.abc import Mapping from collections.abc import Awaitable, Callable, Mapping
from ipaddress import ip_address from ipaddress import ip_address
import logging import logging
from random import randrange from random import randrange
@@ -13,12 +13,13 @@ from pyatv import exceptions, pair, scan
from pyatv.const import DeviceModel, PairingRequirement, Protocol from pyatv.const import DeviceModel, PairingRequirement, Protocol
from pyatv.convert import model_str, protocol_str from pyatv.convert import model_str, protocol_str
from pyatv.helpers import get_unique_id from pyatv.helpers import get_unique_id
from pyatv.interface import BaseConfig, PairingHandler
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components import zeroconf from homeassistant.components import zeroconf
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PIN 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.data_entry_flow import AbortFlow, FlowResult
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession 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.""" """Scan for a specific device using identifier as filter."""
def _filter_device(dev): def _filter_device(dev: BaseConfig) -> bool:
if identifier is None: if identifier is None:
return True return True
if identifier == str(dev.address): if identifier == str(dev.address):
@@ -61,9 +64,12 @@ async def device_scan(hass, identifier, loop):
return True return True
return any(service.identifier == identifier for service in dev.services) 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: try:
return [ip_address(identifier)] ip_address(identifier)
return [identifier]
except ValueError: except ValueError:
return None return None
@@ -84,6 +90,13 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 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 @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(
@@ -92,18 +105,12 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Get options flow for this handler.""" """Get options flow for this handler."""
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
def __init__(self): def __init__(self) -> None:
"""Initialize a new AppleTVConfigFlow.""" """Initialize a new AppleTVConfigFlow."""
self.scan_filter = None self.credentials: dict[int, str | None] = {} # Protocol -> credentials
self.atv = None
self.atv_identifiers = None
self.protocol = None
self.pairing = None
self.credentials = {} # Protocol -> credentials
self.protocols_to_pair = deque()
@property @property
def device_identifier(self): def device_identifier(self) -> str | None:
"""Return a identifier for the config entry. """Return a identifier for the config entry.
A device has multiple unique identifiers, but Home Assistant only supports one 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 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. re-used, otherwise the newly discovered identifier is used instead.
""" """
assert self.atv
all_identifiers = set(self.atv.all_identifiers) all_identifiers = set(self.atv.all_identifiers)
if unique_id := self._entry_unique_id_from_identifers(all_identifiers): if unique_id := self._entry_unique_id_from_identifers(all_identifiers):
return unique_id return unique_id
@@ -143,7 +151,9 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self.context["identifier"] = self.unique_id self.context["identifier"] = self.unique_id
return await self.async_step_reconfigure() 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.""" """Inform user that reconfiguration is about to start."""
if user_input is not None: if user_input is not None:
return await self.async_find_device_wrapper( 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") 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.""" """Handle the initial step."""
errors = {} errors = {}
if user_input is not None: if user_input is not None:
@@ -170,6 +182,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id( await self.async_set_unique_id(
self.device_identifier, raise_on_progress=False self.device_identifier, raise_on_progress=False
) )
assert self.atv
self.context["all_identifiers"] = self.atv.all_identifiers self.context["all_identifiers"] = self.atv.all_identifiers
return await self.async_step_confirm() return await self.async_step_confirm()
@@ -275,8 +288,11 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
context["all_identifiers"].append(unique_id) context["all_identifiers"].append(unique_id)
raise AbortFlow("already_in_progress") 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.""" """Handle device found after Zeroconf discovery."""
assert self.atv
self.context["all_identifiers"] = self.atv.all_identifiers self.context["all_identifiers"] = self.atv.all_identifiers
# Also abort if an integration with this identifier already exists # Also abort if an integration with this identifier already exists
await self.async_set_unique_id(self.device_identifier) 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 self.context["identifier"] = self.unique_id
return await self.async_step_confirm() 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. """Find a specific device and call another function when done.
This function will do error handling and bail out when an error 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() 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.""" """Scan for the selected device to discover services."""
self.atv, self.atv_identifiers = await device_scan( self.atv, self.atv_identifiers = await device_scan(
self.hass, self.scan_filter, self.hass.loop self.hass, self.scan_filter, self.hass.loop
@@ -357,8 +377,11 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if not allow_exist: if not allow_exist:
raise DeviceAlreadyConfigured() 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.""" """Handle user-confirmation of discovered node."""
assert self.atv
if user_input is not None: if user_input is not None:
expected_identifier_count = len(self.context["all_identifiers"]) expected_identifier_count = len(self.context["all_identifiers"])
# If number of services found during device scan mismatch number of # 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.""" """Start pairing process for the next available protocol."""
await self._async_cleanup() await self._async_cleanup()
@@ -393,8 +416,16 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self._async_get_entry() return await self._async_get_entry()
self.protocol = self.protocols_to_pair.popleft() self.protocol = self.protocols_to_pair.popleft()
assert self.atv
service = self.atv.get_service(self.protocol) 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 # Service requires a password
if service.requires_password: if service.requires_password:
return await self.async_step_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) _LOGGER.debug("%s requires pairing", self.protocol)
# Protocol specific arguments # Protocol specific arguments
pair_args = {} pair_args: dict[str, Any] = {}
if self.protocol in {Protocol.AirPlay, Protocol.Companion, Protocol.DMAP}: if self.protocol in {Protocol.AirPlay, Protocol.Companion, Protocol.DMAP}:
pair_args["name"] = "Home Assistant" pair_args["name"] = "Home Assistant"
if self.protocol == Protocol.DMAP: if self.protocol == Protocol.DMAP:
@@ -448,8 +479,11 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_pair_no_pin() 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.""" """Inform user that a protocol is disabled and cannot be paired."""
assert self.protocol
if user_input is not None: if user_input is not None:
return await self.async_pair_next_protocol() return await self.async_pair_next_protocol()
return self.async_show_form( return self.async_show_form(
@@ -457,9 +491,13 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
description_placeholders={"protocol": protocol_str(self.protocol)}, 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.""" """Handle pairing step where a PIN is required from the user."""
errors = {} errors = {}
assert self.pairing
assert self.protocol
if user_input is not None: if user_input is not None:
try: try:
self.pairing.pin(user_input[CONF_PIN]) 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)}, 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.""" """Handle step where user has to enter a PIN on the device."""
assert self.pairing
assert self.protocol
if user_input is not None: if user_input is not None:
await self.pairing.finish() await self.pairing.finish()
if self.pairing.has_paired: if self.pairing.has_paired:
@@ -497,12 +539,15 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="pair_no_pin", step_id="pair_no_pin",
description_placeholders={ description_placeholders={
"protocol": protocol_str(self.protocol), "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.""" """Inform user that a service will not be added."""
assert self.protocol
if user_input is not None: if user_input is not None:
return await self.async_pair_next_protocol() 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)}, 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.""" """Inform user that password is not supported."""
assert self.protocol
if user_input is not None: if user_input is not None:
return await self.async_pair_next_protocol() 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)}, description_placeholders={"protocol": protocol_str(self.protocol)},
) )
async def _async_cleanup(self): async def _async_cleanup(self) -> None:
"""Clean up allocated resources.""" """Clean up allocated resources."""
if self.pairing is not None: if self.pairing is not None:
await self.pairing.close() await self.pairing.close()
self.pairing = None self.pairing = None
async def _async_get_entry(self): async def _async_get_entry(self) -> FlowResult:
"""Return config entry or update existing config entry.""" """Return config entry or update existing config entry."""
# Abort if no protocols were paired # Abort if no protocols were paired
if not self.credentials: if not self.credentials:
return self.async_abort(reason="setup_failed") return self.async_abort(reason="setup_failed")
assert self.atv
data = { data = {
CONF_NAME: self.atv.name, CONF_NAME: self.atv.name,
CONF_CREDENTIALS: self.credentials, CONF_CREDENTIALS: self.credentials,

View File

@@ -16,7 +16,15 @@ from pyatv.const import (
ShuffleState, ShuffleState,
) )
from pyatv.helpers import is_streamable 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 import media_source
from homeassistant.components.media_player import ( 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)]) 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.""" """Representation of an Apple TV media player."""
_attr_supported_features = SUPPORT_APPLE_TV _attr_supported_features = SUPPORT_APPLE_TV
@@ -116,9 +126,9 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
def async_device_connected(self, atv: AppleTV) -> None: def async_device_connected(self, atv: AppleTV) -> None:
"""Handle when connection is made to device.""" """Handle when connection is made to device."""
# NB: Do not use _is_feature_available here as it only works when playing # NB: Do not use _is_feature_available here as it only works when playing
if self.atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates): if atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates):
self.atv.push_updater.listener = self atv.push_updater.listener = self
self.atv.push_updater.start() atv.push_updater.start()
self._attr_supported_features = SUPPORT_BASE 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 # "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 # change after a connection has been established, i.e. an unsupported feature
# can never change to be supported. # 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(): for feature_name, support_flag in SUPPORT_FEATURE_MAPPING.items():
feature_info = all_features.get(feature_name) feature_info = all_features.get(feature_name)
if feature_info and feature_info.state != FeatureState.Unsupported: 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) # metadata update arrives (sometime very soon after this callback returns)
# Listen to power updates # Listen to power updates
self.atv.power.listener = self atv.power.listener = self
# Listen to volume updates # 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()) self.hass.create_task(self._update_app_list())
async def _update_app_list(self) -> None: async def _update_app_list(self) -> None:
_LOGGER.debug("Updating app list") _LOGGER.debug("Updating app list")
if not self.atv:
return
try: try:
apps = await self.atv.apps.app_list() apps = await self.atv.apps.app_list()
except exceptions.NotSupportedError: except exceptions.NotSupportedError:
@@ -189,33 +201,56 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
return None return None
@callback @callback
def playstatus_update(self, _, playing: Playing) -> None: def playstatus_update(self, updater: PushUpdater, playstatus: Playing) -> None:
"""Print what is currently playing when it changes.""" """Print what is currently playing when it changes.
self._playing = playing
This is a callback function from pyatv.interface.PushListener.
"""
self._playing = playstatus
self.async_write_ha_state() self.async_write_ha_state()
@callback @callback
def playstatus_error(self, _, exception: Exception) -> None: def playstatus_error(self, updater: PushUpdater, exception: Exception) -> None:
"""Inform about an error and restart push updates.""" """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) _LOGGER.warning("A %s error occurred: %s", exception.__class__, exception)
self._playing = None self._playing = None
self.async_write_ha_state() self.async_write_ha_state()
@callback @callback
def powerstate_update(self, old_state: PowerState, new_state: PowerState) -> None: 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() self.async_write_ha_state()
@callback @callback
def volume_update(self, old_level: float, new_level: float) -> None: 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() 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 @property
def app_id(self) -> str | None: def app_id(self) -> str | None:
"""ID of the current running app.""" """ID of the current running app."""
if self._is_feature_available(FeatureName.App) and ( if (
app := self.atv.metadata.app self.atv
and self._is_feature_available(FeatureName.App)
and (app := self.atv.metadata.app) is not None
): ):
return app.identifier return app.identifier
return None return None
@@ -223,8 +258,10 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
@property @property
def app_name(self) -> str | None: def app_name(self) -> str | None:
"""Name of the current running app.""" """Name of the current running app."""
if self._is_feature_available(FeatureName.App) and ( if (
app := self.atv.metadata.app self.atv
and self._is_feature_available(FeatureName.App)
and (app := self.atv.metadata.app) is not None
): ):
return app.name return app.name
return None return None
@@ -255,7 +292,7 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
@property @property
def volume_level(self) -> float | None: def volume_level(self) -> float | None:
"""Volume level of the media player (0..1).""" """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 self.atv.audio.volume / 100.0 # from percent
return None return None
@@ -286,6 +323,8 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
"""Send the play_media command to the media player.""" """Send the play_media command to the media player."""
# If input (file) has a file format supported by pyatv, then stream it with # If input (file) has a file format supported by pyatv, then stream it with
# RAOP. Otherwise try to play it with regular AirPlay. # RAOP. Otherwise try to play it with regular AirPlay.
if not self.atv:
return
if media_type in {MediaType.APP, MediaType.URL}: if media_type in {MediaType.APP, MediaType.URL}:
await self.atv.apps.launch_app(media_id) await self.atv.apps.launch_app(media_id)
return return
@@ -313,7 +352,8 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
"""Hash value for media image.""" """Hash value for media image."""
state = self.state state = self.state
if ( if (
self._playing self.atv
and self._playing
and self._is_feature_available(FeatureName.Artwork) and self._is_feature_available(FeatureName.Artwork)
and state not in {None, MediaPlayerState.OFF, MediaPlayerState.IDLE} 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]: async def async_get_media_image(self) -> tuple[bytes | None, str | None]:
"""Fetch media image of current playing image.""" """Fetch media image of current playing image."""
state = self.state 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() artwork = await self.atv.metadata.artwork()
if artwork: if artwork:
return artwork.bytes, artwork.mimetype return artwork.bytes, artwork.mimetype
@@ -439,20 +483,24 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
async def async_turn_on(self) -> None: async def async_turn_on(self) -> None:
"""Turn the media player on.""" """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() await self.atv.power.turn_on()
async def async_turn_off(self) -> None: async def async_turn_off(self) -> None:
"""Turn the media player off.""" """Turn the media player off."""
if (self._is_feature_available(FeatureName.TurnOff)) and ( if (
not self._is_feature_available(FeatureName.PowerState) self.atv
or self.atv.power.power_state == PowerState.On 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() await self.atv.power.turn_off()
async def async_media_play_pause(self) -> None: async def async_media_play_pause(self) -> None:
"""Pause media on media player.""" """Pause media on media player."""
if self._playing: if self.atv and self._playing:
await self.atv.remote_control.play_pause() await self.atv.remote_control.play_pause()
async def async_media_play(self) -> None: async def async_media_play(self) -> None:
@@ -519,5 +567,6 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity):
async def async_select_source(self, source: str) -> None: async def async_select_source(self, source: str) -> None:
"""Select input source.""" """Select input source."""
if app_id := self._app_list.get(source): if self.atv:
await self.atv.apps.launch_app(app_id) if app_id := self._app_list.get(source):
await self.atv.apps.launch_app(app_id)

View File

@@ -15,7 +15,7 @@ from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AppleTVEntity from . import AppleTVEntity, AppleTVManager
from .const import DOMAIN from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -38,8 +38,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Load Apple TV remote based on a config entry.""" """Load Apple TV remote based on a config entry."""
name = config_entry.data[CONF_NAME] name: str = config_entry.data[CONF_NAME]
manager = hass.data[DOMAIN][config_entry.unique_id] # 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)]) 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.""" """Device that sends commands to an Apple TV."""
@property @property
def is_on(self): def is_on(self) -> bool:
"""Return true if device is on.""" """Return true if device is on."""
return self.atv is not None return self.atv is not None
@@ -64,13 +66,13 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity):
num_repeats = kwargs[ATTR_NUM_REPEATS] num_repeats = kwargs[ATTR_NUM_REPEATS]
delay = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) 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) _LOGGER.error("Unable to send commands, not connected to %s", self.name)
return return
for _ in range(num_repeats): for _ in range(num_repeats):
for single_command in command: for single_command in command:
attr_value = None attr_value: Any = None
if attributes := COMMAND_TO_ATTRIBUTE.get(single_command): if attributes := COMMAND_TO_ATTRIBUTE.get(single_command):
attr_value = self.atv attr_value = self.atv
for attr_name in attributes: for attr_name in attributes:
@@ -81,5 +83,5 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity):
raise ValueError("Command not found. Exiting sequence") raise ValueError("Command not found. Exiting sequence")
_LOGGER.info("Sending command %s", single_command) _LOGGER.info("Sending command %s", single_command)
await attr_value() # type: ignore[operator] await attr_value()
await asyncio.sleep(delay) await asyncio.sleep(delay)

View File

@@ -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

View File

@@ -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()

View File

@@ -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"},
)

View File

@@ -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"

View File

@@ -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

View File

@@ -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."""

View File

@@ -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"]
}

View File

@@ -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"
}
}
}
}

View File

@@ -83,7 +83,7 @@ async def _run_client(hass: HomeAssistant, client: Client, interval: float) -> N
except ConnectionFailed: except ConnectionFailed:
await asyncio.sleep(interval) await asyncio.sleep(interval)
except asyncio.TimeoutError: except TimeoutError:
continue continue
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception, aborting arcam client") _LOGGER.exception("Unexpected exception, aborting arcam client")

View File

@@ -241,7 +241,7 @@ async def websocket_run(
# Task contains a timeout # Task contains a timeout
async with asyncio.timeout(timeout): async with asyncio.timeout(timeout):
await run_task await run_task
except asyncio.TimeoutError: except TimeoutError:
pipeline_input.run.process_event( pipeline_input.run.process_event(
PipelineEvent( PipelineEvent(
PipelineEventType.ERROR, PipelineEventType.ERROR,
@@ -487,7 +487,7 @@ async def websocket_device_capture(
) )
try: try:
with contextlib.suppress(asyncio.TimeoutError): with contextlib.suppress(TimeoutError):
async with asyncio.timeout(timeout_seconds): async with asyncio.timeout(timeout_seconds):
while True: while True:
# Send audio chunks encoded as base64 # Send audio chunks encoded as base64

View File

@@ -11,6 +11,7 @@ from typing import Any, TypeVar, cast
from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy
from aiohttp import ClientSession from aiohttp import ClientSession
from pyasuswrt import AsusWrtError, AsusWrtHttp from pyasuswrt import AsusWrtError, AsusWrtHttp
from pyasuswrt.exceptions import AsusWrtNotAvailableInfoError
from homeassistant.const import ( from homeassistant.const import (
CONF_HOST, CONF_HOST,
@@ -354,13 +355,14 @@ class AsusWrtHttpBridge(AsusWrtBridge):
async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]: async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]:
"""Return a dictionary of available sensors for this bridge.""" """Return a dictionary of available sensors for this bridge."""
sensors_temperatures = await self._get_available_temperature_sensors() sensors_temperatures = await self._get_available_temperature_sensors()
sensors_loadavg = await self._get_loadavg_sensors_availability()
sensors_types = { sensors_types = {
SENSORS_TYPE_BYTES: { SENSORS_TYPE_BYTES: {
KEY_SENSORS: SENSORS_BYTES, KEY_SENSORS: SENSORS_BYTES,
KEY_METHOD: self._get_bytes, KEY_METHOD: self._get_bytes,
}, },
SENSORS_TYPE_LOAD_AVG: { SENSORS_TYPE_LOAD_AVG: {
KEY_SENSORS: SENSORS_LOAD_AVG, KEY_SENSORS: sensors_loadavg,
KEY_METHOD: self._get_load_avg, KEY_METHOD: self._get_load_avg,
}, },
SENSORS_TYPE_RATES: { SENSORS_TYPE_RATES: {
@@ -393,6 +395,16 @@ class AsusWrtHttpBridge(AsusWrtBridge):
return [] return []
return available_sensors 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) @handle_errors_and_zip(AsusWrtError, SENSORS_BYTES)
async def _get_bytes(self) -> Any: async def _get_bytes(self) -> Any:
"""Fetch byte information from the router.""" """Fetch byte information from the router."""

View File

@@ -59,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return await async_setup_august(hass, entry, august_gateway) return await async_setup_august(hass, entry, august_gateway)
except (RequireValidation, InvalidAuth) as err: except (RequireValidation, InvalidAuth) as err:
raise ConfigEntryAuthFailed from err raise ConfigEntryAuthFailed from err
except asyncio.TimeoutError as err: except TimeoutError as err:
raise ConfigEntryNotReady("Timed out connecting to august api") from err raise ConfigEntryNotReady("Timed out connecting to august api") from err
except (AugustApiAIOHTTPError, ClientResponseError, CannotConnect) as err: except (AugustApiAIOHTTPError, ClientResponseError, CannotConnect) as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
@@ -233,7 +233,7 @@ class AugustData(AugustSubscriberMixin):
return_exceptions=True, return_exceptions=True,
): ):
if isinstance(result, Exception) and not isinstance( if isinstance(result, Exception) and not isinstance(
result, (asyncio.TimeoutError, ClientResponseError, CannotConnect) result, (TimeoutError, ClientResponseError, CannotConnect)
): ):
_LOGGER.warning( _LOGGER.warning(
"Unexpected exception during initial sync: %s", "Unexpected exception during initial sync: %s",
@@ -249,10 +249,11 @@ class AugustData(AugustSubscriberMixin):
device = self.get_device_detail(device_id) device = self.get_device_detail(device_id)
activities = activities_from_pubnub_message(device, date_time, message) activities = activities_from_pubnub_message(device, date_time, message)
activity_stream = self.activity_stream activity_stream = self.activity_stream
if activities: if activities and activity_stream.async_process_newer_device_activities(
activity_stream.async_process_newer_device_activities(activities) activities
):
self.async_signal_device_id_update(device.device_id) 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 @callback
def async_stop(self) -> None: def async_stop(self) -> None:
@@ -292,7 +293,7 @@ class AugustData(AugustSubscriberMixin):
for device_id in device_ids_list: for device_id in device_ids_list:
try: try:
await self._async_refresh_device_detail_by_id(device_id) await self._async_refresh_device_detail_by_id(device_id)
except asyncio.TimeoutError: except TimeoutError:
_LOGGER.warning( _LOGGER.warning(
"Timed out calling august api during refresh of device: %s", "Timed out calling august api during refresh of device: %s",
device_id, device_id,

View File

@@ -28,5 +28,5 @@
"documentation": "https://www.home-assistant.io/integrations/august", "documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"], "loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==1.10.0", "yalexs-ble==2.4.0"] "requirements": ["yalexs==1.11.2", "yalexs-ble==2.4.1"]
} }

View File

@@ -1,7 +1,6 @@
"""Helpers to resolve client ID/secret.""" """Helpers to resolve client ID/secret."""
from __future__ import annotations from __future__ import annotations
import asyncio
from html.parser import HTMLParser from html.parser import HTMLParser
from ipaddress import ip_address from ipaddress import ip_address
import logging import logging
@@ -102,7 +101,7 @@ async def fetch_redirect_uris(hass: HomeAssistant, url: str) -> list[str]:
if chunks == 10: if chunks == 10:
break break
except asyncio.TimeoutError: except TimeoutError:
_LOGGER.error("Timeout while looking up redirect_uri %s", url) _LOGGER.error("Timeout while looking up redirect_uri %s", url)
except aiohttp.client_exceptions.ClientSSLError: except aiohttp.client_exceptions.ClientSSLError:
_LOGGER.error("SSL error while looking up redirect_uri %s", url) _LOGGER.error("SSL error while looking up redirect_uri %s", url)

View File

@@ -50,7 +50,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
if config_entry.version != 3: if config_entry.version != 3:
# Home Assistant 2023.2 # 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) _LOGGER.info("Migration to version %s successful", config_entry.version)

View File

@@ -1,6 +1,5 @@
"""Axis network device abstraction.""" """Axis network device abstraction."""
import asyncio
from asyncio import timeout from asyncio import timeout
from types import MappingProxyType from types import MappingProxyType
from typing import Any from typing import Any
@@ -270,7 +269,7 @@ async def get_axis_device(
) )
raise AuthenticationRequired from err 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]) LOGGER.error("Error connecting to the Axis device at %s", config[CONF_HOST])
raise CannotConnect from err raise CannotConnect from err

View File

@@ -4,11 +4,12 @@ from __future__ import annotations
import asyncio import asyncio
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
import hashlib import hashlib
import io
import json import json
from pathlib import Path from pathlib import Path
import tarfile import tarfile
from tarfile import TarError from tarfile import TarError
from tempfile import TemporaryDirectory import time
from typing import Any, Protocol, cast from typing import Any, Protocol, cast
from securetar import SecureTarFile, atomic_contents_add 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.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import integration_platform 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 import dt as dt_util
from homeassistant.util.json import json_loads_object from homeassistant.util.json import json_loads_object
@@ -81,6 +82,38 @@ class BackupManager:
return return
self.platforms[integration_domain] = platform 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: async def load_backups(self) -> None:
"""Load data of stored backup files.""" """Load data of stored backup files."""
backups = await self.hass.async_add_executor_job(self._read_backups) backups = await self.hass.async_add_executor_job(self._read_backups)
@@ -159,22 +192,9 @@ class BackupManager:
if self.backing_up: if self.backing_up:
raise HomeAssistantError("Backup already in progress") raise HomeAssistantError("Backup already in progress")
if not self.loaded_platforms:
await self.load_platforms()
try: try:
self.backing_up = True self.backing_up = True
pre_backup_results = await asyncio.gather( await self.pre_backup_actions()
*(
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
backup_name = f"Core {HAVERSION}" backup_name = f"Core {HAVERSION}"
date_str = dt_util.now().isoformat() date_str = dt_util.now().isoformat()
slug = _generate_slug(date_str, backup_name) slug = _generate_slug(date_str, backup_name)
@@ -207,16 +227,7 @@ class BackupManager:
return backup return backup
finally: finally:
self.backing_up = False self.backing_up = False
post_backup_results = await asyncio.gather( await self.post_backup_actions()
*(
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
def _mkdir_and_generate_backup_contents( def _mkdir_and_generate_backup_contents(
self, self,
@@ -228,18 +239,18 @@ class BackupManager:
LOGGER.debug("Creating backup directory") LOGGER.debug("Creating backup directory")
self.backup_dir.mkdir() self.backup_dir.mkdir()
with TemporaryDirectory() as tmp_dir, SecureTarFile( outer_secure_tarfile = SecureTarFile(
tar_file_path, "w", gzip=False, bufsize=BUF_SIZE tar_file_path, "w", gzip=False, bufsize=BUF_SIZE
) as tar_file: )
tmp_dir_path = Path(tmp_dir) with outer_secure_tarfile as outer_secure_tarfile_tarfile:
save_json( raw_bytes = json_bytes(backup_data)
tmp_dir_path.joinpath("./backup.json").as_posix(), fileobj = io.BytesIO(raw_bytes)
backup_data, tar_info = tarfile.TarInfo(name="./backup.json")
) tar_info.size = len(raw_bytes)
with SecureTarFile( tar_info.mtime = int(time.time())
tmp_dir_path.joinpath("./homeassistant.tar.gz").as_posix(), outer_secure_tarfile_tarfile.addfile(tar_info, fileobj=fileobj)
"w", with outer_secure_tarfile.create_inner_tar(
bufsize=BUF_SIZE, "./homeassistant.tar.gz", gzip=True
) as core_tar: ) as core_tar:
atomic_contents_add( atomic_contents_add(
tar_file=core_tar, tar_file=core_tar,
@@ -247,7 +258,7 @@ class BackupManager:
excludes=EXCLUDE_FROM_BACKUP, excludes=EXCLUDE_FROM_BACKUP,
arcname="data", arcname="data",
) )
tar_file.add(tmp_dir_path, arcname=".")
return tar_file_path.stat().st_size return tar_file_path.stat().st_size

View File

@@ -7,5 +7,5 @@
"integration_type": "system", "integration_type": "system",
"iot_class": "calculated", "iot_class": "calculated",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["securetar==2023.3.0"] "requirements": ["securetar==2024.2.0"]
} }

View File

@@ -1,7 +1,6 @@
"""The Big Ass Fans integration.""" """The Big Ass Fans integration."""
from __future__ import annotations from __future__ import annotations
import asyncio
from asyncio import timeout from asyncio import timeout
from aiobafi6 import Device, Service from aiobafi6 import Device, Service
@@ -42,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
f"Unexpected device found at {ip_address}; expected {entry.unique_id}, found {device.dns_sd_uuid}" f"Unexpected device found at {ip_address}; expected {entry.unique_id}, found {device.dns_sd_uuid}"
) from ex ) from ex
except asyncio.TimeoutError as ex: except TimeoutError as ex:
run_future.cancel() run_future.cancel()
raise ConfigEntryNotReady(f"Timed out connecting to {ip_address}") from ex raise ConfigEntryNotReady(f"Timed out connecting to {ip_address}") from ex

View File

@@ -1,7 +1,6 @@
"""Config flow for baf.""" """Config flow for baf."""
from __future__ import annotations from __future__ import annotations
import asyncio
from asyncio import timeout from asyncio import timeout
import logging import logging
from typing import Any from typing import Any
@@ -28,7 +27,7 @@ async def async_try_connect(ip_address: str) -> Device:
try: try:
async with timeout(RUN_TIMEOUT): async with timeout(RUN_TIMEOUT):
await device.async_wait_available() await device.async_wait_available()
except asyncio.TimeoutError as ex: except TimeoutError as ex:
raise CannotConnect from ex raise CannotConnect from ex
finally: finally:
run_future.cancel() run_future.cancel()

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import socket
from pyblackbird import get_blackbird from pyblackbird import get_blackbird
from serial import SerialException from serial import SerialException
@@ -93,7 +92,7 @@ def setup_platform(
try: try:
blackbird = get_blackbird(host, False) blackbird = get_blackbird(host, False)
connection = host connection = host
except socket.timeout: except TimeoutError:
_LOGGER.error("Error connecting to the Blackbird controller") _LOGGER.error("Error connecting to the Blackbird controller")
return return

View File

@@ -62,7 +62,6 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
def __init__(self, feature: blebox_uniapi.light.Light) -> None: def __init__(self, feature: blebox_uniapi.light.Light) -> None:
"""Initialize a BleBox light.""" """Initialize a BleBox light."""
super().__init__(feature) super().__init__(feature)
self._attr_supported_color_modes = {self.color_mode}
if feature.effect_list: if feature.effect_list:
self._attr_supported_features = LightEntityFeature.EFFECT self._attr_supported_features = LightEntityFeature.EFFECT
@@ -94,6 +93,11 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
return color_mode_tmp return color_mode_tmp
@property
def supported_color_modes(self):
"""Return supported color modes."""
return {self.color_mode}
@property @property
def effect_list(self) -> list[str]: def effect_list(self) -> list[str]:
"""Return the list of supported effects.""" """Return the list of supported effects."""

View File

@@ -1,5 +1,4 @@
"""Support for Blink Home Camera System.""" """Support for Blink Home Camera System."""
import asyncio
from copy import deepcopy from copy import deepcopy
import logging import logging
@@ -93,7 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try: try:
await blink.start() await blink.start()
except (ClientError, asyncio.TimeoutError) as ex: except (ClientError, TimeoutError) as ex:
raise ConfigEntryNotReady("Can not connect to host") from ex raise ConfigEntryNotReady("Can not connect to host") from ex
if blink.auth.check_key_required(): if blink.auth.check_key_required():

View File

@@ -1,7 +1,6 @@
"""Support for Blink Alarm Control Panel.""" """Support for Blink Alarm Control Panel."""
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from blinkpy.blinkpy import Blink, BlinkSyncModule from blinkpy.blinkpy import Blink, BlinkSyncModule
@@ -91,7 +90,7 @@ class BlinkSyncModuleHA(
try: try:
await self.sync.async_arm(False) await self.sync.async_arm(False)
except asyncio.TimeoutError as er: except TimeoutError as er:
raise HomeAssistantError("Blink failed to disarm camera") from er raise HomeAssistantError("Blink failed to disarm camera") from er
await self.coordinator.async_refresh() await self.coordinator.async_refresh()
@@ -101,7 +100,7 @@ class BlinkSyncModuleHA(
try: try:
await self.sync.async_arm(True) 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 raise HomeAssistantError("Blink failed to arm camera away") from er
await self.coordinator.async_refresh() await self.coordinator.async_refresh()

View File

@@ -1,7 +1,6 @@
"""Support for Blink system camera.""" """Support for Blink system camera."""
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Mapping from collections.abc import Mapping
import contextlib import contextlib
import logging import logging
@@ -96,7 +95,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
try: try:
await self._camera.async_arm(True) await self._camera.async_arm(True)
except asyncio.TimeoutError as er: except TimeoutError as er:
raise HomeAssistantError("Blink failed to arm camera") from er raise HomeAssistantError("Blink failed to arm camera") from er
self._camera.motion_enabled = True self._camera.motion_enabled = True
@@ -106,7 +105,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
"""Disable motion detection for the camera.""" """Disable motion detection for the camera."""
try: try:
await self._camera.async_arm(False) await self._camera.async_arm(False)
except asyncio.TimeoutError as er: except TimeoutError as er:
raise HomeAssistantError("Blink failed to disarm camera") from er raise HomeAssistantError("Blink failed to disarm camera") from er
self._camera.motion_enabled = False self._camera.motion_enabled = False
@@ -124,7 +123,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
async def trigger_camera(self) -> None: async def trigger_camera(self) -> None:
"""Trigger camera to take a snapshot.""" """Trigger camera to take a snapshot."""
with contextlib.suppress(asyncio.TimeoutError): with contextlib.suppress(TimeoutError):
await self._camera.snap_picture() await self._camera.snap_picture()
self.async_write_ha_state() self.async_write_ha_state()

View File

@@ -1,7 +1,6 @@
"""Support for Blink Motion detection switches.""" """Support for Blink Motion detection switches."""
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import Any from typing import Any
from homeassistant.components.switch import ( from homeassistant.components.switch import (
@@ -74,7 +73,7 @@ class BlinkSwitch(CoordinatorEntity[BlinkUpdateCoordinator], SwitchEntity):
try: try:
await self._camera.async_arm(True) await self._camera.async_arm(True)
except asyncio.TimeoutError as er: except TimeoutError as er:
raise HomeAssistantError( raise HomeAssistantError(
"Blink failed to arm camera motion detection" "Blink failed to arm camera motion detection"
) from er ) from er
@@ -86,7 +85,7 @@ class BlinkSwitch(CoordinatorEntity[BlinkUpdateCoordinator], SwitchEntity):
try: try:
await self._camera.async_arm(False) await self._camera.async_arm(False)
except asyncio.TimeoutError as er: except TimeoutError as er:
raise HomeAssistantError( raise HomeAssistantError(
"Blink failed to dis-arm camera motion detection" "Blink failed to dis-arm camera motion detection"
) from er ) from er

View File

@@ -1,6 +1,7 @@
"""The Blue Current integration.""" """The Blue Current integration."""
from __future__ import annotations from __future__ import annotations
import asyncio
from contextlib import suppress from contextlib import suppress
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -14,8 +15,13 @@ from bluecurrent_api.exceptions import (
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_NAME, CONF_API_TOKEN, Platform from homeassistant.const import (
from homeassistant.core import HomeAssistant ATTR_NAME,
CONF_API_TOKEN,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later 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: except BlueCurrentException as err:
raise ConfigEntryNotReady from 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.get_charge_points()
await client.wait_for_response() 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) 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 return True
@@ -78,9 +89,9 @@ class Connector:
self, hass: HomeAssistant, config: ConfigEntry, client: Client self, hass: HomeAssistant, config: ConfigEntry, client: Client
) -> None: ) -> None:
"""Initialize.""" """Initialize."""
self.config: ConfigEntry = config self.config = config
self.hass: HomeAssistant = hass self.hass = hass
self.client: Client = client self.client = client
self.charge_points: dict[str, dict] = {} self.charge_points: dict[str, dict] = {}
self.grid: dict[str, Any] = {} self.grid: dict[str, Any] = {}
self.available = False self.available = False
@@ -93,22 +104,12 @@ class Connector:
async def on_data(self, message: dict) -> None: async def on_data(self, message: dict) -> None:
"""Handle received data.""" """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] object_name: str = message[OBJECT]
# gets charge point ids # gets charge point ids
if object_name == CHARGE_POINTS: if object_name == CHARGE_POINTS:
charge_points_data: list = message[DATA] 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 # gets charge point key / values
elif object_name in VALUE_TYPES: elif object_name in VALUE_TYPES:
@@ -122,8 +123,21 @@ class Connector:
self.grid = data self.grid = data
self.dispatch_grid_update_signal() self.dispatch_grid_update_signal()
async def get_charge_point_data(self, evse_id: str) -> None: async def handle_charge_point_data(self, charge_points_data: list) -> None:
"""Get all the data of a charge point.""" """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) await self.client.get_status(evse_id)
def add_charge_point(self, evse_id: str, model: str, name: str) -> None: 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.""" """Keep trying to reconnect to the websocket."""
try: try:
await self.connect(self.config.data[CONF_API_TOKEN]) 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()) self.hass.async_create_task(self.start_loop())
await self.client.get_charge_points()
except RequestLimitReached: except RequestLimitReached:
self.available = False self.available = False
async_call_later( async_call_later(

View File

@@ -1,4 +1,6 @@
"""Entity representing a Blue Current charge point.""" """Entity representing a Blue Current charge point."""
from abc import abstractmethod
from homeassistant.const import ATTR_NAME from homeassistant.const import ATTR_NAME
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
@@ -17,9 +19,9 @@ class BlueCurrentEntity(Entity):
def __init__(self, connector: Connector, signal: str) -> None: def __init__(self, connector: Connector, signal: str) -> None:
"""Initialize the entity.""" """Initialize the entity."""
self.connector: Connector = connector self.connector = connector
self.signal: str = signal self.signal = signal
self.has_value: bool = False self.has_value = False
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Register callbacks.""" """Register callbacks."""
@@ -40,9 +42,9 @@ class BlueCurrentEntity(Entity):
return self.connector.available and self.has_value return self.connector.available and self.has_value
@callback @callback
@abstractmethod
def update_from_latest_data(self) -> None: def update_from_latest_data(self) -> None:
"""Update the entity from the latest data.""" """Update the entity from the latest data."""
raise NotImplementedError
class ChargepointEntity(BlueCurrentEntity): class ChargepointEntity(BlueCurrentEntity):
@@ -50,6 +52,8 @@ class ChargepointEntity(BlueCurrentEntity):
def __init__(self, connector: Connector, evse_id: str) -> None: def __init__(self, connector: Connector, evse_id: str) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(connector, f"{DOMAIN}_value_update_{evse_id}")
chargepoint_name = connector.charge_points[evse_id][ATTR_NAME] chargepoint_name = connector.charge_points[evse_id][ATTR_NAME]
self.evse_id = evse_id self.evse_id = evse_id
@@ -59,5 +63,3 @@ class ChargepointEntity(BlueCurrentEntity):
manufacturer="Blue Current", manufacturer="Blue Current",
model=connector.charge_points[evse_id][MODEL_TYPE], model=connector.charge_points[evse_id][MODEL_TYPE],
) )
super().__init__(connector, f"{DOMAIN}_value_update_{self.evse_id}")

View File

@@ -13,7 +13,6 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"limit_reached": "Request limit reached", "limit_reached": "Request limit reached",
"invalid_token": "Invalid token", "invalid_token": "Invalid token",
"no_cards_found": "No charge cards found",
"already_connected": "Already connected", "already_connected": "Already connected",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },

View File

@@ -290,7 +290,7 @@ class BluesoundPlayer(MediaPlayerEntity):
while True: while True:
await self.async_update_status() 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) _LOGGER.info("Node %s:%s is offline, retrying later", self.name, self.port)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
self.start_polling() self.start_polling()
@@ -317,7 +317,7 @@ class BluesoundPlayer(MediaPlayerEntity):
self._retry_remove = None self._retry_remove = None
await self.force_update_sync_status(self._init_callback, True) 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) _LOGGER.info("Node %s:%s is offline, retrying later", self.host, self.port)
self._retry_remove = async_track_time_interval( self._retry_remove = async_track_time_interval(
self._hass, self.async_init, NODE_RETRY_INITIATION self._hass, self.async_init, NODE_RETRY_INITIATION
@@ -370,7 +370,7 @@ class BluesoundPlayer(MediaPlayerEntity):
_LOGGER.error("Error %s on %s", response.status, url) _LOGGER.error("Error %s on %s", response.status, url)
return None return None
except (asyncio.TimeoutError, aiohttp.ClientError): except (TimeoutError, aiohttp.ClientError):
if raise_timeout: if raise_timeout:
_LOGGER.info("Timeout: %s:%s", self.host, self.port) _LOGGER.info("Timeout: %s:%s", self.host, self.port)
raise raise
@@ -437,7 +437,7 @@ class BluesoundPlayer(MediaPlayerEntity):
"Error %s on %s. Trying one more time", response.status, url "Error %s on %s. Trying one more time", response.status, url
) )
except (asyncio.TimeoutError, ClientError): except (TimeoutError, ClientError):
self._is_online = False self._is_online = False
self._last_status_update = None self._last_status_update = None
self._status = None self._status = None

View File

@@ -90,6 +90,8 @@ def seen_all_fields(
class IntegrationMatcher: class IntegrationMatcher:
"""Integration matcher for the bluetooth integration.""" """Integration matcher for the bluetooth integration."""
__slots__ = ("_integration_matchers", "_matched", "_matched_connectable", "_index")
def __init__(self, integration_matchers: list[BluetoothMatcher]) -> None: def __init__(self, integration_matchers: list[BluetoothMatcher]) -> None:
"""Initialize the matcher.""" """Initialize the matcher."""
self._integration_matchers = integration_matchers 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. 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: def __init__(self) -> None:
"""Initialize the matcher index.""" """Initialize the matcher index."""
self.local_name: dict[str, list[_T]] = {} self.local_name: dict[str, list[_T]] = {}
@@ -285,6 +297,8 @@ class BluetoothCallbackMatcherIndex(
Supports matching on addresses. Supports matching on addresses.
""" """
__slots__ = ("address", "connectable")
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the matcher index.""" """Initialize the matcher index."""
super().__init__() super().__init__()

View File

@@ -649,7 +649,8 @@ class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProce
self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name
if device_id is None: if device_id is None:
self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_BLUETOOTH, address)} 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 @property
def available(self) -> bool: def available(self) -> bool:

View File

@@ -1,7 +1,6 @@
"""Tracking for bluetooth low energy devices.""" """Tracking for bluetooth low energy devices."""
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from uuid import UUID from uuid import UUID
@@ -155,7 +154,7 @@ async def async_setup_scanner( # noqa: C901
async with BleakClient(device) as client: async with BleakClient(device) as client:
bat_char = await client.read_gatt_char(BATTERY_CHARACTERISTIC_UUID) bat_char = await client.read_gatt_char(BATTERY_CHARACTERISTIC_UUID)
battery = ord(bat_char) battery = ord(bat_char)
except asyncio.TimeoutError: except TimeoutError:
_LOGGER.debug( _LOGGER.debug(
"Timeout when trying to get battery status for %s", service_info.name "Timeout when trying to get battery status for %s", service_info.name
) )

View File

@@ -10,7 +10,11 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform
from homeassistant.core import HomeAssistant, callback 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 import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.typing import ConfigType 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 return True

View File

@@ -1,5 +1,4 @@
"""The Bond integration.""" """The Bond integration."""
from asyncio import TimeoutError as AsyncIOTimeoutError
from http import HTTPStatus from http import HTTPStatus
import logging import logging
from typing import Any 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) _LOGGER.error("Bond token no longer valid: %s", ex)
return False return False
raise ConfigEntryNotReady from ex raise ConfigEntryNotReady from ex
except (ClientError, AsyncIOTimeoutError, OSError) as error: except (ClientError, TimeoutError, OSError) as error:
raise ConfigEntryNotReady from error raise ConfigEntryNotReady from error
bpup_subs = BPUPSubscriptions() bpup_subs = BPUPSubscriptions()

View File

@@ -1,7 +1,6 @@
"""Config flow for Bond integration.""" """Config flow for Bond integration."""
from __future__ import annotations from __future__ import annotations
import asyncio
import contextlib import contextlib
from http import HTTPStatus from http import HTTPStatus
import logging import logging
@@ -87,7 +86,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
try: try:
if not (token := await async_get_token(self.hass, host)): if not (token := await async_get_token(self.hass, host)):
return return
except asyncio.TimeoutError: except TimeoutError:
return return
self._discovered[CONF_ACCESS_TOKEN] = token self._discovered[CONF_ACCESS_TOKEN] = token

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from abc import abstractmethod from abc import abstractmethod
from asyncio import Lock, TimeoutError as AsyncIOTimeoutError from asyncio import Lock
from datetime import datetime from datetime import datetime
import logging import logging
@@ -139,7 +139,7 @@ class BondEntity(Entity):
"""Fetch via the API.""" """Fetch via the API."""
try: try:
state: dict = await self._hub.bond.device_state(self._device_id) 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: if self.available:
_LOGGER.warning( _LOGGER.warning(
"Entity %s has become unavailable", self.entity_id, exc_info=error "Entity %s has become unavailable", self.entity_id, exc_info=error

View File

@@ -3,8 +3,8 @@ from __future__ import annotations
import logging import logging
from python_bring_api.bring import Bring from bring_api.bring import Bring
from python_bring_api.exceptions import ( from bring_api.exceptions import (
BringAuthException, BringAuthException,
BringParseException, BringParseException,
BringRequestException, BringRequestException,
@@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN from .const import DOMAIN
from .coordinator import BringDataUpdateCoordinator from .coordinator import BringDataUpdateCoordinator
@@ -29,14 +30,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
email = entry.data[CONF_EMAIL] email = entry.data[CONF_EMAIL]
password = entry.data[CONF_PASSWORD] password = entry.data[CONF_PASSWORD]
bring = Bring(email, password) session = async_get_clientsession(hass)
bring = Bring(session, email, password)
def login_and_load_lists() -> None:
bring.login()
bring.loadLists()
try: try:
await hass.async_add_executor_job(login_and_load_lists) await bring.login()
await bring.loadLists()
except BringRequestException as e: except BringRequestException as e:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
f"Timeout while connecting for email '{email}'" f"Timeout while connecting for email '{email}'"

View File

@@ -4,13 +4,14 @@ from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
from python_bring_api.bring import Bring from bring_api.bring import Bring
from python_bring_api.exceptions import BringAuthException, BringRequestException from bring_api.exceptions import BringAuthException, BringRequestException
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import ( from homeassistant.helpers.selector import (
TextSelector, TextSelector,
TextSelectorConfig, TextSelectorConfig,
@@ -48,14 +49,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle the initial step.""" """Handle the initial step."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
bring = Bring(user_input[CONF_EMAIL], user_input[CONF_PASSWORD]) session = async_get_clientsession(self.hass)
bring = Bring(session, user_input[CONF_EMAIL], user_input[CONF_PASSWORD])
def login_and_load_lists() -> None:
bring.login()
bring.loadLists()
try: try:
await self.hass.async_add_executor_job(login_and_load_lists) await bring.login()
await bring.loadLists()
except BringRequestException: except BringRequestException:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except BringAuthException: except BringAuthException:

View File

@@ -4,9 +4,9 @@ from __future__ import annotations
from datetime import timedelta from datetime import timedelta
import logging import logging
from python_bring_api.bring import Bring from bring_api.bring import Bring
from python_bring_api.exceptions import BringParseException, BringRequestException from bring_api.exceptions import BringParseException, BringRequestException
from python_bring_api.types import BringItemsResponse, BringList from bring_api.types import BringItemsResponse, BringList
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -40,9 +40,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
async def _async_update_data(self) -> dict[str, BringData]: async def _async_update_data(self) -> dict[str, BringData]:
try: try:
lists_response = await self.hass.async_add_executor_job( lists_response = await self.bring.loadLists()
self.bring.loadLists
)
except BringRequestException as e: except BringRequestException as e:
raise UpdateFailed("Unable to connect and retrieve data from bring") from e raise UpdateFailed("Unable to connect and retrieve data from bring") from e
except BringParseException as e: except BringParseException as e:
@@ -51,9 +49,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
list_dict = {} list_dict = {}
for lst in lists_response["lists"]: for lst in lists_response["lists"]:
try: try:
items = await self.hass.async_add_executor_job( items = await self.bring.getItems(lst["listUuid"])
self.bring.getItems, lst["listUuid"]
)
except BringRequestException as e: except BringRequestException as e:
raise UpdateFailed( raise UpdateFailed(
"Unable to connect and retrieve data from bring" "Unable to connect and retrieve data from bring"

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bring", "documentation": "https://www.home-assistant.io/integrations/bring",
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["python-bring-api==2.0.0"] "requirements": ["bring-api==0.1.1"]
} }

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from python_bring_api.exceptions import BringRequestException from bring_api.exceptions import BringRequestException
from homeassistant.components.todo import ( from homeassistant.components.todo import (
TodoItem, TodoItem,
@@ -75,8 +75,8 @@ class BringTodoListEntity(
"""Return the todo items.""" """Return the todo items."""
return [ return [
TodoItem( TodoItem(
uid=item["name"], uid=item["itemId"],
summary=item["name"], summary=item["itemId"],
description=item["specification"] or "", description=item["specification"] or "",
status=TodoItemStatus.NEEDS_ACTION, status=TodoItemStatus.NEEDS_ACTION,
) )
@@ -91,11 +91,8 @@ class BringTodoListEntity(
async def async_create_todo_item(self, item: TodoItem) -> None: async def async_create_todo_item(self, item: TodoItem) -> None:
"""Add an item to the To-do list.""" """Add an item to the To-do list."""
try: try:
await self.hass.async_add_executor_job( await self.coordinator.bring.saveItem(
self.coordinator.bring.saveItem, self.bring_list["listUuid"], item.summary, item.description or ""
self.bring_list["listUuid"],
item.summary,
item.description or "",
) )
except BringRequestException as e: except BringRequestException as e:
raise HomeAssistantError("Unable to save todo item for bring") from e raise HomeAssistantError("Unable to save todo item for bring") from e
@@ -126,16 +123,14 @@ class BringTodoListEntity(
assert item.uid assert item.uid
if item.status == TodoItemStatus.COMPLETED: if item.status == TodoItemStatus.COMPLETED:
await self.hass.async_add_executor_job( await self.coordinator.bring.removeItem(
self.coordinator.bring.removeItem,
bring_list["listUuid"], bring_list["listUuid"],
item.uid, item.uid,
) )
elif item.summary == item.uid: elif item.summary == item.uid:
try: try:
await self.hass.async_add_executor_job( await self.coordinator.bring.updateItem(
self.coordinator.bring.updateItem,
bring_list["listUuid"], bring_list["listUuid"],
item.uid, item.uid,
item.description or "", item.description or "",
@@ -144,13 +139,11 @@ class BringTodoListEntity(
raise HomeAssistantError("Unable to update todo item for bring") from e raise HomeAssistantError("Unable to update todo item for bring") from e
else: else:
try: try:
await self.hass.async_add_executor_job( await self.coordinator.bring.removeItem(
self.coordinator.bring.removeItem,
bring_list["listUuid"], bring_list["listUuid"],
item.uid, item.uid,
) )
await self.hass.async_add_executor_job( await self.coordinator.bring.saveItem(
self.coordinator.bring.saveItem,
bring_list["listUuid"], bring_list["listUuid"],
item.summary, item.summary,
item.description or "", item.description or "",
@@ -164,8 +157,8 @@ class BringTodoListEntity(
"""Delete an item from the To-do list.""" """Delete an item from the To-do list."""
for uid in uids: for uid in uids:
try: try:
await self.hass.async_add_executor_job( await self.coordinator.bring.removeItem(
self.coordinator.bring.removeItem, self.bring_list["listUuid"], uid self.bring_list["listUuid"], uid
) )
except BringRequestException as e: except BringRequestException as e:
raise HomeAssistantError("Unable to delete todo item for bring") from e raise HomeAssistantError("Unable to delete todo item for bring") from e

View File

@@ -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"
}
}
}
}

View File

@@ -52,14 +52,12 @@ class BrotherSensorEntityDescription(
SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="status", key="status",
icon="mdi:printer",
translation_key="status", translation_key="status",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
value=lambda data: data.status, value=lambda data: data.status,
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="page_counter", key="page_counter",
icon="mdi:file-document-outline",
translation_key="page_counter", translation_key="page_counter",
native_unit_of_measurement=UNIT_PAGES, native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -68,7 +66,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="bw_counter", key="bw_counter",
icon="mdi:file-document-outline",
translation_key="bw_pages", translation_key="bw_pages",
native_unit_of_measurement=UNIT_PAGES, native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -77,7 +74,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="color_counter", key="color_counter",
icon="mdi:file-document-outline",
translation_key="color_pages", translation_key="color_pages",
native_unit_of_measurement=UNIT_PAGES, native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -86,7 +82,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="duplex_unit_pages_counter", key="duplex_unit_pages_counter",
icon="mdi:file-document-outline",
translation_key="duplex_unit_page_counter", translation_key="duplex_unit_page_counter",
native_unit_of_measurement=UNIT_PAGES, native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -95,7 +90,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="drum_remaining_life", key="drum_remaining_life",
icon="mdi:chart-donut",
translation_key="drum_remaining_life", translation_key="drum_remaining_life",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -104,7 +98,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="drum_remaining_pages", key="drum_remaining_pages",
icon="mdi:chart-donut",
translation_key="drum_remaining_pages", translation_key="drum_remaining_pages",
native_unit_of_measurement=UNIT_PAGES, native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -113,7 +106,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="drum_counter", key="drum_counter",
icon="mdi:chart-donut",
translation_key="drum_page_counter", translation_key="drum_page_counter",
native_unit_of_measurement=UNIT_PAGES, native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -122,7 +114,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="black_drum_remaining_life", key="black_drum_remaining_life",
icon="mdi:chart-donut",
translation_key="black_drum_remaining_life", translation_key="black_drum_remaining_life",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -131,7 +122,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="black_drum_remaining_pages", key="black_drum_remaining_pages",
icon="mdi:chart-donut",
translation_key="black_drum_remaining_pages", translation_key="black_drum_remaining_pages",
native_unit_of_measurement=UNIT_PAGES, native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -140,7 +130,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="black_drum_counter", key="black_drum_counter",
icon="mdi:chart-donut",
translation_key="black_drum_page_counter", translation_key="black_drum_page_counter",
native_unit_of_measurement=UNIT_PAGES, native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -149,7 +138,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="cyan_drum_remaining_life", key="cyan_drum_remaining_life",
icon="mdi:chart-donut",
translation_key="cyan_drum_remaining_life", translation_key="cyan_drum_remaining_life",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -158,7 +146,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="cyan_drum_remaining_pages", key="cyan_drum_remaining_pages",
icon="mdi:chart-donut",
translation_key="cyan_drum_remaining_pages", translation_key="cyan_drum_remaining_pages",
native_unit_of_measurement=UNIT_PAGES, native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -167,7 +154,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="cyan_drum_counter", key="cyan_drum_counter",
icon="mdi:chart-donut",
translation_key="cyan_drum_page_counter", translation_key="cyan_drum_page_counter",
native_unit_of_measurement=UNIT_PAGES, native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -176,7 +162,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="magenta_drum_remaining_life", key="magenta_drum_remaining_life",
icon="mdi:chart-donut",
translation_key="magenta_drum_remaining_life", translation_key="magenta_drum_remaining_life",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -185,7 +170,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="magenta_drum_remaining_pages", key="magenta_drum_remaining_pages",
icon="mdi:chart-donut",
translation_key="magenta_drum_remaining_pages", translation_key="magenta_drum_remaining_pages",
native_unit_of_measurement=UNIT_PAGES, native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -194,7 +178,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="magenta_drum_counter", key="magenta_drum_counter",
icon="mdi:chart-donut",
translation_key="magenta_drum_page_counter", translation_key="magenta_drum_page_counter",
native_unit_of_measurement=UNIT_PAGES, native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -203,7 +186,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="yellow_drum_remaining_life", key="yellow_drum_remaining_life",
icon="mdi:chart-donut",
translation_key="yellow_drum_remaining_life", translation_key="yellow_drum_remaining_life",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -212,7 +194,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="yellow_drum_remaining_pages", key="yellow_drum_remaining_pages",
icon="mdi:chart-donut",
translation_key="yellow_drum_remaining_pages", translation_key="yellow_drum_remaining_pages",
native_unit_of_measurement=UNIT_PAGES, native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -221,7 +202,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="yellow_drum_counter", key="yellow_drum_counter",
icon="mdi:chart-donut",
translation_key="yellow_drum_page_counter", translation_key="yellow_drum_page_counter",
native_unit_of_measurement=UNIT_PAGES, native_unit_of_measurement=UNIT_PAGES,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -230,7 +210,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="belt_unit_remaining_life", key="belt_unit_remaining_life",
icon="mdi:current-ac",
translation_key="belt_unit_remaining_life", translation_key="belt_unit_remaining_life",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -239,7 +218,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="fuser_remaining_life", key="fuser_remaining_life",
icon="mdi:water-outline",
translation_key="fuser_remaining_life", translation_key="fuser_remaining_life",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -248,7 +226,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="laser_remaining_life", key="laser_remaining_life",
icon="mdi:spotlight-beam",
translation_key="laser_remaining_life", translation_key="laser_remaining_life",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -257,7 +234,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="pf_kit_1_remaining_life", key="pf_kit_1_remaining_life",
icon="mdi:printer-3d",
translation_key="pf_kit_1_remaining_life", translation_key="pf_kit_1_remaining_life",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -266,7 +242,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="pf_kit_mp_remaining_life", key="pf_kit_mp_remaining_life",
icon="mdi:printer-3d",
translation_key="pf_kit_mp_remaining_life", translation_key="pf_kit_mp_remaining_life",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -275,7 +250,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="black_toner_remaining", key="black_toner_remaining",
icon="mdi:printer-3d-nozzle",
translation_key="black_toner_remaining", translation_key="black_toner_remaining",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -284,7 +258,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="cyan_toner_remaining", key="cyan_toner_remaining",
icon="mdi:printer-3d-nozzle",
translation_key="cyan_toner_remaining", translation_key="cyan_toner_remaining",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -293,7 +266,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="magenta_toner_remaining", key="magenta_toner_remaining",
icon="mdi:printer-3d-nozzle",
translation_key="magenta_toner_remaining", translation_key="magenta_toner_remaining",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -302,7 +274,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="yellow_toner_remaining", key="yellow_toner_remaining",
icon="mdi:printer-3d-nozzle",
translation_key="yellow_toner_remaining", translation_key="yellow_toner_remaining",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -311,7 +282,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="black_ink_remaining", key="black_ink_remaining",
icon="mdi:printer-3d-nozzle",
translation_key="black_ink_remaining", translation_key="black_ink_remaining",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -320,7 +290,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="cyan_ink_remaining", key="cyan_ink_remaining",
icon="mdi:printer-3d-nozzle",
translation_key="cyan_ink_remaining", translation_key="cyan_ink_remaining",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -329,7 +298,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="magenta_ink_remaining", key="magenta_ink_remaining",
icon="mdi:printer-3d-nozzle",
translation_key="magenta_ink_remaining", translation_key="magenta_ink_remaining",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
@@ -338,7 +306,6 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
), ),
BrotherSensorEntityDescription( BrotherSensorEntityDescription(
key="yellow_ink_remaining", key="yellow_ink_remaining",
icon="mdi:printer-3d-nozzle",
translation_key="yellow_ink_remaining", translation_key="yellow_ink_remaining",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,

View File

@@ -128,7 +128,7 @@ class BuienradarCam(Camera):
_LOGGER.debug("HTTP 200 - Last-Modified: %s", last_modified) _LOGGER.debug("HTTP 200 - Last-Modified: %s", last_modified)
return True return True
except (asyncio.TimeoutError, aiohttp.ClientError) as err: except (TimeoutError, aiohttp.ClientError) as err:
_LOGGER.error("Failed to fetch image, %s", type(err)) _LOGGER.error("Failed to fetch image, %s", type(err))
return False return False

View File

@@ -1,5 +1,4 @@
"""Shared utilities for different supported platforms.""" """Shared utilities for different supported platforms."""
import asyncio
from asyncio import timeout from asyncio import timeout
from datetime import datetime, timedelta from datetime import datetime, timedelta
from http import HTTPStatus from http import HTTPStatus
@@ -104,7 +103,7 @@ class BrData:
result[MESSAGE] = "Got http statuscode: %d" % (resp.status) result[MESSAGE] = "Got http statuscode: %d" % (resp.status)
return result return result
except (asyncio.TimeoutError, aiohttp.ClientError) as err: except (TimeoutError, aiohttp.ClientError) as err:
result[MESSAGE] = str(err) result[MESSAGE] = str(err)
return result return result
finally: finally:

View File

@@ -181,7 +181,7 @@ async def _async_get_image(
that we can scale, however the majority of cases that we can scale, however the majority of cases
are handled. are handled.
""" """
with suppress(asyncio.CancelledError, asyncio.TimeoutError): with suppress(asyncio.CancelledError, TimeoutError):
async with asyncio.timeout(timeout): async with asyncio.timeout(timeout):
image_bytes = ( image_bytes = (
await _async_get_stream_image( await _async_get_stream_image(
@@ -891,7 +891,7 @@ async def ws_camera_stream(
except HomeAssistantError as ex: except HomeAssistantError as ex:
_LOGGER.error("Error requesting stream: %s", ex) _LOGGER.error("Error requesting stream: %s", ex)
connection.send_error(msg["id"], "start_stream_failed", str(ex)) connection.send_error(msg["id"], "start_stream_failed", str(ex))
except asyncio.TimeoutError: except TimeoutError:
_LOGGER.error("Timeout getting stream source") _LOGGER.error("Timeout getting stream source")
connection.send_error( connection.send_error(
msg["id"], "start_stream_failed", "Timeout getting stream source" 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: except (HomeAssistantError, ValueError) as ex:
_LOGGER.error("Error handling WebRTC offer: %s", ex) _LOGGER.error("Error handling WebRTC offer: %s", ex)
connection.send_error(msg["id"], "web_rtc_offer_failed", str(ex)) connection.send_error(msg["id"], "web_rtc_offer_failed", str(ex))
except asyncio.TimeoutError: except TimeoutError:
_LOGGER.error("Timeout handling WebRTC offer") _LOGGER.error("Timeout handling WebRTC offer")
connection.send_error( connection.send_error(
msg["id"], "web_rtc_offer_failed", "Timeout handling WebRTC offer" msg["id"], "web_rtc_offer_failed", "Timeout handling WebRTC offer"

View File

@@ -1,9 +1,7 @@
"""Consts for Cast integration.""" """Consts for Cast integration."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, TypedDict
from pychromecast.controllers.homeassistant import HomeAssistantController
from homeassistant.helpers.dispatcher import SignalType 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. # Dispatcher signal fired when a Chromecast should show a Home Assistant Cast view.
SIGNAL_HASS_CAST_SHOW_VIEW: SignalType[ SIGNAL_HASS_CAST_SHOW_VIEW: SignalType[
HomeAssistantController, str, str, str | None HomeAssistantControllerData, str, str, str | None
] = SignalType("cast_show_view") ] = SignalType("cast_show_view")
CONF_IGNORE_CEC = "ignore_cec" CONF_IGNORE_CEC = "ignore_cec"
CONF_KNOWN_HOSTS = "known_hosts" 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

View File

@@ -1,7 +1,6 @@
"""Helpers to deal with Cast devices.""" """Helpers to deal with Cast devices."""
from __future__ import annotations from __future__ import annotations
import asyncio
import configparser import configparser
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
@@ -183,10 +182,10 @@ class CastStatusListener(
if self._valid: if self._valid:
self._cast_device.new_media_status(status) 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.""" """Handle reception of a new MediaStatus."""
if self._valid: 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): def new_connection_status(self, status):
"""Handle reception of a new ConnectionStatus.""" """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) playlist_data = (await resp.content.read(64 * 1024)).decode(charset)
except ValueError as err: except ValueError as err:
raise PlaylistError(f"Could not decode playlist {url}") from 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 raise PlaylistError(f"Timeout while fetching playlist {url}") from err
except aiohttp.client_exceptions.ClientError as err: except aiohttp.client_exceptions.ClientError as err:
raise PlaylistError(f"Error while fetching playlist {url}") from err raise PlaylistError(f"Error while fetching playlist {url}") from err

View File

@@ -1,7 +1,6 @@
"""Home Assistant Cast integration for Cast.""" """Home Assistant Cast integration for Cast."""
from __future__ import annotations from __future__ import annotations
from pychromecast.controllers.homeassistant import HomeAssistantController
import voluptuous as vol import voluptuous as vol
from homeassistant import auth, config_entries, core 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.network import NoURLAvailableError, get_url
from homeassistant.helpers.service import async_register_admin_service 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" SERVICE_SHOW_VIEW = "show_lovelace_view"
ATTR_VIEW_PATH = "view_path" ATTR_VIEW_PATH = "view_path"
@@ -55,7 +54,7 @@ async def async_setup_ha_cast(
hass_uuid = await instance_id.async_get(hass) hass_uuid = await instance_id.async_get(hass)
controller = HomeAssistantController( controller_data = HomeAssistantControllerData(
# If you are developing Home Assistant Cast, uncomment and set to # If you are developing Home Assistant Cast, uncomment and set to
# your dev app id. # your dev app id.
# app_id="5FE44367", # app_id="5FE44367",
@@ -68,7 +67,7 @@ async def async_setup_ha_cast(
dispatcher.async_dispatcher_send( dispatcher.async_dispatcher_send(
hass, hass,
SIGNAL_HASS_CAST_SHOW_VIEW, SIGNAL_HASS_CAST_SHOW_VIEW,
controller, controller_data,
call.data[ATTR_ENTITY_ID], call.data[ATTR_ENTITY_ID],
call.data[ATTR_VIEW_PATH], call.data[ATTR_VIEW_PATH],
call.data.get(ATTR_URL_PATH), call.data.get(ATTR_URL_PATH),

View File

@@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/cast", "documentation": "https://www.home-assistant.io/integrations/cast",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["casttube", "pychromecast"], "loggers": ["casttube", "pychromecast"],
"requirements": ["PyChromecast==13.1.0"], "requirements": ["PyChromecast==14.0.0"],
"zeroconf": ["_googlecast._tcp.local."] "zeroconf": ["_googlecast._tcp.local."]
} }

View File

@@ -61,6 +61,7 @@ from .const import (
SIGNAL_CAST_DISCOVERED, SIGNAL_CAST_DISCOVERED,
SIGNAL_CAST_REMOVED, SIGNAL_CAST_REMOVED,
SIGNAL_HASS_CAST_SHOW_VIEW, SIGNAL_HASS_CAST_SHOW_VIEW,
HomeAssistantControllerData,
) )
from .discovery import setup_internal_discovery from .discovery import setup_internal_discovery
from .helpers import ( from .helpers import (
@@ -389,15 +390,15 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
self.media_status_received = dt_util.utcnow() self.media_status_received = dt_util.utcnow()
self.schedule_update_ha_state() 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.""" """Handle load media failed."""
_LOGGER.debug( _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.entity_id,
self._cast_info.friendly_name, self._cast_info.friendly_name,
error_code, error_code,
MEDIA_PLAYER_ERROR_CODES.get(error_code, "unknown code"), MEDIA_PLAYER_ERROR_CODES.get(error_code, "unknown code"),
item, queue_item_id,
) )
def new_connection_status(self, connection_status): def new_connection_status(self, connection_status):
@@ -951,7 +952,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
def _handle_signal_show_view( def _handle_signal_show_view(
self, self,
controller: HomeAssistantController, controller_data: HomeAssistantControllerData,
entity_id: str, entity_id: str,
view_path: str, view_path: str,
url_path: str | None, url_path: str | None,
@@ -961,6 +962,23 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
return return
if self._hass_cast_controller is None: 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._hass_cast_controller = controller
self._chromecast.register_handler(controller) self._chromecast.register_handler(controller)

View File

@@ -64,8 +64,11 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
ClimateEntityFeature.TARGET_TEMPERATURE ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.SWING_MODE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
) )
_attr_name = None _attr_name = None
_enable_turn_on_off_backwards_compatibility = False
def __init__( def __init__(
self, ac_host: str, ac_index: int, coordinator: CCM15Coordinator self, ac_host: str, ac_index: int, coordinator: CCM15Coordinator

View File

@@ -55,7 +55,7 @@ async def get_cert_expiry_timestamp(
cert = await async_get_cert(hass, hostname, port) cert = await async_get_cert(hass, hostname, port)
except socket.gaierror as err: except socket.gaierror as err:
raise ResolveFailed(f"Cannot resolve hostname: {hostname}") from err raise ResolveFailed(f"Cannot resolve hostname: {hostname}") from err
except asyncio.TimeoutError as err: except TimeoutError as err:
raise ConnectionTimeout( raise ConnectionTimeout(
f"Connection timeout with server: {hostname}:{port}" f"Connection timeout with server: {hostname}:{port}"
) from err ) from err

View File

@@ -144,7 +144,7 @@ async def async_citybikes_request(hass, uri, schema):
json_response = await req.json() json_response = await req.json()
return schema(json_response) return schema(json_response)
except (asyncio.TimeoutError, aiohttp.ClientError): except (TimeoutError, aiohttp.ClientError):
_LOGGER.error("Could not connect to CityBikes API endpoint") _LOGGER.error("Could not connect to CityBikes API endpoint")
except ValueError: except ValueError:
_LOGGER.error("Received non-JSON data from CityBikes API endpoint") _LOGGER.error("Received non-JSON data from CityBikes API endpoint")

View File

@@ -339,9 +339,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def _report_turn_on_off(feature: str, method: str) -> None: def _report_turn_on_off(feature: str, method: str) -> None:
"""Log warning not implemented turn on/off feature.""" """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() report_issue = self._suggest_report_issue()
if feature.startswith("TURN"): if feature.startswith("TURN"):
message = ( message = (

View File

@@ -1,4 +1,5 @@
"""Intents for the client integration.""" """Intents for the client integration."""
from __future__ import annotations from __future__ import annotations
import voluptuous as vol import voluptuous as vol
@@ -36,24 +37,34 @@ class GetTemperatureIntent(intent.IntentHandler):
if not entities: if not entities:
raise intent.IntentHandleError("No climate entities") raise intent.IntentHandleError("No climate entities")
if "area" in slots: name_slot = slots.get("name", {})
# Filter by area entity_name: str | None = name_slot.get("value")
area_name = slots["area"]["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( 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 climate_state = maybe_climate
break break
if climate_state is None: 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) climate_entity = component.get_entity(climate_state.entity_id)
elif "name" in slots: elif entity_name:
# Filter by name # Filter by name
entity_name = slots["name"]["value"]
for maybe_climate in intent.async_match_states( for maybe_climate in intent.async_match_states(
hass, name=entity_name, domains=[DOMAIN] hass, name=entity_name, domains=[DOMAIN]
): ):
@@ -61,7 +72,12 @@ class GetTemperatureIntent(intent.IntentHandler):
break break
if climate_state is None: 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) climate_entity = component.get_entity(climate_state.entity_id)
else: else:

View File

@@ -1,7 +1,6 @@
"""Account linking via the cloud.""" """Account linking via the cloud."""
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import datetime from datetime import datetime
import logging import logging
from typing import Any from typing import Any
@@ -69,7 +68,7 @@ async def _get_services(hass: HomeAssistant) -> list[dict[str, Any]]:
try: try:
services = await account_link.async_fetch_available_services(hass.data[DOMAIN]) services = await account_link.async_fetch_available_services(hass.data[DOMAIN])
except (aiohttp.ClientError, asyncio.TimeoutError): except (aiohttp.ClientError, TimeoutError):
return [] return []
hass.data[DATA_SERVICES] = services hass.data[DATA_SERVICES] = services
@@ -114,7 +113,7 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement
try: try:
tokens = await helper.async_get_tokens() tokens = await helper.async_get_tokens()
except asyncio.TimeoutError: except TimeoutError:
_LOGGER.info("Timeout fetching tokens for flow %s", flow_id) _LOGGER.info("Timeout fetching tokens for flow %s", flow_id)
except account_link.AccountLinkException as err: except account_link.AccountLinkException as err:
_LOGGER.info( _LOGGER.info(

View File

@@ -246,21 +246,27 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
await self._prefs.async_update( await self._prefs.async_update(
alexa_settings_version=ALEXA_SETTINGS_VERSION alexa_settings_version=ALEXA_SETTINGS_VERSION
) )
async_listen_entity_updates( self._on_deinitialize.append(
self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated async_listen_entity_updates(
self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated
)
) )
async def on_hass_start(hass: HomeAssistant) -> None: async def on_hass_start(hass: HomeAssistant) -> None:
if self.enabled and ALEXA_DOMAIN not in self.hass.config.components: if self.enabled and ALEXA_DOMAIN not in self.hass.config.components:
await async_setup_component(self.hass, ALEXA_DOMAIN, {}) await async_setup_component(self.hass, ALEXA_DOMAIN, {})
start.async_at_start(self.hass, on_hass_start) self._on_deinitialize.append(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_started(self.hass, on_hass_started))
self._prefs.async_listen_updates(self._async_prefs_updated) self._on_deinitialize.append(
self.hass.bus.async_listen( self._prefs.async_listen_updates(self._async_prefs_updated)
er.EVENT_ENTITY_REGISTRY_UPDATED, )
self._handle_entity_registry_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: def _should_expose_legacy(self, entity_id: str) -> bool:
@@ -505,7 +511,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
return True return True
except asyncio.TimeoutError: except TimeoutError:
_LOGGER.warning("Timeout trying to sync entities to Alexa") _LOGGER.warning("Timeout trying to sync entities to Alexa")
return False return False

View File

@@ -9,7 +9,7 @@ from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
import aiohttp 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 import google_assistant, persistent_notification, webhook
from homeassistant.components.alexa import ( from homeassistant.components.alexa import (
@@ -213,6 +213,10 @@ class CloudClient(Interface):
"""Cleanup some stuff after logout.""" """Cleanup some stuff after logout."""
await self.prefs.async_set_username(None) await self.prefs.async_set_username(None)
if self._alexa_config:
self._alexa_config.async_deinitialize()
self._alexa_config = None
if self._google_config: if self._google_config:
self._google_config.async_deinitialize() self._google_config.async_deinitialize()
self._google_config = None self._google_config = None
@@ -230,6 +234,8 @@ class CloudClient(Interface):
async def async_cloud_connect_update(self, connect: bool) -> None: async def async_cloud_connect_update(self, connect: bool) -> None:
"""Process cloud remote message to client.""" """Process cloud remote message to client."""
if not self._prefs.remote_allow_remote_enable:
raise RemoteActivationNotAllowed
await self._prefs.async_update(remote_enabled=connect) await self._prefs.async_update(remote_enabled=connect)
async def async_cloud_connection_info( async def async_cloud_connection_info(
@@ -238,6 +244,7 @@ class CloudClient(Interface):
"""Process cloud connection info message to client.""" """Process cloud connection info message to client."""
return { return {
"remote": { "remote": {
"can_enable": self._prefs.remote_allow_remote_enable,
"connected": self.cloud.remote.is_connected, "connected": self.cloud.remote.is_connected,
"enabled": self._prefs.remote_enabled, "enabled": self._prefs.remote_enabled,
"instance_domain": self.cloud.remote.instance_domain, "instance_domain": self.cloud.remote.instance_domain,

View File

@@ -30,6 +30,8 @@ PREF_GOOGLE_DEFAULT_EXPOSE = "google_default_expose"
PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version" PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version"
PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version" PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version"
PREF_TTS_DEFAULT_VOICE = "tts_default_voice" 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_TTS_DEFAULT_VOICE = ("en-US", "female")
DEFAULT_DISABLE_2FA = False DEFAULT_DISABLE_2FA = False
DEFAULT_ALEXA_REPORT_STATE = True DEFAULT_ALEXA_REPORT_STATE = True

Some files were not shown because too many files have changed in this diff Show More