This commit is contained in:
Franck Nijhof 2024-03-06 18:52:11 +01:00 committed by GitHub
commit 1aa5a07501
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2294 changed files with 69142 additions and 17592 deletions

View File

@ -73,6 +73,10 @@ omit =
homeassistant/components/apple_tv/browse_media.py
homeassistant/components/apple_tv/media_player.py
homeassistant/components/apple_tv/remote.py
homeassistant/components/aprilaire/__init__.py
homeassistant/components/aprilaire/climate.py
homeassistant/components/aprilaire/coordinator.py
homeassistant/components/aprilaire/entity.py
homeassistant/components/aqualogic/*
homeassistant/components/aquostv/media_player.py
homeassistant/components/arcam_fmj/__init__.py
@ -89,7 +93,7 @@ omit =
homeassistant/components/aseko_pool_live/entity.py
homeassistant/components/aseko_pool_live/sensor.py
homeassistant/components/asterisk_cdr/mailbox.py
homeassistant/components/asterisk_mbox/*
homeassistant/components/asterisk_mbox/mailbox.py
homeassistant/components/aten_pe/*
homeassistant/components/atome/*
homeassistant/components/aurora/__init__.py
@ -188,6 +192,7 @@ omit =
homeassistant/components/comelit/const.py
homeassistant/components/comelit/cover.py
homeassistant/components/comelit/coordinator.py
homeassistant/components/comelit/humidifier.py
homeassistant/components/comelit/light.py
homeassistant/components/comelit/sensor.py
homeassistant/components/comelit/switch.py
@ -359,7 +364,6 @@ omit =
homeassistant/components/escea/__init__.py
homeassistant/components/escea/climate.py
homeassistant/components/escea/discovery.py
homeassistant/components/esphome/manager.py
homeassistant/components/etherscan/sensor.py
homeassistant/components/eufy/*
homeassistant/components/eufylife_ble/__init__.py
@ -555,6 +559,7 @@ omit =
homeassistant/components/hunterdouglas_powerview/coordinator.py
homeassistant/components/hunterdouglas_powerview/cover.py
homeassistant/components/hunterdouglas_powerview/entity.py
homeassistant/components/hunterdouglas_powerview/number.py
homeassistant/components/hunterdouglas_powerview/select.py
homeassistant/components/hunterdouglas_powerview/sensor.py
homeassistant/components/hunterdouglas_powerview/shade_data.py
@ -634,12 +639,6 @@ omit =
homeassistant/components/izone/climate.py
homeassistant/components/izone/discovery.py
homeassistant/components/joaoapps_join/*
homeassistant/components/juicenet/__init__.py
homeassistant/components/juicenet/device.py
homeassistant/components/juicenet/entity.py
homeassistant/components/juicenet/number.py
homeassistant/components/juicenet/sensor.py
homeassistant/components/juicenet/switch.py
homeassistant/components/justnimbus/coordinator.py
homeassistant/components/justnimbus/entity.py
homeassistant/components/justnimbus/sensor.py
@ -765,6 +764,16 @@ omit =
homeassistant/components/meteoclimatic/__init__.py
homeassistant/components/meteoclimatic/sensor.py
homeassistant/components/meteoclimatic/weather.py
homeassistant/components/microbees/__init__.py
homeassistant/components/microbees/api.py
homeassistant/components/microbees/application_credentials.py
homeassistant/components/microbees/button.py
homeassistant/components/microbees/const.py
homeassistant/components/microbees/coordinator.py
homeassistant/components/microbees/entity.py
homeassistant/components/microbees/light.py
homeassistant/components/microbees/sensor.py
homeassistant/components/microbees/switch.py
homeassistant/components/microsoft/tts.py
homeassistant/components/mikrotik/hub.py
homeassistant/components/mill/climate.py
@ -874,6 +883,7 @@ omit =
homeassistant/components/notion/__init__.py
homeassistant/components/notion/binary_sensor.py
homeassistant/components/notion/sensor.py
homeassistant/components/notion/util.py
homeassistant/components/nsw_fuel_station/sensor.py
homeassistant/components/nuki/__init__.py
homeassistant/components/nuki/binary_sensor.py
@ -1067,6 +1077,7 @@ omit =
homeassistant/components/renson/sensor.py
homeassistant/components/renson/button.py
homeassistant/components/renson/fan.py
homeassistant/components/renson/switch.py
homeassistant/components/renson/binary_sensor.py
homeassistant/components/renson/number.py
homeassistant/components/renson/time.py
@ -1535,6 +1546,7 @@ omit =
homeassistant/components/vicare/entity.py
homeassistant/components/vicare/number.py
homeassistant/components/vicare/sensor.py
homeassistant/components/vicare/types.py
homeassistant/components/vicare/utils.py
homeassistant/components/vicare/water_heater.py
homeassistant/components/vilfo/__init__.py
@ -1572,6 +1584,11 @@ omit =
homeassistant/components/weatherflow/__init__.py
homeassistant/components/weatherflow/const.py
homeassistant/components/weatherflow/sensor.py
homeassistant/components/weatherflow_cloud/__init__.py
homeassistant/components/weatherflow_cloud/const.py
homeassistant/components/weatherflow_cloud/coordinator.py
homeassistant/components/weatherflow_cloud/weather.py
homeassistant/components/webmin/sensor.py
homeassistant/components/wiffi/__init__.py
homeassistant/components/wiffi/binary_sensor.py
homeassistant/components/wiffi/sensor.py
@ -1695,6 +1712,7 @@ omit =
homeassistant/components/myuplink/application_credentials.py
homeassistant/components/myuplink/coordinator.py
homeassistant/components/myuplink/entity.py
homeassistant/components/myuplink/helpers.py
homeassistant/components/myuplink/sensor.py

View File

@ -103,7 +103,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@v3.0.0
uses: dawidd6/action-download-artifact@v3.1.2
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@ -114,7 +114,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@v3.0.0
uses: dawidd6/action-download-artifact@v3.1.2
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/intents-package
@ -341,7 +341,7 @@ jobs:
uses: actions/checkout@v4.1.1
- name: Install Cosign
uses: sigstore/cosign-installer@v3.3.0
uses: sigstore/cosign-installer@v3.4.0
with:
cosign-release: "v2.0.2"

View File

@ -36,7 +36,7 @@ env:
CACHE_VERSION: 5
PIP_CACHE_VERSION: 4
MYPY_CACHE_VERSION: 7
HA_SHORT_VERSION: "2024.2"
HA_SHORT_VERSION: "2024.3"
DEFAULT_PYTHON: "3.11"
ALL_PYTHON_VERSIONS: "['3.11', '3.12']"
# 10.3 is the oldest supported version
@ -103,7 +103,7 @@ jobs:
echo "key=pre-commit-${{ env.CACHE_VERSION }}-${{
hashFiles('.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
- name: Filter for core changes
uses: dorny/paths-filter@v3.0.0
uses: dorny/paths-filter@v3.0.1
id: core
with:
filters: .core_files.yaml
@ -118,7 +118,7 @@ jobs:
echo "Result:"
cat .integration_paths.yaml
- name: Filter for integration changes
uses: dorny/paths-filter@v3.0.0
uses: dorny/paths-filter@v3.0.1
id: integrations
with:
filters: .integration_paths.yaml
@ -803,10 +803,11 @@ jobs:
path: pytest-*.txt
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v3.1.2
uses: actions/upload-artifact@v4.3.1
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
overwrite: true
- name: Check dirty
run: |
./script/check_dirty
@ -928,11 +929,12 @@ jobs:
path: pytest-*.txt
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v3.1.2
uses: actions/upload-artifact@v4.3.1
with:
name: coverage-${{ matrix.python-version }}-mariadb-${{
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
path: coverage.xml
overwrite: true
- name: Check dirty
run: |
./script/check_dirty
@ -1055,11 +1057,12 @@ jobs:
path: pytest-*.txt
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v3.1.0
uses: actions/upload-artifact@v4.3.1
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
path: coverage.xml
overwrite: true
- name: Check dirty
run: |
./script/check_dirty
@ -1076,10 +1079,12 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.1.1
- name: Download all coverage artifacts
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4.1.3
with:
pattern: coverage-*
- name: Upload coverage to Codecov (full coverage)
if: needs.info.outputs.test_full_suite == 'true'
uses: Wandalen/wretry.action@v1.3.0
uses: Wandalen/wretry.action@v1.4.4
with:
action: codecov/codecov-action@v3.1.3
with: |
@ -1090,7 +1095,7 @@ jobs:
attempt_delay: 30000
- name: Upload coverage to Codecov (partial coverage)
if: needs.info.outputs.test_full_suite == 'false'
uses: Wandalen/wretry.action@v1.3.0
uses: Wandalen/wretry.action@v1.4.4
with:
action: codecov/codecov-action@v3.1.3
with: |

View File

@ -2,11 +2,6 @@ name: "CodeQL"
# yamllint disable-line rule:truthy
on:
push:
branches:
- dev
- rc
- master
schedule:
- cron: "30 18 * * 4"
@ -29,11 +24,11 @@ jobs:
uses: actions/checkout@v4.1.1
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.23.2
uses: github/codeql-action/init@v3.24.5
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.23.2
uses: github/codeql-action/analyze@v3.24.5
with:
category: "/language:python"

View File

@ -63,16 +63,18 @@ jobs:
) > .env_file
- name: Upload env_file
uses: actions/upload-artifact@v3.1.2
uses: actions/upload-artifact@v4.3.1
with:
name: env_file
path: ./.env_file
overwrite: true
- name: Upload requirements_diff
uses: actions/upload-artifact@v3.1.2
uses: actions/upload-artifact@v4.3.1
with:
name: requirements_diff
path: ./requirements_diff.txt
overwrite: true
core:
name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2)
@ -82,19 +84,19 @@ jobs:
strategy:
fail-fast: false
matrix:
abi: ["cp311", "cp312"]
abi: ["cp312"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
uses: actions/checkout@v4.1.1
- name: Download env_file
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4.1.3
with:
name: env_file
- name: Download requirements_diff
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4.1.3
with:
name: requirements_diff
@ -120,19 +122,19 @@ jobs:
strategy:
fail-fast: false
matrix:
abi: ["cp311", "cp312"]
abi: ["cp312"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
uses: actions/checkout@v4.1.1
- name: Download env_file
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4.1.3
with:
name: env_file
- name: Download requirements_diff
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4.1.3
with:
name: requirements_diff

View File

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

View File

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

View File

@ -104,6 +104,8 @@ build.json @home-assistant/supervisor
/tests/components/application_credentials/ @home-assistant/core
/homeassistant/components/apprise/ @caronc
/tests/components/apprise/ @caronc
/homeassistant/components/aprilaire/ @chamberlain2007
/tests/components/aprilaire/ @chamberlain2007
/homeassistant/components/aprs/ @PhilRW
/tests/components/aprs/ @PhilRW
/homeassistant/components/aranet/ @aschmitz @thecode
@ -157,8 +159,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/binary_sensor/ @home-assistant/core
/tests/components/binary_sensor/ @home-assistant/core
/homeassistant/components/bizkaibus/ @UgaitzEtxebarria
/homeassistant/components/blebox/ @bbx-a @riokuu
/tests/components/blebox/ @bbx-a @riokuu
/homeassistant/components/blebox/ @bbx-a @riokuu @swistakm
/tests/components/blebox/ @bbx-a @riokuu @swistakm
/homeassistant/components/blink/ @fronzbot @mkmer
/tests/components/blink/ @fronzbot @mkmer
/homeassistant/components/blue_current/ @Floris272 @gleeuwen
@ -329,8 +331,8 @@ build.json @home-assistant/supervisor
/tests/components/ecoforest/ @pjanuario
/homeassistant/components/econet/ @w1ll1am23
/tests/components/econet/ @w1ll1am23
/homeassistant/components/ecovacs/ @OverloadUT @mib1185 @edenhaus
/tests/components/ecovacs/ @OverloadUT @mib1185 @edenhaus
/homeassistant/components/ecovacs/ @OverloadUT @mib1185 @edenhaus @Augar
/tests/components/ecovacs/ @OverloadUT @mib1185 @edenhaus @Augar
/homeassistant/components/ecowitt/ @pvizeli
/tests/components/ecowitt/ @pvizeli
/homeassistant/components/efergy/ @tkdrob
@ -584,6 +586,8 @@ build.json @home-assistant/supervisor
/tests/components/humidifier/ @home-assistant/core @Shulyaka
/homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
/homeassistant/components/husqvarna_automower/ @Thomas55555
/tests/components/husqvarna_automower/ @Thomas55555
/homeassistant/components/huum/ @frwickst
/tests/components/huum/ @frwickst
/homeassistant/components/hvv_departures/ @vigonotion
@ -665,8 +669,6 @@ build.json @home-assistant/supervisor
/tests/components/jellyfin/ @j-stienstra @ctalkington
/homeassistant/components/jewish_calendar/ @tsvi
/tests/components/jewish_calendar/ @tsvi
/homeassistant/components/juicenet/ @jesserockz
/tests/components/juicenet/ @jesserockz
/homeassistant/components/justnimbus/ @kvanzuijlen
/tests/components/justnimbus/ @kvanzuijlen
/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi
@ -766,8 +768,8 @@ build.json @home-assistant/supervisor
/tests/components/lupusec/ @majuss @suaveolent
/homeassistant/components/lutron/ @cdheiser @wilburCForce
/tests/components/lutron/ @cdheiser @wilburCForce
/homeassistant/components/lutron_caseta/ @swails @bdraco @danaues
/tests/components/lutron_caseta/ @swails @bdraco @danaues
/homeassistant/components/lutron_caseta/ @swails @bdraco @danaues @eclair4151
/tests/components/lutron_caseta/ @swails @bdraco @danaues @eclair4151
/homeassistant/components/lyric/ @timmo001
/tests/components/lyric/ @timmo001
/homeassistant/components/mastodon/ @fabaff
@ -801,6 +803,8 @@ build.json @home-assistant/supervisor
/tests/components/meteoclimatic/ @adrianmo
/homeassistant/components/metoffice/ @MrHarcombe @avee87
/tests/components/metoffice/ @MrHarcombe @avee87
/homeassistant/components/microbees/ @microBeesTech
/tests/components/microbees/ @microBeesTech
/homeassistant/components/mikrotik/ @engrbm87
/tests/components/mikrotik/ @engrbm87
/homeassistant/components/mill/ @danielhiversen
@ -848,8 +852,8 @@ build.json @home-assistant/supervisor
/tests/components/mysensors/ @MartinHjelmare @functionpointer
/homeassistant/components/mystrom/ @fabaff
/tests/components/mystrom/ @fabaff
/homeassistant/components/myuplink/ @pajzo
/tests/components/myuplink/ @pajzo
/homeassistant/components/myuplink/ @pajzo @astrandb
/tests/components/myuplink/ @pajzo @astrandb
/homeassistant/components/nam/ @bieniu
/tests/components/nam/ @bieniu
/homeassistant/components/nanoleaf/ @milanmeu
@ -967,8 +971,8 @@ build.json @home-assistant/supervisor
/tests/components/otbr/ @home-assistant/core
/homeassistant/components/ourgroceries/ @OnFreund
/tests/components/ourgroceries/ @OnFreund
/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev
/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev
/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117
/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117
/homeassistant/components/ovo_energy/ @timmo001
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas
@ -1125,8 +1129,8 @@ build.json @home-assistant/supervisor
/tests/components/roku/ @ctalkington
/homeassistant/components/romy/ @xeniter
/tests/components/romy/ @xeniter
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1 @Orhideous
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1 @Orhideous
/homeassistant/components/roon/ @pavoni
/tests/components/roon/ @pavoni
/homeassistant/components/rpi_power/ @shenxn @swetoast
@ -1452,13 +1456,14 @@ build.json @home-assistant/supervisor
/tests/components/v2c/ @dgomes
/homeassistant/components/vacuum/ @home-assistant/core
/tests/components/vacuum/ @home-assistant/core
/homeassistant/components/vallox/ @andre-richter @slovdahl @viiru-
/tests/components/vallox/ @andre-richter @slovdahl @viiru-
/homeassistant/components/vallox/ @andre-richter @slovdahl @viiru- @yozik04
/tests/components/vallox/ @andre-richter @slovdahl @viiru- @yozik04
/homeassistant/components/valve/ @home-assistant/core
/tests/components/valve/ @home-assistant/core
/homeassistant/components/velbus/ @Cereal2nd @brefra
/tests/components/velbus/ @Cereal2nd @brefra
/homeassistant/components/velux/ @Julius2342
/homeassistant/components/velux/ @Julius2342 @DeerMaximum
/tests/components/velux/ @Julius2342 @DeerMaximum
/homeassistant/components/venstar/ @garbled1 @jhollowe
/tests/components/venstar/ @garbled1 @jhollowe
/homeassistant/components/versasense/ @imstevenxyz
@ -1504,10 +1509,14 @@ build.json @home-assistant/supervisor
/tests/components/weather/ @home-assistant/core
/homeassistant/components/weatherflow/ @natekspencer @jeeftor
/tests/components/weatherflow/ @natekspencer @jeeftor
/homeassistant/components/weatherflow_cloud/ @jeeftor
/tests/components/weatherflow_cloud/ @jeeftor
/homeassistant/components/weatherkit/ @tjhorner
/tests/components/weatherkit/ @tjhorner
/homeassistant/components/webhook/ @home-assistant/core
/tests/components/webhook/ @home-assistant/core
/homeassistant/components/webmin/ @autinerd
/tests/components/webmin/ @autinerd
/homeassistant/components/webostv/ @thecode
/tests/components/webostv/ @thecode
/homeassistant/components/websocket_api/ @home-assistant/core

View File

@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant
build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.02.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.02.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.02.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.02.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.02.0
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.02.1
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.02.1
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.02.1
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.02.1
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.02.1
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io

View File

@ -3,9 +3,10 @@ from __future__ import annotations
import asyncio
import contextlib
from datetime import datetime, timedelta
from datetime import timedelta
import logging
import logging.handlers
from operator import itemgetter
import os
import platform
import sys
@ -13,13 +14,28 @@ import threading
from time import monotonic
from typing import TYPE_CHECKING, Any
# Import cryptography early since import openssl is not thread-safe
# _frozen_importlib._DeadlockError: deadlock detected by _ModuleLock('cryptography.hazmat.backends.openssl.backend')
import cryptography.hazmat.backends.openssl.backend # noqa: F401
import voluptuous as vol
import yarl
from . import config as conf_util, config_entries, core, loader, requirements
from .components import http
# Pre-import config and lovelace which have no requirements here to avoid
# loading them at run time and blocking the event loop. We do this ahead
# of time so that we do not have to flag frontends deps with `import_executor`
# as it would create a thundering heard of executor jobs trying to import
# frontend deps at the same time.
from .components import (
api as api_pre_import, # noqa: F401
config as config_pre_import, # noqa: F401
http,
lovelace as lovelace_pre_import, # noqa: F401
)
from .const import (
FORMAT_DATETIME,
KEY_DATA_LOGGING as DATA_LOGGING,
REQUIRED_NEXT_PYTHON_HA_RELEASE,
REQUIRED_NEXT_PYTHON_VER,
SIGNAL_BOOTSTRAP_INTEGRATIONS,
@ -31,21 +47,25 @@ from .helpers import (
device_registry,
entity,
entity_registry,
floor_registry,
issue_registry,
label_registry,
recorder,
restore_state,
template,
translation,
)
from .helpers.dispatcher import async_dispatcher_send
from .helpers.typing import ConfigType
from .setup import (
BASE_PLATFORMS,
DATA_SETUP_STARTED,
DATA_SETUP_TIME,
async_notify_setup_error,
async_set_domains_to_be_loaded,
async_setup_component,
)
from .util import dt as dt_util
from .util.async_ import create_eager_task
from .util.logging import async_activate_log_queue_handler
from .util.package import async_get_user_site, is_virtual_env
@ -57,7 +77,6 @@ _LOGGER = logging.getLogger(__name__)
ERROR_LOG_FILENAME = "home-assistant.log"
# hass.data key for logging information.
DATA_LOGGING = "logging"
DATA_REGISTRIES_LOADED = "bootstrap_registries_loaded"
LOG_SLOW_STARTUP_INTERVAL = 60
@ -110,6 +129,7 @@ DEFAULT_INTEGRATIONS = {
#
# Integrations providing core functionality:
"application_credentials",
"backup",
"frontend",
"hardware",
"logger",
@ -143,15 +163,22 @@ DEFAULT_INTEGRATIONS_SUPERVISOR = {
# These integrations are set up if using the Supervisor
"hassio",
}
DEFAULT_INTEGRATIONS_NON_SUPERVISOR = {
# These integrations are set up if not using the Supervisor
"backup",
}
CRITICAL_INTEGRATIONS = {
# Recovery mode is activated if these integrations fail to set up
"frontend",
}
SETUP_ORDER = {
# Load logging as soon as possible
"logging": LOGGING_INTEGRATIONS,
# Setup frontend
"frontend": FRONTEND_INTEGRATIONS,
# Setup recorder
"recorder": RECORDER_INTEGRATIONS,
# Start up debuggers. Start these first in case they want to wait.
"debugger": DEBUGGER_INTEGRATIONS,
}
async def async_setup_hass(
runtime_config: RuntimeConfig,
@ -217,7 +244,7 @@ async def async_setup_hass(
)
# Ask integrations to shut down. It's messy but we can't
# do a clean stop without knowing what is broken
with contextlib.suppress(asyncio.TimeoutError):
with contextlib.suppress(TimeoutError):
async with hass.timeout.async_timeout(10):
await hass.async_stop()
@ -291,17 +318,20 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
platform.uname().processor # pylint: disable=expression-not-assigned
# Load the registries and cache the result of platform.uname().processor
translation.async_setup(hass)
entity.async_setup(hass)
template.async_setup(hass)
await asyncio.gather(
area_registry.async_load(hass),
device_registry.async_load(hass),
entity_registry.async_load(hass),
issue_registry.async_load(hass),
create_eager_task(area_registry.async_load(hass)),
create_eager_task(device_registry.async_load(hass)),
create_eager_task(entity_registry.async_load(hass)),
create_eager_task(floor_registry.async_load(hass)),
create_eager_task(issue_registry.async_load(hass)),
create_eager_task(label_registry.async_load(hass)),
hass.async_add_executor_job(_cache_uname_processor),
template.async_load_custom_templates(hass),
restore_state.async_load(hass),
hass.config_entries.async_initialize(),
create_eager_task(template.async_load_custom_templates(hass)),
create_eager_task(restore_state.async_load(hass)),
create_eager_task(hass.config_entries.async_initialize()),
)
@ -324,7 +354,7 @@ async def async_from_config_dict(
if not all(
await asyncio.gather(
*(
async_setup_component(hass, domain, config)
create_eager_task(async_setup_component(hass, domain, config))
for domain in CORE_INTEGRATIONS
)
)
@ -533,42 +563,73 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
# Add domains depending on if the Supervisor is used or not
if "SUPERVISOR" in os.environ:
domains.update(DEFAULT_INTEGRATIONS_SUPERVISOR)
else:
domains.update(DEFAULT_INTEGRATIONS_NON_SUPERVISOR)
return domains
async def _async_watch_pending_setups(hass: core.HomeAssistant) -> None:
"""Periodic log of setups that are pending.
class _WatchPendingSetups:
"""Periodic log and dispatch of setups that are pending."""
def __init__(
self, hass: core.HomeAssistant, setup_started: dict[str, float]
) -> None:
"""Initialize the WatchPendingSetups class."""
self._hass = hass
self._setup_started = setup_started
self._duration_count = 0
self._handle: asyncio.TimerHandle | None = None
self._previous_was_empty = True
self._loop = hass.loop
def _async_watch(self) -> None:
"""Periodic log of setups that are pending."""
now = monotonic()
self._duration_count += SLOW_STARTUP_CHECK_INTERVAL
Pending for longer than LOG_SLOW_STARTUP_INTERVAL.
"""
loop_count = 0
setup_started: dict[str, datetime] = hass.data[DATA_SETUP_STARTED]
previous_was_empty = True
while True:
now = dt_util.utcnow()
remaining_with_setup_started = {
domain: (now - setup_started[domain]).total_seconds()
for domain in setup_started
domain: (now - start_time)
for domain, start_time in self._setup_started.items()
}
_LOGGER.debug("Integration remaining: %s", remaining_with_setup_started)
if remaining_with_setup_started or not previous_was_empty:
async_dispatcher_send(
hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, remaining_with_setup_started
)
previous_was_empty = not remaining_with_setup_started
await asyncio.sleep(SLOW_STARTUP_CHECK_INTERVAL)
loop_count += SLOW_STARTUP_CHECK_INTERVAL
if loop_count >= LOG_SLOW_STARTUP_INTERVAL and setup_started:
self._async_dispatch(remaining_with_setup_started)
if (
self._setup_started
and self._duration_count % LOG_SLOW_STARTUP_INTERVAL == 0
):
# We log every LOG_SLOW_STARTUP_INTERVAL until all integrations are done
# once we take over LOG_SLOW_STARTUP_INTERVAL (60s) to start up
_LOGGER.warning(
"Waiting on integrations to complete setup: %s",
", ".join(setup_started),
", ".join(self._setup_started),
)
loop_count = 0
_LOGGER.debug("Running timeout Zones: %s", hass.timeout.zones)
_LOGGER.debug("Running timeout Zones: %s", self._hass.timeout.zones)
self._async_schedule_next()
def _async_dispatch(self, remaining_with_setup_started: dict[str, float]) -> None:
"""Dispatch the signal."""
if remaining_with_setup_started or not self._previous_was_empty:
async_dispatcher_send(
self._hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, remaining_with_setup_started
)
self._previous_was_empty = not remaining_with_setup_started
def _async_schedule_next(self) -> None:
"""Schedule the next call."""
self._handle = self._loop.call_later(
SLOW_STARTUP_CHECK_INTERVAL, self._async_watch
)
def async_start(self) -> None:
"""Start watching."""
self._async_schedule_next()
def async_stop(self) -> None:
"""Stop watching."""
self._async_dispatch({})
if self._handle:
self._handle.cancel()
self._handle = None
async def async_setup_multi_components(
@ -581,7 +642,9 @@ async def async_setup_multi_components(
domains_not_yet_setup = domains - hass.config.components
futures = {
domain: hass.async_create_task(
async_setup_component(hass, domain, config), f"setup component {domain}"
async_setup_component(hass, domain, config),
f"setup component {domain}",
eager_start=True,
)
for domain in domains_not_yet_setup
}
@ -596,17 +659,12 @@ async def async_setup_multi_components(
)
async def _async_set_up_integrations(
async def _async_resolve_domains_to_setup(
hass: core.HomeAssistant, config: dict[str, Any]
) -> None:
"""Set up all the integrations."""
hass.data[DATA_SETUP_STARTED] = {}
setup_time: dict[str, timedelta] = hass.data.setdefault(DATA_SETUP_TIME, {})
watch_task = asyncio.create_task(_async_watch_pending_setups(hass))
) -> tuple[set[str], dict[str, loader.Integration]]:
"""Resolve all dependencies and return list of domains to set up."""
base_platforms_loaded = False
domains_to_setup = _get_domains(hass, config)
needed_requirements: set[str] = set()
# Resolve all dependencies so we know all integrations
@ -617,48 +675,58 @@ async def _async_set_up_integrations(
old_to_resolve: set[str] = to_resolve
to_resolve = set()
integrations_to_process = [
int_or_exc
for int_or_exc in (
await loader.async_get_integrations(hass, old_to_resolve)
).values()
if isinstance(int_or_exc, loader.Integration)
]
if not base_platforms_loaded:
# Load base platforms right away since
# we do not require the manifest to list
# them as dependencies and we want
# to avoid the lock contention when multiple
# integrations try to resolve them at once
base_platforms_loaded = True
to_get = {*old_to_resolve, *BASE_PLATFORMS}
else:
to_get = old_to_resolve
manifest_deps: set[str] = set()
for itg in integrations_to_process:
resolve_dependencies_tasks: list[asyncio.Task[bool]] = []
integrations_to_process: list[loader.Integration] = []
for domain, itg in (await loader.async_get_integrations(hass, to_get)).items():
if not isinstance(itg, loader.Integration) or domain not in old_to_resolve:
continue
integrations_to_process.append(itg)
integration_cache[domain] = itg
manifest_deps.update(itg.dependencies)
manifest_deps.update(itg.after_dependencies)
needed_requirements.update(itg.requirements)
if not itg.all_dependencies_resolved:
resolve_dependencies_tasks.append(
create_eager_task(
itg.resolve_dependencies(),
name=f"resolve dependencies {domain}",
loop=hass.loop,
)
)
if manifest_deps:
if unseen_deps := manifest_deps - integration_cache.keys():
# If there are dependencies, try to preload all
# the integrations manifest at once and add them
# to the list of requirements we need to install
# so we can try to check if they are already installed
# in a single call below which avoids each integration
# having to wait for the lock to do it individually
deps = await loader.async_get_integrations(hass, manifest_deps)
for dependant_itg in deps.values():
deps = await loader.async_get_integrations(hass, unseen_deps)
for dependant_domain, dependant_itg in deps.items():
if isinstance(dependant_itg, loader.Integration):
integration_cache[dependant_domain] = dependant_itg
needed_requirements.update(dependant_itg.requirements)
resolve_dependencies_tasks = [
itg.resolve_dependencies()
for itg in integrations_to_process
if not itg.all_dependencies_resolved
]
if resolve_dependencies_tasks:
await asyncio.gather(*resolve_dependencies_tasks)
for itg in integrations_to_process:
integration_cache[itg.domain] = itg
for dep in itg.all_dependencies:
if dep in domains_to_setup:
continue
domains_to_setup.add(dep)
to_resolve.add(dep)
@ -670,31 +738,50 @@ async def _async_set_up_integrations(
hass.async_create_background_task(
requirements.async_load_installed_versions(hass, needed_requirements),
"check installed requirements",
eager_start=True,
)
# Start loading translations for all integrations we are going to set up
# in the background so they are ready when we need them. This avoids a
# lot of waiting for the translation load lock and a thundering herd of
# tasks trying to load the same translations at the same time as each
# integration is loaded.
#
# We do not wait for this since as soon as the task runs it will
# hold the translation load lock and if anything is fast enough to
# wait for the translation load lock, loading will be done by the
# time it gets to it.
hass.async_create_background_task(
translation.async_load_integrations(hass, {*BASE_PLATFORMS, *domains_to_setup}),
"load translations",
eager_start=True,
)
return domains_to_setup, integration_cache
async def _async_set_up_integrations(
hass: core.HomeAssistant, config: dict[str, Any]
) -> None:
"""Set up all the integrations."""
setup_started: dict[str, float] = {}
hass.data[DATA_SETUP_STARTED] = setup_started
setup_time: dict[str, timedelta] = hass.data.setdefault(DATA_SETUP_TIME, {})
watcher = _WatchPendingSetups(hass, setup_started)
watcher.async_start()
domains_to_setup, integration_cache = await _async_resolve_domains_to_setup(
hass, config
)
# Initialize recorder
if "recorder" in domains_to_setup:
recorder.async_initialize_recorder(hass)
# Load logging as soon as possible
if logging_domains := domains_to_setup & LOGGING_INTEGRATIONS:
_LOGGER.info("Setting up logging: %s", logging_domains)
await async_setup_multi_components(hass, logging_domains, config)
# Setup frontend
if frontend_domains := domains_to_setup & FRONTEND_INTEGRATIONS:
_LOGGER.info("Setting up frontend: %s", frontend_domains)
await async_setup_multi_components(hass, frontend_domains, config)
# Setup recorder
if recorder_domains := domains_to_setup & RECORDER_INTEGRATIONS:
_LOGGER.info("Setting up recorder: %s", recorder_domains)
await async_setup_multi_components(hass, recorder_domains, config)
# Start up debuggers. Start these first in case they want to wait.
if debuggers := domains_to_setup & DEBUGGER_INTEGRATIONS:
_LOGGER.debug("Setting up debuggers: %s", debuggers)
await async_setup_multi_components(hass, debuggers, config)
pre_stage_domains: dict[str, set[str]] = {
name: domains_to_setup & domain_group
for name, domain_group in SETUP_ORDER.items()
}
# calculate what components to setup in what stage
stage_1_domains: set[str] = set()
@ -718,14 +805,13 @@ async def _async_set_up_integrations(
deps_promotion.update(dep_itg.all_dependencies)
stage_2_domains = (
domains_to_setup
- logging_domains
- frontend_domains
- recorder_domains
- debuggers
- stage_1_domains
)
stage_2_domains = domains_to_setup - stage_1_domains
for name, domain_group in pre_stage_domains.items():
if domain_group:
stage_2_domains -= domain_group
_LOGGER.info("Setting up %s: %s", name, domain_group)
await async_setup_multi_components(hass, domain_group, config)
# Enables after dependencies when setting up stage 1 domains
async_set_domains_to_be_loaded(hass, stage_1_domains)
@ -738,7 +824,7 @@ async def _async_set_up_integrations(
STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME
):
await async_setup_multi_components(hass, stage_1_domains, config)
except asyncio.TimeoutError:
except TimeoutError:
_LOGGER.warning("Setup timed out for stage 1 - moving forward")
# Add after dependencies when setting up stage 2 domains
@ -751,7 +837,7 @@ async def _async_set_up_integrations(
STAGE_2_TIMEOUT, cool_down=COOLDOWN_TIME
):
await async_setup_multi_components(hass, stage_2_domains, config)
except asyncio.TimeoutError:
except TimeoutError:
_LOGGER.warning("Setup timed out for stage 2 - moving forward")
# Wrap up startup
@ -759,18 +845,12 @@ async def _async_set_up_integrations(
try:
async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME):
await hass.async_block_till_done()
except asyncio.TimeoutError:
except TimeoutError:
_LOGGER.warning("Setup timed out for bootstrap - moving forward")
watch_task.cancel()
async_dispatcher_send(hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, {})
watcher.async_stop()
_LOGGER.debug(
"Integration setup times: %s",
{
integration: timedelta.total_seconds()
for integration, timedelta in sorted(
setup_time.items(), key=lambda item: item[1].total_seconds()
)
},
dict(sorted(setup_time.items(), key=itemgetter(1))),
)

View File

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

View File

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

View File

@ -0,0 +1 @@
"""Virtual integration: Acomax."""

View File

@ -0,0 +1,6 @@
{
"domain": "acomax",
"name": "Acomax",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

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

View File

@ -17,7 +17,8 @@ from homeassistant.components.climate import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
@ -49,6 +50,24 @@ ADVANTAGE_AIR_HEAT_TARGET = "myAutoHeatTargetTemp"
ADVANTAGE_AIR_COOL_TARGET = "myAutoCoolTargetTemp"
ADVANTAGE_AIR_MYFAN = "autoAA"
HVAC_MODES = [
HVACMode.OFF,
HVACMode.COOL,
HVACMode.HEAT,
HVACMode.FAN_ONLY,
HVACMode.DRY,
]
HVAC_MODES_MYAUTO = HVAC_MODES + [HVACMode.HEAT_COOL]
SUPPORTED_FEATURES = (
ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
SUPPORTED_FEATURES_MYZONE = SUPPORTED_FEATURES | ClimateEntityFeature.TARGET_TEMPERATURE
SUPPORTED_FEATURES_MYAUTO = (
SUPPORTED_FEATURES | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
@ -84,34 +103,56 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
_attr_min_temp = 16
_attr_name = None
_enable_turn_on_off_backwards_compatibility = False
_support_preset = ClimateEntityFeature(0)
def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize an AdvantageAir AC unit."""
super().__init__(instance, ac_key)
self._attr_supported_features = (
ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
self._attr_hvac_modes = [
HVACMode.OFF,
HVACMode.COOL,
HVACMode.HEAT,
HVACMode.FAN_ONLY,
HVACMode.DRY,
]
# Set supported features and HVAC modes based on current operating mode
self._attr_preset_modes = [ADVANTAGE_AIR_MYZONE]
# Add "MyTemp" preset if available
if ADVANTAGE_AIR_MYTEMP_ENABLED in self._ac:
self._attr_preset_modes += [ADVANTAGE_AIR_MYTEMP]
self._support_preset = ClimateEntityFeature.PRESET_MODE
# Add "MyAuto" preset if available
if ADVANTAGE_AIR_MYAUTO_ENABLED in self._ac:
self._attr_preset_modes += [ADVANTAGE_AIR_MYAUTO]
self._support_preset = ClimateEntityFeature.PRESET_MODE
# Setup attributes based on current preset
self._async_configure_preset()
def _async_configure_preset(self) -> None:
"""Configure attributes based on preset."""
# Preset Changes
if self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED):
# MyAuto
self._attr_supported_features |= (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
self._attr_preset_mode = ADVANTAGE_AIR_MYAUTO
self._attr_hvac_modes = HVAC_MODES_MYAUTO
self._attr_supported_features = (
SUPPORTED_FEATURES_MYAUTO | self._support_preset
)
self._attr_hvac_modes += [HVACMode.HEAT_COOL]
elif not self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED):
elif self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED):
# MyTemp
self._attr_preset_mode = ADVANTAGE_AIR_MYTEMP
self._attr_hvac_modes = HVAC_MODES
self._attr_supported_features = SUPPORTED_FEATURES | self._support_preset
else:
# MyZone
self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE
self._attr_preset_mode = ADVANTAGE_AIR_MYZONE
self._attr_hvac_modes = HVAC_MODES
self._attr_supported_features = (
SUPPORTED_FEATURES_MYZONE | self._support_preset
)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._async_configure_preset()
super()._handle_coordinator_update()
@property
def current_temperature(self) -> float | None:
@ -124,11 +165,7 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
def target_temperature(self) -> float | None:
"""Return the current target temperature."""
# If the system is in MyZone mode, and a zone is set, return that temperature instead.
if (
self._myzone
and not self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED)
and not self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED)
):
if self._myzone and self.preset_mode == ADVANTAGE_AIR_MYZONE:
return self._myzone["setTemp"]
return self._ac["setTemp"]
@ -169,14 +206,15 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC Mode and State."""
if hvac_mode == HVACMode.OFF:
await self.async_update_ac({"state": ADVANTAGE_AIR_STATE_OFF})
else:
await self.async_update_ac(
{
"state": ADVANTAGE_AIR_STATE_ON,
"mode": HASS_HVAC_MODES.get(hvac_mode),
}
)
return await self.async_turn_off()
if hvac_mode == HVACMode.HEAT_COOL and self.preset_mode != ADVANTAGE_AIR_MYAUTO:
raise ServiceValidationError("Heat/Cool is not supported in this mode")
await self.async_update_ac(
{
"state": ADVANTAGE_AIR_STATE_ON,
"mode": HASS_HVAC_MODES.get(hvac_mode),
}
)
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set the Fan Mode."""
@ -198,6 +236,16 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
}
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode."""
change = {}
if ADVANTAGE_AIR_MYTEMP_ENABLED in self._ac:
change[ADVANTAGE_AIR_MYTEMP_ENABLED] = preset_mode == ADVANTAGE_AIR_MYTEMP
if ADVANTAGE_AIR_MYAUTO_ENABLED in self._ac:
change[ADVANTAGE_AIR_MYAUTO_ENABLED] = preset_mode == ADVANTAGE_AIR_MYAUTO
if change:
await self.async_update_ac(change)
class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
"""AdvantageAir MyTemp Zone control."""

View File

@ -31,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
longitude = entry.data[CONF_LONGITUDE]
station_updates = entry.options.get(CONF_STATION_UPDATES, True)
options = ConnectionOptions(api_key, station_updates, True)
options = ConnectionOptions(api_key, station_updates)
aemet = AEMET(aiohttp_client.async_get_clientsession(hass), options)
try:
await aemet.select_coordinates(latitude, longitude)

View File

@ -21,7 +21,7 @@ from .const import CONF_STATION_UPDATES, DEFAULT_NAME, DOMAIN
OPTIONS_SCHEMA = vol.Schema(
{
vol.Required(CONF_STATION_UPDATES): bool,
vol.Required(CONF_STATION_UPDATES, default=True): bool,
}
)
OPTIONS_FLOW = {
@ -45,7 +45,7 @@ class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(f"{latitude}-{longitude}")
self._abort_if_unique_id_configured()
options = ConnectionOptions(user_input[CONF_API_KEY], False, True)
options = ConnectionOptions(user_input[CONF_API_KEY], False)
aemet = AEMET(aiohttp_client.async_get_clientsession(self.hass), options)
try:
await aemet.select_coordinates(latitude, longitude)

View File

@ -20,7 +20,7 @@ from aemet_opendata.const import (
AOD_TEMP,
AOD_TEMP_MAX,
AOD_TEMP_MIN,
AOD_TIMESTAMP,
AOD_TIMESTAMP_UTC,
AOD_WIND_DIRECTION,
AOD_WIND_SPEED,
AOD_WIND_SPEED_MAX,
@ -105,7 +105,7 @@ FORECAST_MAP = {
AOD_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY,
AOD_TEMP_MAX: ATTR_FORECAST_NATIVE_TEMP,
AOD_TEMP_MIN: ATTR_FORECAST_NATIVE_TEMP_LOW,
AOD_TIMESTAMP: ATTR_FORECAST_TIME,
AOD_TIMESTAMP_UTC: ATTR_FORECAST_TIME,
AOD_WIND_DIRECTION: ATTR_FORECAST_WIND_BEARING,
AOD_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED,
},
@ -114,7 +114,7 @@ FORECAST_MAP = {
AOD_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY,
AOD_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION,
AOD_TEMP: ATTR_FORECAST_NATIVE_TEMP,
AOD_TIMESTAMP: ATTR_FORECAST_TIME,
AOD_TIMESTAMP_UTC: ATTR_FORECAST_TIME,
AOD_WIND_DIRECTION: ATTR_FORECAST_WIND_BEARING,
AOD_WIND_SPEED_MAX: ATTR_FORECAST_NATIVE_WIND_GUST_SPEED,
AOD_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED,

View File

@ -0,0 +1,44 @@
"""Support for the AEMET OpenData diagnostics."""
from __future__ import annotations
from typing import Any
from aemet_opendata.const import AOD_COORDS
from homeassistant.components.diagnostics.util import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_API_KEY,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_UNIQUE_ID,
)
from homeassistant.core import HomeAssistant
from .const import DOMAIN, ENTRY_WEATHER_COORDINATOR
from .coordinator import WeatherUpdateCoordinator
TO_REDACT_CONFIG = [
CONF_API_KEY,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_UNIQUE_ID,
]
TO_REDACT_COORD = [
AOD_COORDS,
]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
aemet_entry = hass.data[DOMAIN][config_entry.entry_id]
coordinator: WeatherUpdateCoordinator = aemet_entry[ENTRY_WEATHER_COORDINATOR]
return {
"api_data": coordinator.aemet.raw_data(),
"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT_CONFIG),
"coord_data": async_redact_data(coordinator.data, TO_REDACT_COORD),
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aemet",
"iot_class": "cloud_polling",
"loggers": ["aemet_opendata"],
"requirements": ["AEMET-OpenData==0.4.7"]
"requirements": ["AEMET-OpenData==0.5.1"]
}

View File

@ -27,7 +27,7 @@ from aemet_opendata.const import (
AOD_TEMP,
AOD_TEMP_MAX,
AOD_TEMP_MIN,
AOD_TIMESTAMP,
AOD_TIMESTAMP_UTC,
AOD_TOWN,
AOD_WEATHER,
AOD_WIND_DIRECTION,
@ -171,7 +171,7 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
),
AemetSensorEntityDescription(
key=f"forecast-daily-{ATTR_API_FORECAST_TIME}",
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_TIMESTAMP],
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_TIMESTAMP_UTC],
name="Daily forecast time",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=dt_util.parse_datetime,
@ -179,7 +179,7 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
AemetSensorEntityDescription(
entity_registry_enabled_default=False,
key=f"forecast-hourly-{ATTR_API_FORECAST_TIME}",
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_TIMESTAMP],
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_TIMESTAMP_UTC],
name="Hourly forecast time",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=dt_util.parse_datetime,
@ -286,7 +286,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
),
AemetSensorEntityDescription(
key=ATTR_API_STATION_TIMESTAMP,
keys=[AOD_STATION, AOD_TIMESTAMP],
keys=[AOD_STATION, AOD_TIMESTAMP_UTC],
name="Station timestamp",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=dt_util.parse_datetime,
@ -326,7 +326,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
),
AemetSensorEntityDescription(
key=ATTR_API_TOWN_TIMESTAMP,
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_TIMESTAMP],
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_TIMESTAMP_UTC],
name="Town timestamp",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=dt_util.parse_datetime,

View File

@ -22,8 +22,6 @@ CONF_TRACKING_NUMBER: Final = "tracking_number"
DEFAULT_NAME: Final = "aftership"
UPDATE_TOPIC: Final = f"{DOMAIN}_update"
ICON: Final = "mdi:package-variant-closed"
MIN_TIME_BETWEEN_UPDATES: Final = timedelta(minutes=15)
SERVICE_ADD_TRACKING: Final = "add_tracking"

View File

@ -0,0 +1,13 @@
{
"entity": {
"sensor": {
"packages": {
"default": "mdi:package-variant-closed"
}
}
},
"services": {
"add_tracking": "mdi:package-variant-plus",
"remove_tracking": "mdi:package-variant-minus"
}
}

View File

@ -35,7 +35,6 @@ from .const import (
CONF_TRACKING_NUMBER,
DEFAULT_NAME,
DOMAIN,
ICON,
MIN_TIME_BETWEEN_UPDATES,
REMOVE_TRACKING_SERVICE_SCHEMA,
SERVICE_ADD_TRACKING,
@ -135,7 +134,7 @@ class AfterShipSensor(SensorEntity):
_attr_attribution = ATTRIBUTION
_attr_native_unit_of_measurement: str = "packages"
_attr_icon: str = ICON
_attr_translation_key = "packages"
def __init__(self, aftership: AfterShip, name: str) -> None:
"""Initialize the sensor."""

View File

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

View File

@ -23,6 +23,13 @@ from .const import DOMAIN, MFCT_ID
_LOGGER = logging.getLogger(__name__)
SERVICE_UUIDS = [
"b42e1f6e-ade7-11e4-89d3-123b93f75cba",
"b42e4a8e-ade7-11e4-89d3-123b93f75cba",
"b42e1c08-ade7-11e4-89d3-123b93f75cba",
"b42e3882-ade7-11e4-89d3-123b93f75cba",
]
@dataclasses.dataclass
class Discovery:
@ -147,6 +154,9 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
if MFCT_ID not in discovery_info.manufacturer_data:
continue
if not any(uuid in SERVICE_UUIDS for uuid in discovery_info.service_uuids):
continue
try:
device = await self._get_device_data(discovery_info)
except AirthingsDeviceUpdateError:

View File

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

View File

@ -0,0 +1,12 @@
{
"entity": {
"sensor": {
"pollutant_level": {
"default": "mdi:gauge"
},
"pollutant_label": {
"default": "mdi:chemical-weapon"
}
}
}
}

View File

@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["airvisual_pro"],
"documentation": "https://www.home-assistant.io/integrations/airvisual",
"import_executor": true,
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyairvisual", "pysmb"],

View File

@ -42,7 +42,6 @@ GEOGRAPHY_SENSOR_DESCRIPTIONS = (
SensorEntityDescription(
key=SENSOR_KIND_LEVEL,
name="Air pollution level",
icon="mdi:gauge",
device_class=SensorDeviceClass.ENUM,
options=[
"good",
@ -63,7 +62,6 @@ GEOGRAPHY_SENSOR_DESCRIPTIONS = (
SensorEntityDescription(
key=SENSOR_KIND_POLLUTANT,
name="Main pollutant",
icon="mdi:chemical-weapon",
device_class=SensorDeviceClass.ENUM,
options=["co", "n2", "o3", "p1", "p2", "s2"],
translation_key="pollutant_label",

View File

@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==0.7.4"]
"requirements": ["aioairzone==0.7.6"]
}

View File

@ -24,6 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
options = ConnectionOptions(
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
True,
)
airzone = AirzoneCloudApi(aiohttp_client.async_get_clientsession(hass), options)

View File

@ -7,6 +7,7 @@ from typing import Any, Final
from aioairzone_cloud.const import (
AZD_ACTIVE,
AZD_AIDOOS,
AZD_AQ_ACTIVE,
AZD_ERRORS,
AZD_PROBLEMS,
AZD_SYSTEMS,
@ -76,6 +77,10 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]
device_class=BinarySensorDeviceClass.RUNNING,
key=AZD_ACTIVE,
),
AirzoneBinarySensorEntityDescription(
key=AZD_AQ_ACTIVE,
translation_key="air_quality_active",
),
AirzoneBinarySensorEntityDescription(
attributes={
"warnings": AZD_WARNINGS,

View File

@ -94,6 +94,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
ConnectionOptions(
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
False,
),
)

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"iot_class": "cloud_polling",
"loggers": ["aioairzone_cloud"],
"requirements": ["aioairzone-cloud==0.3.8"]
"requirements": ["aioairzone-cloud==0.4.5"]
}

View File

@ -5,6 +5,10 @@ from typing import Any, Final
from aioairzone_cloud.const import (
AZD_AIDOOS,
AZD_AQ_INDEX,
AZD_AQ_PM_1,
AZD_AQ_PM_2P5,
AZD_AQ_PM_10,
AZD_HUMIDITY,
AZD_TEMP,
AZD_WEBSERVERS,
@ -20,6 +24,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
@ -58,6 +63,29 @@ WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
)
ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
SensorEntityDescription(
device_class=SensorDeviceClass.AQI,
key=AZD_AQ_INDEX,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
device_class=SensorDeviceClass.PM1,
key=AZD_AQ_PM_1,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
device_class=SensorDeviceClass.PM25,
key=AZD_AQ_PM_2P5,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
device_class=SensorDeviceClass.PM10,
key=AZD_AQ_PM_10,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
device_class=SensorDeviceClass.TEMPERATURE,
key=AZD_TEMP,

View File

@ -15,5 +15,12 @@
}
}
}
},
"entity": {
"binary_sensor": {
"air_quality_active": {
"name": "Air Quality active"
}
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,13 @@
{
"entity": {
"sensor": {
"alarm_panel_display": {
"default": "mdi:alarm-check"
}
}
},
"services": {
"alarm_keypress": "mdi:dialpad",
"alarm_toggle_chime": "mdi:abc"
}
}

View File

@ -20,7 +20,7 @@ async def async_setup_entry(
class AlarmDecoderSensor(SensorEntity):
"""Representation of an AlarmDecoder keypad."""
_attr_icon = "mdi:alarm-check"
_attr_translation_key = "alarm_panel_display"
_attr_name = "Alarm Panel Display"
_attr_should_poll = False

View File

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

View File

@ -29,12 +29,20 @@ class AbstractConfig(ABC):
"""Initialize abstract config."""
self.hass = hass
self._enable_proactive_mode_lock = asyncio.Lock()
self._on_deinitialize: list[CALLBACK_TYPE] = []
async def async_initialize(self) -> None:
"""Perform async initialization of config."""
self._store = AlexaConfigStore(self.hass)
await self._store.async_load()
@callback
def async_deinitialize(self) -> None:
"""Remove listeners."""
_LOGGER.debug("async_deinitialize")
while self._on_deinitialize:
self._on_deinitialize.pop()()
@property
def supports_auth(self) -> bool:
"""Return if config supports auth."""

View File

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

View File

@ -0,0 +1,18 @@
{
"entity": {
"sensor": {
"general": {
"default": "mdi:transmission-tower"
},
"controlled_load": {
"default": "mdi:clock-outline"
},
"feed_in": {
"default": "mdi:solar-power"
},
"renewables": {
"default": "mdi:solar-power"
}
}
}
}

View File

@ -27,12 +27,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION, DOMAIN
from .coordinator import AmberUpdateCoordinator, normalize_descriptor
ICONS = {
"general": "mdi:transmission-tower",
"controlled_load": "mdi:clock-outline",
"feed_in": "mdi:solar-power",
}
UNIT = f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}"
@ -219,7 +213,7 @@ async def async_setup_entry(
name=f"{entry.title} - {friendly_channel_type(channel_type)} Price",
native_unit_of_measurement=UNIT,
state_class=SensorStateClass.MEASUREMENT,
icon=ICONS[channel_type],
translation_key=channel_type,
)
entities.append(AmberPriceSensor(coordinator, description, channel_type))
@ -230,7 +224,7 @@ async def async_setup_entry(
f"{entry.title} - {friendly_channel_type(channel_type)} Price"
" Descriptor"
),
icon=ICONS[channel_type],
translation_key=channel_type,
)
entities.append(
AmberPriceDescriptorSensor(coordinator, description, channel_type)
@ -242,7 +236,7 @@ async def async_setup_entry(
name=f"{entry.title} - {friendly_channel_type(channel_type)} Forecast",
native_unit_of_measurement=UNIT,
state_class=SensorStateClass.MEASUREMENT,
icon=ICONS[channel_type],
translation_key=channel_type,
)
entities.append(AmberForecastSensor(coordinator, description, channel_type))
@ -251,7 +245,7 @@ async def async_setup_entry(
name=f"{entry.title} - Renewables",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:solar-power",
translation_key="renewables",
)
entities.append(AmberGridSensor(coordinator, renewables_description))

View File

@ -4,7 +4,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.typing import ConfigType
from . import config_flow
@ -41,5 +41,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Ambiclimate from a config entry."""
ir.async_create_issue(
hass,
DOMAIN,
DOMAIN,
breaks_in_ha_version="2024.4.0",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="integration_removed",
translation_placeholders={
"entries": "/config/integrations/integration/ambiclimate",
},
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

View File

@ -0,0 +1,7 @@
{
"services": {
"set_comfort_mode": "mdi:auto-mode",
"send_comfort_feedback": "mdi:thermometer-checked",
"set_temperature_mode": "mdi:thermometer"
}
}

View File

@ -19,6 +19,12 @@
"access_token": "Unknown error generating an access token."
}
},
"issues": {
"integration_removed": {
"title": "The Ambiclimate integration has been deprecated and will be removed",
"description": "All Ambiclimate services will be terminated, effective March 31, 2024, as Ambi Labs winds down business operations, and the Ambiclimate integration will be removed from Home Assistant.\n\nTo resolve this issue, please remove the integration entries from your Home Assistant setup. [Click here to see your existing Logi Circle integration entries]({entries})."
}
},
"services": {
"set_comfort_mode": {
"name": "Set comfort mode",
@ -40,7 +46,7 @@
},
"value": {
"name": "Comfort value",
"description": "Send any of the following comfort values: too_hot, too_warm, bit_warm, comfortable, bit_cold, too_cold, freezing\n."
"description": "Send any of the following comfort values: too_hot, too_warm, bit_warm, comfortable, bit_cold, too_cold, freezing."
}
}
},

View File

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

View File

@ -0,0 +1 @@
"""Virtual integration: AMP motorization."""

View File

@ -173,6 +173,7 @@ class Analytics:
async def send_analytics(self, _: datetime | None = None) -> None:
"""Send analytics."""
hass = self.hass
supervisor_info = None
operating_system_info: dict[str, Any] = {}
@ -185,10 +186,10 @@ class Analytics:
await self._store.async_save(dataclass_asdict(self._data))
if self.supervisor:
supervisor_info = hassio.get_supervisor_info(self.hass)
operating_system_info = hassio.get_os_info(self.hass) or {}
supervisor_info = hassio.get_supervisor_info(hass)
operating_system_info = hassio.get_os_info(hass) or {}
system_info = await async_get_system_info(self.hass)
system_info = await async_get_system_info(hass)
integrations = []
custom_integrations = []
addons = []
@ -214,10 +215,10 @@ class Analytics:
if self.preferences.get(ATTR_USAGE, False) or self.preferences.get(
ATTR_STATISTICS, False
):
ent_reg = er.async_get(self.hass)
ent_reg = er.async_get(hass)
try:
yaml_configuration = await conf_util.async_hass_config_yaml(self.hass)
yaml_configuration = await conf_util.async_hass_config_yaml(hass)
except HomeAssistantError as err:
LOGGER.error(err)
return
@ -229,8 +230,8 @@ class Analytics:
if not entity.disabled
}
domains = async_get_loaded_integrations(self.hass)
configured_integrations = await async_get_integrations(self.hass, domains)
domains = async_get_loaded_integrations(hass)
configured_integrations = await async_get_integrations(hass, domains)
enabled_domains = set(configured_integrations)
for integration in configured_integrations.values():
@ -261,7 +262,7 @@ class Analytics:
if supervisor_info is not None:
installed_addons = await asyncio.gather(
*(
hassio.async_get_addon_info(self.hass, addon[ATTR_SLUG])
hassio.async_get_addon_info(hass, addon[ATTR_SLUG])
for addon in supervisor_info[ATTR_ADDONS]
)
)
@ -276,7 +277,7 @@ class Analytics:
)
if self.preferences.get(ATTR_USAGE, False):
payload[ATTR_CERTIFICATE] = self.hass.http.ssl_certificate is not None
payload[ATTR_CERTIFICATE] = hass.http.ssl_certificate is not None
payload[ATTR_INTEGRATIONS] = integrations
payload[ATTR_CUSTOM_INTEGRATIONS] = custom_integrations
if supervisor_info is not None:
@ -284,11 +285,11 @@ class Analytics:
if ENERGY_DOMAIN in enabled_domains:
payload[ATTR_ENERGY] = {
ATTR_CONFIGURED: await energy_is_configured(self.hass)
ATTR_CONFIGURED: await energy_is_configured(hass)
}
if RECORDER_DOMAIN in enabled_domains:
instance = get_recorder_instance(self.hass)
instance = get_recorder_instance(hass)
engine = instance.database_engine
if engine and engine.version is not None:
payload[ATTR_RECORDER] = {
@ -297,9 +298,9 @@ class Analytics:
}
if self.preferences.get(ATTR_STATISTICS, False):
payload[ATTR_STATE_COUNT] = len(self.hass.states.async_all())
payload[ATTR_AUTOMATION_COUNT] = len(
self.hass.states.async_all(AUTOMATION_DOMAIN)
payload[ATTR_STATE_COUNT] = hass.states.async_entity_ids_count()
payload[ATTR_AUTOMATION_COUNT] = hass.states.async_entity_ids_count(
AUTOMATION_DOMAIN
)
payload[ATTR_INTEGRATION_COUNT] = len(integrations)
if supervisor_info is not None:
@ -307,7 +308,7 @@ class Analytics:
payload[ATTR_USER_COUNT] = len(
[
user
for user in await self.hass.auth.async_get_users()
for user in await hass.auth.async_get_users()
if not user.system_generated
]
)
@ -329,7 +330,7 @@ class Analytics:
response.status,
self.endpoint,
)
except asyncio.TimeoutError:
except TimeoutError:
LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL)
except aiohttp.ClientError as err:
LOGGER.error(

View File

@ -5,6 +5,7 @@
"codeowners": ["@home-assistant/core", "@ludeeus"],
"dependencies": ["api", "websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/analytics",
"import_executor": true,
"integration_type": "system",
"iot_class": "cloud_push",
"quality_scale": "internal"

View File

@ -4,6 +4,7 @@
"codeowners": ["@joostlek"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/analytics_insights",
"import_executor": true,
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["python_homeassistant_analytics"],

View File

@ -0,0 +1,62 @@
{
"entity": {
"sensor": {
"audio_connections": {
"default": "mdi:speaker"
},
"battery_temperature": {
"default": "mdi:thermometer"
},
"light": {
"default": "mdi:flashlight"
},
"motion": {
"default": "mdi:run"
},
"pressure": {
"default": "mdi:gauge"
},
"proximity": {
"default": "mdi:map-marker-radius"
},
"sound": {
"default": "mdi:speaker"
},
"video_connections": {
"default": "mdi:eye"
}
},
"switch": {
"exposure_lock": {
"default": "mdi:camera"
},
"ffc": {
"default": "mdi:camera-front-variant"
},
"focus": {
"default": "mdi:image-filter-center-focus"
},
"gps_active": {
"default": "mdi:crosshairs-gps"
},
"motion_detect": {
"default": "mdi:flash"
},
"night_vision": {
"default": "mdi:weather-night"
},
"overlay": {
"default": "mdi:monitor"
},
"torch": {
"default": "mdi:white-balance-sunny"
},
"whitebalance_lock": {
"default": "mdi:white-balance-auto"
},
"video_recording": {
"default": "mdi:record-rec"
}
}
}
}

View File

@ -42,8 +42,8 @@ class AndroidIPWebcamSensorEntityDescription(
SENSOR_TYPES: tuple[AndroidIPWebcamSensorEntityDescription, ...] = (
AndroidIPWebcamSensorEntityDescription(
key="audio_connections",
translation_key="audio_connections",
name="Audio connections",
icon="mdi:speaker",
state_class=SensorStateClass.TOTAL,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda ipcam: ipcam.status_data.get("audio_connections"),
@ -59,8 +59,8 @@ SENSOR_TYPES: tuple[AndroidIPWebcamSensorEntityDescription, ...] = (
),
AndroidIPWebcamSensorEntityDescription(
key="battery_temp",
translation_key="battery_temperature",
name="Battery temperature",
icon="mdi:thermometer",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda ipcam: ipcam.get_sensor_value("battery_temp"),
@ -76,48 +76,48 @@ SENSOR_TYPES: tuple[AndroidIPWebcamSensorEntityDescription, ...] = (
),
AndroidIPWebcamSensorEntityDescription(
key="light",
translation_key="light",
name="Light level",
icon="mdi:flashlight",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ipcam: ipcam.get_sensor_value("light"),
unit_fn=lambda ipcam: ipcam.get_sensor_unit("light"),
),
AndroidIPWebcamSensorEntityDescription(
key="motion",
translation_key="motion",
name="Motion",
icon="mdi:run",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ipcam: ipcam.get_sensor_value("motion"),
unit_fn=lambda ipcam: ipcam.get_sensor_unit("motion"),
),
AndroidIPWebcamSensorEntityDescription(
key="pressure",
translation_key="pressure",
name="Pressure",
icon="mdi:gauge",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ipcam: ipcam.get_sensor_value("pressure"),
unit_fn=lambda ipcam: ipcam.get_sensor_unit("pressure"),
),
AndroidIPWebcamSensorEntityDescription(
key="proximity",
translation_key="proximity",
name="Proximity",
icon="mdi:map-marker-radius",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ipcam: ipcam.get_sensor_value("proximity"),
unit_fn=lambda ipcam: ipcam.get_sensor_unit("proximity"),
),
AndroidIPWebcamSensorEntityDescription(
key="sound",
translation_key="sound",
name="Sound",
icon="mdi:speaker",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda ipcam: ipcam.get_sensor_value("sound"),
unit_fn=lambda ipcam: ipcam.get_sensor_unit("sound"),
),
AndroidIPWebcamSensorEntityDescription(
key="video_connections",
translation_key="video_connections",
name="Video connections",
icon="mdi:eye",
state_class=SensorStateClass.TOTAL,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda ipcam: ipcam.status_data.get("video_connections"),

View File

@ -36,80 +36,80 @@ class AndroidIPWebcamSwitchEntityDescription(
SWITCH_TYPES: tuple[AndroidIPWebcamSwitchEntityDescription, ...] = (
AndroidIPWebcamSwitchEntityDescription(
key="exposure_lock",
translation_key="exposure_lock",
name="Exposure lock",
icon="mdi:camera",
entity_category=EntityCategory.CONFIG,
on_func=lambda ipcam: ipcam.change_setting("exposure_lock", True),
off_func=lambda ipcam: ipcam.change_setting("exposure_lock", False),
),
AndroidIPWebcamSwitchEntityDescription(
key="ffc",
translation_key="ffc",
name="Front-facing camera",
icon="mdi:camera-front-variant",
entity_category=EntityCategory.CONFIG,
on_func=lambda ipcam: ipcam.change_setting("ffc", True),
off_func=lambda ipcam: ipcam.change_setting("ffc", False),
),
AndroidIPWebcamSwitchEntityDescription(
key="focus",
translation_key="focus",
name="Focus",
icon="mdi:image-filter-center-focus",
entity_category=EntityCategory.CONFIG,
on_func=lambda ipcam: ipcam.focus(activate=True),
off_func=lambda ipcam: ipcam.focus(activate=False),
),
AndroidIPWebcamSwitchEntityDescription(
key="gps_active",
translation_key="gps_active",
name="GPS active",
icon="mdi:crosshairs-gps",
entity_category=EntityCategory.CONFIG,
on_func=lambda ipcam: ipcam.change_setting("gps_active", True),
off_func=lambda ipcam: ipcam.change_setting("gps_active", False),
),
AndroidIPWebcamSwitchEntityDescription(
key="motion_detect",
translation_key="motion_detect",
name="Motion detection",
icon="mdi:flash",
entity_category=EntityCategory.CONFIG,
on_func=lambda ipcam: ipcam.change_setting("motion_detect", True),
off_func=lambda ipcam: ipcam.change_setting("motion_detect", False),
),
AndroidIPWebcamSwitchEntityDescription(
key="night_vision",
translation_key="night_vision",
name="Night vision",
icon="mdi:weather-night",
entity_category=EntityCategory.CONFIG,
on_func=lambda ipcam: ipcam.change_setting("night_vision", True),
off_func=lambda ipcam: ipcam.change_setting("night_vision", False),
),
AndroidIPWebcamSwitchEntityDescription(
key="overlay",
translation_key="overlay",
name="Overlay",
icon="mdi:monitor",
entity_category=EntityCategory.CONFIG,
on_func=lambda ipcam: ipcam.change_setting("overlay", True),
off_func=lambda ipcam: ipcam.change_setting("overlay", False),
),
AndroidIPWebcamSwitchEntityDescription(
key="torch",
translation_key="torch",
name="Torch",
icon="mdi:white-balance-sunny",
entity_category=EntityCategory.CONFIG,
on_func=lambda ipcam: ipcam.torch(activate=True),
off_func=lambda ipcam: ipcam.torch(activate=False),
),
AndroidIPWebcamSwitchEntityDescription(
key="whitebalance_lock",
translation_key="whitebalance_lock",
name="White balance lock",
icon="mdi:white-balance-auto",
entity_category=EntityCategory.CONFIG,
on_func=lambda ipcam: ipcam.change_setting("whitebalance_lock", True),
off_func=lambda ipcam: ipcam.change_setting("whitebalance_lock", False),
),
AndroidIPWebcamSwitchEntityDescription(
key="video_recording",
translation_key="video_recording",
name="Video recording",
icon="mdi:record-rec",
entity_category=EntityCategory.CONFIG,
on_func=lambda ipcam: ipcam.record(record=True),
off_func=lambda ipcam: ipcam.record(record=False),

View File

@ -0,0 +1,145 @@
"""Base AndroidTV Entity."""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine
import functools
import logging
from typing import Any, Concatenate, ParamSpec, TypeVar
from androidtv.exceptions import LockNotAcquiredException
from androidtv.setup_async import AndroidTVAsync, FireTVAsync
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_CONNECTIONS,
ATTR_IDENTIFIERS,
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_SW_VERSION,
CONF_HOST,
CONF_NAME,
)
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import Entity
from . import ADB_PYTHON_EXCEPTIONS, ADB_TCP_EXCEPTIONS, get_androidtv_mac
from .const import DEVICE_ANDROIDTV, DOMAIN
PREFIX_ANDROIDTV = "Android TV"
PREFIX_FIRETV = "Fire TV"
_LOGGER = logging.getLogger(__name__)
_ADBDeviceT = TypeVar("_ADBDeviceT", bound="AndroidTVEntity")
_R = TypeVar("_R")
_P = ParamSpec("_P")
_FuncType = Callable[Concatenate[_ADBDeviceT, _P], Awaitable[_R]]
_ReturnFuncType = Callable[Concatenate[_ADBDeviceT, _P], Coroutine[Any, Any, _R | None]]
def adb_decorator(
override_available: bool = False,
) -> Callable[[_FuncType[_ADBDeviceT, _P, _R]], _ReturnFuncType[_ADBDeviceT, _P, _R]]:
"""Wrap ADB methods and catch exceptions.
Allows for overriding the available status of the ADB connection via the
`override_available` parameter.
"""
def _adb_decorator(
func: _FuncType[_ADBDeviceT, _P, _R],
) -> _ReturnFuncType[_ADBDeviceT, _P, _R]:
"""Wrap the provided ADB method and catch exceptions."""
@functools.wraps(func)
async def _adb_exception_catcher(
self: _ADBDeviceT, *args: _P.args, **kwargs: _P.kwargs
) -> _R | None:
"""Call an ADB-related method and catch exceptions."""
if not self.available and not override_available:
return None
try:
return await func(self, *args, **kwargs)
except LockNotAcquiredException:
# If the ADB lock could not be acquired, skip this command
_LOGGER.info(
(
"ADB command %s not executed because the connection is"
" currently in use"
),
func.__name__,
)
return None
except self.exceptions as err:
_LOGGER.error(
(
"Failed to execute an ADB command. ADB connection re-"
"establishing attempt in the next update. Error: %s"
),
err,
)
await self.aftv.adb_close()
# pylint: disable-next=protected-access
self._attr_available = False
return None
except Exception:
# An unforeseen exception occurred. Close the ADB connection so that
# it doesn't happen over and over again, then raise the exception.
await self.aftv.adb_close()
# pylint: disable-next=protected-access
self._attr_available = False
raise
return _adb_exception_catcher
return _adb_decorator
class AndroidTVEntity(Entity):
"""Defines a base AndroidTV entity."""
_attr_has_entity_name = True
def __init__(
self,
aftv: AndroidTVAsync | FireTVAsync,
entry: ConfigEntry,
entry_data: dict[str, Any],
) -> None:
"""Initialize the AndroidTV base entity."""
self.aftv = aftv
self._attr_unique_id = entry.unique_id
self._entry_data = entry_data
device_class = aftv.DEVICE_CLASS
device_type = (
PREFIX_ANDROIDTV if device_class == DEVICE_ANDROIDTV else PREFIX_FIRETV
)
# CONF_NAME may be present in entry.data for configuration imported from YAML
device_name = entry.data.get(
CONF_NAME, f"{device_type} {entry.data[CONF_HOST]}"
)
info = aftv.device_properties
model = info.get(ATTR_MODEL)
self._attr_device_info = DeviceInfo(
model=f"{model} ({device_type})" if model else device_type,
name=device_name,
)
if self.unique_id:
self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, self.unique_id)}
if manufacturer := info.get(ATTR_MANUFACTURER):
self._attr_device_info[ATTR_MANUFACTURER] = manufacturer
if sw_version := info.get(ATTR_SW_VERSION):
self._attr_device_info[ATTR_SW_VERSION] = sw_version
if mac := get_androidtv_mac(info):
self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, mac)}
# ADB exceptions to catch
if not aftv.adb_server_ip:
# Using "adb_shell" (Python ADB implementation)
self.exceptions = ADB_PYTHON_EXCEPTIONS
else:
# Using "pure-python-adb" (communicate with ADB server)
self.exceptions = ADB_TCP_EXCEPTIONS

View File

@ -0,0 +1,8 @@
{
"services": {
"adb_command": "mdi:console",
"download": "mdi:download",
"upload": "mdi:upload",
"learn_sendevent": "mdi:remote"
}
}

View File

@ -1,15 +1,12 @@
"""Support for functionality to interact with Android / Fire TV devices."""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine
from datetime import timedelta
import functools
import hashlib
import logging
from typing import Any, Concatenate, ParamSpec, TypeVar
from typing import Any
from androidtv.constants import APPS, KEYS
from androidtv.exceptions import LockNotAcquiredException
from androidtv.setup_async import AndroidTVAsync, FireTVAsync
import voluptuous as vol
@ -21,23 +18,13 @@ from homeassistant.components.media_player import (
MediaPlayerState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_COMMAND,
ATTR_CONNECTIONS,
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_SW_VERSION,
CONF_HOST,
CONF_NAME,
)
from homeassistant.const import ATTR_COMMAND
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import Throttle
from . import ADB_PYTHON_EXCEPTIONS, ADB_TCP_EXCEPTIONS, get_androidtv_mac
from .const import (
ANDROID_DEV,
ANDROID_DEV_OPT,
@ -54,10 +41,7 @@ from .const import (
DOMAIN,
SIGNAL_CONFIG_ENTITY,
)
_ADBDeviceT = TypeVar("_ADBDeviceT", bound="ADBDevice")
_R = TypeVar("_R")
_P = ParamSpec("_P")
from .entity import AndroidTVEntity, adb_decorator
_LOGGER = logging.getLogger(__name__)
@ -73,9 +57,6 @@ SERVICE_DOWNLOAD = "download"
SERVICE_LEARN_SENDEVENT = "learn_sendevent"
SERVICE_UPLOAD = "upload"
PREFIX_ANDROIDTV = "Android TV"
PREFIX_FIRETV = "Fire TV"
# Translate from `AndroidTV` / `FireTV` reported state to HA state.
ANDROIDTV_STATES = {
"off": MediaPlayerState.OFF,
@ -92,25 +73,11 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Android Debug Bridge entity."""
aftv: AndroidTVAsync | FireTVAsync = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV]
entry_data = hass.data[DOMAIN][entry.entry_id]
aftv: AndroidTVAsync | FireTVAsync = entry_data[ANDROID_DEV]
device_class = aftv.DEVICE_CLASS
device_type = (
PREFIX_ANDROIDTV if device_class == DEVICE_ANDROIDTV else PREFIX_FIRETV
)
# CONF_NAME may be present in entry.data for configuration imported from YAML
device_name: str = entry.data.get(
CONF_NAME, f"{device_type} {entry.data[CONF_HOST]}"
)
device_args = [
aftv,
device_name,
device_type,
entry.unique_id,
entry.entry_id,
hass.data[DOMAIN][entry.entry_id],
]
device_args = [aftv, entry, entry_data]
async_add_entities(
[
AndroidTVDevice(*device_args)
@ -146,108 +113,25 @@ async def async_setup_entry(
)
_FuncType = Callable[Concatenate[_ADBDeviceT, _P], Awaitable[_R]]
_ReturnFuncType = Callable[Concatenate[_ADBDeviceT, _P], Coroutine[Any, Any, _R | None]]
def adb_decorator(
override_available: bool = False,
) -> Callable[[_FuncType[_ADBDeviceT, _P, _R]], _ReturnFuncType[_ADBDeviceT, _P, _R]]:
"""Wrap ADB methods and catch exceptions.
Allows for overriding the available status of the ADB connection via the
`override_available` parameter.
"""
def _adb_decorator(
func: _FuncType[_ADBDeviceT, _P, _R],
) -> _ReturnFuncType[_ADBDeviceT, _P, _R]:
"""Wrap the provided ADB method and catch exceptions."""
@functools.wraps(func)
async def _adb_exception_catcher(
self: _ADBDeviceT, *args: _P.args, **kwargs: _P.kwargs
) -> _R | None:
"""Call an ADB-related method and catch exceptions."""
if not self.available and not override_available:
return None
try:
return await func(self, *args, **kwargs)
except LockNotAcquiredException:
# If the ADB lock could not be acquired, skip this command
_LOGGER.info(
(
"ADB command %s not executed because the connection is"
" currently in use"
),
func.__name__,
)
return None
except self.exceptions as err:
_LOGGER.error(
(
"Failed to execute an ADB command. ADB connection re-"
"establishing attempt in the next update. Error: %s"
),
err,
)
await self.aftv.adb_close()
# pylint: disable-next=protected-access
self._attr_available = False
return None
except Exception:
# An unforeseen exception occurred. Close the ADB connection so that
# it doesn't happen over and over again, then raise the exception.
await self.aftv.adb_close()
# pylint: disable-next=protected-access
self._attr_available = False
raise
return _adb_exception_catcher
return _adb_decorator
class ADBDevice(MediaPlayerEntity):
class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
"""Representation of an Android or Fire TV device."""
_attr_device_class = MediaPlayerDeviceClass.TV
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
aftv: AndroidTVAsync | FireTVAsync,
name: str,
dev_type: str,
unique_id: str,
entry_id: str,
entry: ConfigEntry,
entry_data: dict[str, Any],
) -> None:
"""Initialize the Android / Fire TV device."""
self.aftv = aftv
self._attr_unique_id = unique_id
self._entry_id = entry_id
self._entry_data = entry_data
super().__init__(aftv, entry, entry_data)
self._entry_id = entry.entry_id
self._media_image: tuple[bytes | None, str | None] = None, None
self._attr_media_image_hash = None
info = aftv.device_properties
model = info.get(ATTR_MODEL)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
model=f"{model} ({dev_type})" if model else dev_type,
name=name,
)
if manufacturer := info.get(ATTR_MANUFACTURER):
self._attr_device_info[ATTR_MANUFACTURER] = manufacturer
if sw_version := info.get(ATTR_SW_VERSION):
self._attr_device_info[ATTR_SW_VERSION] = sw_version
if mac := get_androidtv_mac(info):
self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, mac)}
self._app_id_to_name: dict[str, str] = {}
self._app_name_to_id: dict[str, str] = {}
self._get_sources = DEFAULT_GET_SOURCES
@ -256,14 +140,6 @@ class ADBDevice(MediaPlayerEntity):
self.turn_on_command: str | None = None
self.turn_off_command: str | None = None
# ADB exceptions to catch
if not aftv.adb_server_ip:
# Using "adb_shell" (Python ADB implementation)
self.exceptions = ADB_PYTHON_EXCEPTIONS
else:
# Using "pure-python-adb" (communicate with ADB server)
self.exceptions = ADB_TCP_EXCEPTIONS
# Property attributes
self._attr_extra_state_attributes = {
ATTR_ADB_RESPONSE: None,

View File

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

View File

@ -4,6 +4,7 @@
"codeowners": ["@tronikos", "@Drafteed"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/androidtv_remote",
"import_executor": true,
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["androidtvremote2"],

View File

@ -53,7 +53,6 @@ class AnthemAVR(MediaPlayerEntity):
_attr_name = None
_attr_should_poll = False
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
_attr_icon = "mdi:audio-video"
_attr_supported_features = (
MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE

View File

@ -0,0 +1,9 @@
{
"entity": {
"sensor": {
"hot_water_availability": {
"default": "mdi:water-thermometer"
}
}
}
}

View File

@ -33,7 +33,6 @@ STATUS_ENTITY_DESCRIPTIONS: tuple[AOSmithStatusSensorEntityDescription, ...] = (
AOSmithStatusSensorEntityDescription(
key="hot_water_availability",
translation_key="hot_water_availability",
icon="mdi:water-thermometer",
device_class=SensorDeviceClass.ENUM,
options=["low", "medium", "high"],
value_fn=lambda device: HOT_WATER_STATUS_MAP.get(

View File

@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__)
_DESCRIPTION = BinarySensorEntityDescription(
key="statflag",
name="UPS Online Status",
icon="mdi:heart",
translation_key="online_status",
)
# The bit in STATFLAG that indicates the online status of the APC UPS.
_VALUE_ONLINE_MASK: Final = 0b1000

View File

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

View File

@ -0,0 +1,155 @@
{
"entity": {
"binary_sensor": {
"online_status": {
"default": "mdi:heart"
}
},
"sensor": {
"alarm_delay": {
"default": "mdi:alarm"
},
"apc_status": {
"default": "mdi:information-outline"
},
"apc_model": {
"default": "mdi:information-outline"
},
"bad_batteries": {
"default": "mdi:information-outline"
},
"battery_replacement_date": {
"default": "mdi:calendar-clock"
},
"battery_status": {
"default": "mdi:information-outline"
},
"cable_type": {
"default": "mdi:ethernet-cable"
},
"total_time_on_battery": {
"default": "mdi:timer-outline"
},
"date": {
"default": "mdi:calendar-clock"
},
"dip_switch_settings": {
"default": "mdi:information-outline"
},
"low_battery_signal": {
"default": "mdi:clock-alert"
},
"driver": {
"default": "mdi:information-outline"
},
"shutdown_delay": {
"default": "mdi:timer-outline"
},
"wake_delay": {
"default": "mdi:timer-outline"
},
"date_and_time": {
"default": "mdi:calendar-clock"
},
"external_batteries": {
"default": "mdi:information-outline"
},
"firmware_version": {
"default": "mdi:information-outline"
},
"hostname": {
"default": "mdi:information-outline"
},
"last_self_test": {
"default": "mdi:calendar-clock"
},
"last_transfer": {
"default": "mdi:transfer"
},
"line_failure": {
"default": "mdi:information-outline"
},
"load_capacity": {
"default": "mdi:gauge"
},
"apparent_power": {
"default": "mdi:gauge"
},
"manufacture_date": {
"default": "mdi:calendar"
},
"master_update": {
"default": "mdi:information-outline"
},
"max_time": {
"default": "mdi:timer-off-outline"
},
"max_battery_charge": {
"default": "mdi:battery-alert"
},
"min_time": {
"default": "mdi:timer-outline"
},
"model": {
"default": "mdi:information-outline"
},
"transfer_count": {
"default": "mdi:counter"
},
"register_1_fault": {
"default": "mdi:information-outline"
},
"register_2_fault": {
"default": "mdi:information-outline"
},
"register_3_fault": {
"default": "mdi:information-outline"
},
"restore_capacity": {
"default": "mdi:battery-alert"
},
"self_test_result": {
"default": "mdi:information-outline"
},
"sensitivity": {
"default": "mdi:information-outline"
},
"serial_number": {
"default": "mdi:information-outline"
},
"startup_time": {
"default": "mdi:calendar-clock"
},
"online_status": {
"default": "mdi:information-outline"
},
"status": {
"default": "mdi:information-outline"
},
"self_test_interval": {
"default": "mdi:information-outline"
},
"time_left": {
"default": "mdi:clock-alert"
},
"time_on_battery": {
"default": "mdi:timer-outline"
},
"ups_mode": {
"default": "mdi:information-outline"
},
"ups_name": {
"default": "mdi:information-outline"
},
"version": {
"default": "mdi:information-outline"
},
"transfer_from_battery": {
"default": "mdi:transfer"
},
"transfer_to_battery": {
"default": "mdi:transfer"
}
}
}
}

View File

@ -31,43 +31,42 @@ _LOGGER = logging.getLogger(__name__)
SENSORS: dict[str, SensorEntityDescription] = {
"alarmdel": SensorEntityDescription(
key="alarmdel",
translation_key="alarm_delay",
name="UPS Alarm Delay",
icon="mdi:alarm",
),
"ambtemp": SensorEntityDescription(
key="ambtemp",
name="UPS Ambient Temperature",
icon="mdi:thermometer",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
"apc": SensorEntityDescription(
key="apc",
translation_key="apc_status",
name="UPS Status Data",
icon="mdi:information-outline",
entity_registry_enabled_default=False,
),
"apcmodel": SensorEntityDescription(
key="apcmodel",
translation_key="apc_model",
name="UPS Model",
icon="mdi:information-outline",
entity_registry_enabled_default=False,
),
"badbatts": SensorEntityDescription(
key="badbatts",
translation_key="bad_batteries",
name="UPS Bad Batteries",
icon="mdi:information-outline",
),
"battdate": SensorEntityDescription(
key="battdate",
translation_key="battery_replacement_date",
name="UPS Battery Replaced",
icon="mdi:calendar-clock",
),
"battstat": SensorEntityDescription(
key="battstat",
translation_key="battery_status",
name="UPS Battery Status",
icon="mdi:information-outline",
),
"battv": SensorEntityDescription(
key="battv",
@ -80,69 +79,68 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="bcharge",
name="UPS Battery",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:battery",
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
),
"cable": SensorEntityDescription(
key="cable",
translation_key="cable_type",
name="UPS Cable Type",
icon="mdi:ethernet-cable",
entity_registry_enabled_default=False,
),
"cumonbatt": SensorEntityDescription(
key="cumonbatt",
translation_key="total_time_on_battery",
name="UPS Total Time on Battery",
icon="mdi:timer-outline",
state_class=SensorStateClass.TOTAL_INCREASING,
),
"date": SensorEntityDescription(
key="date",
translation_key="date",
name="UPS Status Date",
icon="mdi:calendar-clock",
entity_registry_enabled_default=False,
),
"dipsw": SensorEntityDescription(
key="dipsw",
translation_key="dip_switch_settings",
name="UPS Dip Switch Settings",
icon="mdi:information-outline",
),
"dlowbatt": SensorEntityDescription(
key="dlowbatt",
translation_key="low_battery_signal",
name="UPS Low Battery Signal",
icon="mdi:clock-alert",
),
"driver": SensorEntityDescription(
key="driver",
translation_key="driver",
name="UPS Driver",
icon="mdi:information-outline",
entity_registry_enabled_default=False,
),
"dshutd": SensorEntityDescription(
key="dshutd",
translation_key="shutdown_delay",
name="UPS Shutdown Delay",
icon="mdi:timer-outline",
),
"dwake": SensorEntityDescription(
key="dwake",
translation_key="wake_delay",
name="UPS Wake Delay",
icon="mdi:timer-outline",
),
"end apc": SensorEntityDescription(
key="end apc",
translation_key="date_and_time",
name="UPS Date and Time",
icon="mdi:calendar-clock",
entity_registry_enabled_default=False,
),
"extbatts": SensorEntityDescription(
key="extbatts",
translation_key="external_batteries",
name="UPS External Batteries",
icon="mdi:information-outline",
),
"firmware": SensorEntityDescription(
key="firmware",
translation_key="firmware_version",
name="UPS Firmware Version",
icon="mdi:information-outline",
entity_registry_enabled_default=False,
),
"hitrans": SensorEntityDescription(
@ -153,8 +151,8 @@ SENSORS: dict[str, SensorEntityDescription] = {
),
"hostname": SensorEntityDescription(
key="hostname",
translation_key="hostname",
name="UPS Hostname",
icon="mdi:information-outline",
entity_registry_enabled_default=False,
),
"humidity": SensorEntityDescription(
@ -162,7 +160,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
name="UPS Ambient Humidity",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
icon="mdi:water-percent",
state_class=SensorStateClass.MEASUREMENT,
),
"itemp": SensorEntityDescription(
@ -174,19 +171,19 @@ SENSORS: dict[str, SensorEntityDescription] = {
),
"laststest": SensorEntityDescription(
key="laststest",
translation_key="last_self_test",
name="UPS Last Self Test",
icon="mdi:calendar-clock",
),
"lastxfer": SensorEntityDescription(
key="lastxfer",
translation_key="last_transfer",
name="UPS Last Transfer",
icon="mdi:transfer",
entity_registry_enabled_default=False,
),
"linefail": SensorEntityDescription(
key="linefail",
translation_key="line_failure",
name="UPS Input Voltage Status",
icon="mdi:information-outline",
),
"linefreq": SensorEntityDescription(
key="linefreq",
@ -204,16 +201,16 @@ SENSORS: dict[str, SensorEntityDescription] = {
),
"loadpct": SensorEntityDescription(
key="loadpct",
translation_key="load_capacity",
name="UPS Load",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:gauge",
state_class=SensorStateClass.MEASUREMENT,
),
"loadapnt": SensorEntityDescription(
key="loadapnt",
translation_key="apparent_power",
name="UPS Load Apparent Power",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:gauge",
),
"lotrans": SensorEntityDescription(
key="lotrans",
@ -223,14 +220,14 @@ SENSORS: dict[str, SensorEntityDescription] = {
),
"mandate": SensorEntityDescription(
key="mandate",
translation_key="manufacture_date",
name="UPS Manufacture Date",
icon="mdi:calendar",
entity_registry_enabled_default=False,
),
"masterupd": SensorEntityDescription(
key="masterupd",
translation_key="master_update",
name="UPS Master Update",
icon="mdi:information-outline",
),
"maxlinev": SensorEntityDescription(
key="maxlinev",
@ -240,14 +237,14 @@ SENSORS: dict[str, SensorEntityDescription] = {
),
"maxtime": SensorEntityDescription(
key="maxtime",
translation_key="max_time",
name="UPS Battery Timeout",
icon="mdi:timer-off-outline",
),
"mbattchg": SensorEntityDescription(
key="mbattchg",
translation_key="max_battery_charge",
name="UPS Battery Shutdown",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:battery-alert",
),
"minlinev": SensorEntityDescription(
key="minlinev",
@ -257,13 +254,13 @@ SENSORS: dict[str, SensorEntityDescription] = {
),
"mintimel": SensorEntityDescription(
key="mintimel",
translation_key="min_time",
name="UPS Shutdown Time",
icon="mdi:timer-outline",
),
"model": SensorEntityDescription(
key="model",
translation_key="model",
name="UPS Model",
icon="mdi:information-outline",
entity_registry_enabled_default=False,
),
"nombattv": SensorEntityDescription(
@ -298,8 +295,8 @@ SENSORS: dict[str, SensorEntityDescription] = {
),
"numxfers": SensorEntityDescription(
key="numxfers",
translation_key="transfer_count",
name="UPS Transfer Count",
icon="mdi:counter",
state_class=SensorStateClass.TOTAL_INCREASING,
),
"outcurnt": SensorEntityDescription(
@ -318,109 +315,109 @@ SENSORS: dict[str, SensorEntityDescription] = {
),
"reg1": SensorEntityDescription(
key="reg1",
translation_key="register_1_fault",
name="UPS Register 1 Fault",
icon="mdi:information-outline",
entity_registry_enabled_default=False,
),
"reg2": SensorEntityDescription(
key="reg2",
translation_key="register_2_fault",
name="UPS Register 2 Fault",
icon="mdi:information-outline",
entity_registry_enabled_default=False,
),
"reg3": SensorEntityDescription(
key="reg3",
translation_key="register_3_fault",
name="UPS Register 3 Fault",
icon="mdi:information-outline",
entity_registry_enabled_default=False,
),
"retpct": SensorEntityDescription(
key="retpct",
translation_key="restore_capacity",
name="UPS Restore Requirement",
native_unit_of_measurement=PERCENTAGE,
icon="mdi:battery-alert",
),
"selftest": SensorEntityDescription(
key="selftest",
translation_key="self_test_result",
name="UPS Self Test result",
icon="mdi:information-outline",
),
"sense": SensorEntityDescription(
key="sense",
translation_key="sensitivity",
name="UPS Sensitivity",
icon="mdi:information-outline",
entity_registry_enabled_default=False,
),
"serialno": SensorEntityDescription(
key="serialno",
translation_key="serial_number",
name="UPS Serial Number",
icon="mdi:information-outline",
entity_registry_enabled_default=False,
),
"starttime": SensorEntityDescription(
key="starttime",
translation_key="startup_time",
name="UPS Startup Time",
icon="mdi:calendar-clock",
),
"statflag": SensorEntityDescription(
key="statflag",
translation_key="online_status",
name="UPS Status Flag",
icon="mdi:information-outline",
entity_registry_enabled_default=False,
),
"status": SensorEntityDescription(
key="status",
translation_key="status",
name="UPS Status",
icon="mdi:information-outline",
),
"stesti": SensorEntityDescription(
key="stesti",
translation_key="self_test_interval",
name="UPS Self Test Interval",
icon="mdi:information-outline",
),
"timeleft": SensorEntityDescription(
key="timeleft",
translation_key="time_left",
name="UPS Time Left",
icon="mdi:clock-alert",
state_class=SensorStateClass.MEASUREMENT,
),
"tonbatt": SensorEntityDescription(
key="tonbatt",
translation_key="time_on_battery",
name="UPS Time on Battery",
icon="mdi:timer-outline",
state_class=SensorStateClass.TOTAL_INCREASING,
),
"upsmode": SensorEntityDescription(
key="upsmode",
translation_key="ups_mode",
name="UPS Mode",
icon="mdi:information-outline",
),
"upsname": SensorEntityDescription(
key="upsname",
translation_key="ups_name",
name="UPS Name",
icon="mdi:information-outline",
entity_registry_enabled_default=False,
),
"version": SensorEntityDescription(
key="version",
translation_key="version",
name="UPS Daemon Info",
icon="mdi:information-outline",
entity_registry_enabled_default=False,
),
"xoffbat": SensorEntityDescription(
key="xoffbat",
translation_key="transfer_from_battery",
name="UPS Transfer from Battery",
icon="mdi:transfer",
),
"xoffbatt": SensorEntityDescription(
key="xoffbatt",
translation_key="transfer_from_battery",
name="UPS Transfer from Battery",
icon="mdi:transfer",
),
"xonbatt": SensorEntityDescription(
key="xonbatt",
translation_key="transfer_to_battery",
name="UPS Transfer to Battery",
icon="mdi:transfer",
),
}

View File

@ -12,7 +12,6 @@ import voluptuous as vol
from homeassistant.auth.models import User
from homeassistant.auth.permissions.const import POLICY_READ
from homeassistant.bootstrap import DATA_LOGGING
from homeassistant.components.http import (
KEY_HASS,
KEY_HASS_USER,
@ -23,6 +22,7 @@ from homeassistant.const import (
CONTENT_TYPE_JSON,
EVENT_HOMEASSISTANT_STOP,
EVENT_STATE_CHANGED,
KEY_DATA_LOGGING as DATA_LOGGING,
MATCH_ALL,
URL_API,
URL_API_COMPONENTS,
@ -175,7 +175,7 @@ class APIEventStream(HomeAssistantView):
msg = f"data: {payload}\n\n"
_LOGGER.debug("STREAM %s WRITING %s", id(stop_obj), msg.strip())
await response.write(msg.encode("UTF-8"))
except asyncio.TimeoutError:
except TimeoutError:
await to_write.put(STREAM_PING_PAYLOAD)
except asyncio.CancelledError:
@ -222,7 +222,7 @@ class APIStatesView(HomeAssistantView):
if entity_perm(state.entity_id, "read")
)
response = web.Response(
body=b"[" + b",".join(states) + b"]",
body=b"".join((b"[", b",".join(states), b"]")),
content_type=CONTENT_TYPE_JSON,
zlib_executor_size=32768,
)
@ -472,7 +472,9 @@ class APIErrorLog(HomeAssistantView):
async def get(self, request: web.Request) -> web.FileResponse:
"""Retrieve API error log."""
hass: HomeAssistant = request.app[KEY_HASS]
return web.FileResponse(hass.data[DATA_LOGGING])
response = web.FileResponse(hass.data[DATA_LOGGING])
response.enable_compression()
return response
async def async_services_json(hass: HomeAssistant) -> list[dict[str, Any]]:

View File

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

View File

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

View File

@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["zeroconf"],
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
"import_executor": true,
"iot_class": "local_push",
"loggers": ["pyatv", "srptools"],
"requirements": ["pyatv==0.14.3"],

View File

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

View File

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

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:
await asyncio.sleep(interval)
except asyncio.TimeoutError:
except TimeoutError:
continue
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception, aborting arcam client")

View File

@ -325,9 +325,7 @@ class ArcamFmj(MediaPlayerEntity):
def media_content_type(self) -> MediaType | None:
"""Content type of current playing media."""
source = self._state.get_source()
if source == SourceCodes.DAB:
value = MediaType.MUSIC
elif source == SourceCodes.FM:
if source in (SourceCodes.DAB, SourceCodes.FM):
value = MediaType.MUSIC
else:
value = None

View File

@ -83,6 +83,7 @@ async def async_pipeline_from_audio_stream(
event_callback: PipelineEventCallback,
stt_metadata: stt.SpeechMetadata,
stt_stream: AsyncIterable[bytes],
wake_word_phrase: str | None = None,
pipeline_id: str | None = None,
conversation_id: str | None = None,
tts_audio_output: str | None = None,
@ -101,6 +102,7 @@ async def async_pipeline_from_audio_stream(
device_id=device_id,
stt_metadata=stt_metadata,
stt_stream=stt_stream,
wake_word_phrase=wake_word_phrase,
run=PipelineRun(
hass,
context=context,

View File

@ -10,6 +10,6 @@ DEFAULT_WAKE_WORD_TIMEOUT = 3 # seconds
CONF_DEBUG_RECORDING_DIR = "debug_recording_dir"
DATA_LAST_WAKE_UP = f"{DOMAIN}.last_wake_up"
DEFAULT_WAKE_WORD_COOLDOWN = 2 # seconds
WAKE_WORD_COOLDOWN = 2 # seconds
EVENT_RECORDING = f"{DOMAIN}_recording"

View File

@ -38,6 +38,17 @@ class SpeechToTextError(PipelineError):
"""Error in speech-to-text portion of pipeline."""
class DuplicateWakeUpDetectedError(WakeWordDetectionError):
"""Error when multiple voice assistants wake up at the same time (same wake word)."""
def __init__(self, wake_up_phrase: str) -> None:
"""Set error message."""
super().__init__(
"duplicate_wake_up_detected",
f"Duplicate wake-up detected for {wake_up_phrase}",
)
class IntentRecognitionError(PipelineError):
"""Error in intent recognition portion of pipeline."""

View File

@ -55,10 +55,11 @@ from .const import (
CONF_DEBUG_RECORDING_DIR,
DATA_CONFIG,
DATA_LAST_WAKE_UP,
DEFAULT_WAKE_WORD_COOLDOWN,
DOMAIN,
WAKE_WORD_COOLDOWN,
)
from .error import (
DuplicateWakeUpDetectedError,
IntentRecognitionError,
PipelineError,
PipelineNotFound,
@ -453,9 +454,6 @@ class WakeWordSettings:
audio_seconds_to_buffer: float = 0
"""Seconds of audio to buffer before detection and forward to STT."""
cooldown_seconds: float = DEFAULT_WAKE_WORD_COOLDOWN
"""Seconds after a wake word detection where other detections are ignored."""
@dataclass(frozen=True)
class AudioSettings:
@ -742,16 +740,22 @@ class PipelineRun:
wake_word_output: dict[str, Any] = {}
else:
# Avoid duplicate detections by checking cooldown
wake_up_key = f"{self.wake_word_entity_id}.{result.wake_word_id}"
last_wake_up = self.hass.data[DATA_LAST_WAKE_UP].get(wake_up_key)
last_wake_up = self.hass.data[DATA_LAST_WAKE_UP].get(
result.wake_word_phrase
)
if last_wake_up is not None:
sec_since_last_wake_up = time.monotonic() - last_wake_up
if sec_since_last_wake_up < wake_word_settings.cooldown_seconds:
_LOGGER.debug("Duplicate wake word detection occurred")
raise WakeWordDetectionAborted
if sec_since_last_wake_up < WAKE_WORD_COOLDOWN:
_LOGGER.debug(
"Duplicate wake word detection occurred for %s",
result.wake_word_phrase,
)
raise DuplicateWakeUpDetectedError(result.wake_word_phrase)
# Record last wake up time to block duplicate detections
self.hass.data[DATA_LAST_WAKE_UP][wake_up_key] = time.monotonic()
self.hass.data[DATA_LAST_WAKE_UP][
result.wake_word_phrase
] = time.monotonic()
if result.queued_audio:
# Add audio that was pending at detection.
@ -1308,6 +1312,9 @@ class PipelineInput:
stt_stream: AsyncIterable[bytes] | None = None
"""Input audio for stt. Required when start_stage = stt."""
wake_word_phrase: str | None = None
"""Optional key used to de-duplicate wake-ups for local wake word detection."""
intent_input: str | None = None
"""Input for conversation agent. Required when start_stage = intent."""
@ -1352,6 +1359,25 @@ class PipelineInput:
assert self.stt_metadata is not None
assert stt_processed_stream is not None
if self.wake_word_phrase is not None:
# Avoid duplicate wake-ups by checking cooldown
last_wake_up = self.run.hass.data[DATA_LAST_WAKE_UP].get(
self.wake_word_phrase
)
if last_wake_up is not None:
sec_since_last_wake_up = time.monotonic() - last_wake_up
if sec_since_last_wake_up < WAKE_WORD_COOLDOWN:
_LOGGER.debug(
"Speech-to-text cancelled to avoid duplicate wake-up for %s",
self.wake_word_phrase,
)
raise DuplicateWakeUpDetectedError(self.wake_word_phrase)
# Record last wake up time to block duplicate detections
self.run.hass.data[DATA_LAST_WAKE_UP][
self.wake_word_phrase
] = time.monotonic()
stt_input_stream = stt_processed_stream
if stt_audio_buffer:

View File

@ -97,7 +97,12 @@ def async_register_websocket_api(hass: HomeAssistant) -> None:
extra=vol.ALLOW_EXTRA,
),
PipelineStage.STT: vol.Schema(
{vol.Required("input"): {vol.Required("sample_rate"): int}},
{
vol.Required("input"): {
vol.Required("sample_rate"): int,
vol.Optional("wake_word_phrase"): str,
}
},
extra=vol.ALLOW_EXTRA,
),
PipelineStage.INTENT: vol.Schema(
@ -149,12 +154,15 @@ async def websocket_run(
msg_input = msg["input"]
audio_queue: asyncio.Queue[bytes] = asyncio.Queue()
incoming_sample_rate = msg_input["sample_rate"]
wake_word_phrase: str | None = None
if start_stage == PipelineStage.WAKE_WORD:
wake_word_settings = WakeWordSettings(
timeout=msg["input"].get("timeout", DEFAULT_WAKE_WORD_TIMEOUT),
audio_seconds_to_buffer=msg_input.get("audio_seconds_to_buffer", 0),
)
elif start_stage == PipelineStage.STT:
wake_word_phrase = msg["input"].get("wake_word_phrase")
async def stt_stream() -> AsyncGenerator[bytes, None]:
state = None
@ -189,6 +197,7 @@ async def websocket_run(
channel=stt.AudioChannels.CHANNEL_MONO,
)
input_args["stt_stream"] = stt_stream()
input_args["wake_word_phrase"] = wake_word_phrase
# Audio settings
audio_settings = AudioSettings(
@ -241,7 +250,7 @@ async def websocket_run(
# Task contains a timeout
async with asyncio.timeout(timeout):
await run_task
except asyncio.TimeoutError:
except TimeoutError:
pipeline_input.run.process_event(
PipelineEvent(
PipelineEventType.ERROR,
@ -487,7 +496,7 @@ async def websocket_device_capture(
)
try:
with contextlib.suppress(asyncio.TimeoutError):
with contextlib.suppress(TimeoutError):
async with asyncio.timeout(timeout_seconds):
while True:
# Send audio chunks encoded as base64

View File

@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_connect
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
from homeassistant.helpers.typing import ConfigType
_LOGGER = logging.getLogger(__name__)
@ -50,6 +51,21 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
password: str = conf[CONF_PASSWORD]
hass.data[DOMAIN] = AsteriskData(hass, host, port, password, config)
create_issue(
hass,
DOMAIN,
"deprecated_integration",
breaks_in_ha_version="2024.9.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_integration",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Asterisk Voicemail",
"mailbox": "mailbox",
},
)
return True

View File

@ -0,0 +1,8 @@
{
"issues": {
"deprecated_integration": {
"title": "The {integration_title} is being removed",
"description": "{integration_title} is being removed as the `{mailbox}` platform is being removed and {integration_title} supports no other platforms. Remove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
}
}
}

View File

@ -211,10 +211,7 @@ class AsusWrtLegacyBridge(AsusWrtBridge):
async def async_get_connected_devices(self) -> dict[str, WrtDevice]:
"""Get list of connected devices."""
try:
api_devices = await self._api.async_get_connected_devices()
except OSError as exc:
raise UpdateFailed(exc) from exc
api_devices = await self._api.async_get_connected_devices()
return {
format_mac(mac): WrtDevice(dev.ip, dev.name, None)
for mac, dev in api_devices.items()
@ -343,10 +340,7 @@ class AsusWrtHttpBridge(AsusWrtBridge):
async def async_get_connected_devices(self) -> dict[str, WrtDevice]:
"""Get list of connected devices."""
try:
api_devices = await self._api.async_get_connected_devices()
except AsusWrtError as exc:
raise UpdateFailed(exc) from exc
api_devices = await self._api.async_get_connected_devices()
return {
format_mac(mac): WrtDevice(dev.ip, dev.name, dev.node)
for mac, dev in api_devices.items()

View File

@ -216,7 +216,7 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN):
if error is not None:
return error, None
_LOGGER.info(
_LOGGER.debug(
"Successfully connected to the AsusWrt router at %s using protocol %s",
host,
protocol,

View File

@ -20,7 +20,7 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util, slugify
from .bridge import AsusWrtBridge, WrtDevice
@ -276,7 +276,7 @@ class AsusWrtRouter:
_LOGGER.debug("Checking devices for ASUS router %s", self.host)
try:
wrt_devices = await self._api.async_get_connected_devices()
except UpdateFailed as exc:
except (OSError, AsusWrtError) as exc:
if not self._connect_error:
self._connect_error = True
_LOGGER.error(

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)
except (RequireValidation, InvalidAuth) as err:
raise ConfigEntryAuthFailed from err
except asyncio.TimeoutError as err:
except TimeoutError as err:
raise ConfigEntryNotReady("Timed out connecting to august api") from err
except (AugustApiAIOHTTPError, ClientResponseError, CannotConnect) as err:
raise ConfigEntryNotReady from err
@ -233,7 +233,7 @@ class AugustData(AugustSubscriberMixin):
return_exceptions=True,
):
if isinstance(result, Exception) and not isinstance(
result, (asyncio.TimeoutError, ClientResponseError, CannotConnect)
result, (TimeoutError, ClientResponseError, CannotConnect)
):
_LOGGER.warning(
"Unexpected exception during initial sync: %s",
@ -293,7 +293,7 @@ class AugustData(AugustSubscriberMixin):
for device_id in device_ids_list:
try:
await self._async_refresh_device_detail_by_id(device_id)
except asyncio.TimeoutError:
except TimeoutError:
_LOGGER.warning(
"Timed out calling august api during refresh of device: %s",
device_id,

View File

@ -1,7 +1,6 @@
"""Consume the august activity stream."""
from __future__ import annotations
import asyncio
from datetime import datetime
from functools import partial
import logging
@ -63,11 +62,10 @@ class ActivityStream(AugustSubscriberMixin):
self._update_debounce: dict[str, Debouncer] = {}
self._update_debounce_jobs: dict[str, HassJob] = {}
async def _async_update_house_id_later(
self, debouncer: Debouncer, _: datetime
) -> None:
@callback
def _async_update_house_id_later(self, debouncer: Debouncer, _: datetime) -> None:
"""Call a debouncer from async_call_later."""
await debouncer.async_call()
debouncer.async_schedule_call()
async def async_setup(self) -> None:
"""Token refresh check and catch up the activity stream."""
@ -128,9 +126,9 @@ class ActivityStream(AugustSubscriberMixin):
_LOGGER.debug("Skipping update because pubnub is connected")
return
_LOGGER.debug("Start retrieving device activities")
await asyncio.gather(
*(debouncer.async_call() for debouncer in self._update_debounce.values())
)
# Await in sequence to avoid hammering the API
for debouncer in self._update_debounce.values():
await debouncer.async_call()
@callback
def async_schedule_house_id_refresh(self, house_id: str) -> None:
@ -139,7 +137,7 @@ class ActivityStream(AugustSubscriberMixin):
_async_cancel_future_scheduled_updates(future_updates)
debouncer = self._update_debounce[house_id]
self._hass.async_create_task(debouncer.async_call())
debouncer.async_schedule_call()
# Schedule two updates past the debounce time
# to ensure we catch the case where the activity
# api does not update right away and we need to poll

View File

@ -26,7 +26,8 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/august",
"import_executor": true,
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==1.11.2", "yalexs-ble==2.4.1"]
"requirements": ["yalexs==1.11.4", "yalexs-ble==2.4.2"]
}

View File

@ -43,12 +43,17 @@ class AugustSubscriberMixin:
async def _async_refresh(self, time: datetime) -> None:
"""Refresh data."""
@callback
def _async_scheduled_refresh(self, now: datetime) -> None:
"""Call the refresh method."""
self._hass.async_create_task(self._async_refresh(now), eager_start=True)
@callback
def _async_setup_listeners(self) -> None:
"""Create interval and stop listeners."""
self._unsub_interval = async_track_time_interval(
self._hass,
self._async_refresh,
self._async_scheduled_refresh,
self._update_interval,
name="august refresh",
)

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