Compare commits

..

1 Commits

Author SHA1 Message Date
G Johansson bda95f4d7a Create preview for history_stats 2025-01-07 22:24:05 +00:00
2165 changed files with 29965 additions and 72741 deletions
+1 -1
View File
@@ -62,7 +62,7 @@
"json.schemas": [
{
"fileMatch": ["homeassistant/components/*/manifest.json"],
"url": "${containerWorkspaceFolder}/script/json_schemas/manifest_schema.json"
"url": "./script/json_schemas/manifest_schema.json"
}
]
}
-11
View File
@@ -11,14 +11,3 @@
*.pcm binary
Dockerfile.dev linguist-language=Dockerfile
# Generated files
CODEOWNERS linguist-generated=true
Dockerfile linguist-generated=true
homeassistant/generated/*.py linguist-generated=true
mypy.ini linguist-generated=true
requirements.txt linguist-generated=true
requirements_all.txt linguist-generated=true
requirements_test_all.txt linguist-generated=true
requirements_test_pre_commit.txt linguist-generated=true
script/hassfest/docker/Dockerfile linguist-generated=true
+6 -6
View File
@@ -69,7 +69,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.5.0
with:
name: translations
path: translations.tar.gz
@@ -94,7 +94,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@v8
uses: dawidd6/action-download-artifact@v7
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -105,7 +105,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@v8
uses: dawidd6/action-download-artifact@v7
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/intents-package
@@ -509,7 +509,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0
uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -522,7 +522,7 @@ jobs:
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0
uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -531,7 +531,7 @@ jobs:
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0
uses: actions/attest-build-provenance@7668571508540a607bdfd90a87a560489fe372eb # v2.1.0
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
+15 -15
View File
@@ -41,8 +41,8 @@ env:
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 9
HA_SHORT_VERSION: "2025.2"
DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13']"
DEFAULT_PYTHON: "3.12"
ALL_PYTHON_VERSIONS: "['3.12', '3.13']"
# 10.3 is the oldest supported version
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
# 10.6 is the current long-term-support
@@ -537,7 +537,7 @@ jobs:
python --version
uv pip freeze >> pip_freeze.txt
- name: Upload pip_freeze artifact
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.5.0
with:
name: pip-freeze-${{ matrix.python-version }}
path: pip_freeze.txt
@@ -661,7 +661,7 @@ jobs:
. venv/bin/activate
python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json
- name: Upload licenses
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.5.0
with:
name: licenses-${{ github.run_number }}-${{ matrix.python-version }}
path: licenses-${{ matrix.python-version }}.json
@@ -877,7 +877,7 @@ jobs:
. venv/bin/activate
python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests
- name: Upload pytest_buckets
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.5.0
with:
name: pytest_buckets
path: pytest_buckets.txt
@@ -979,14 +979,14 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-full.conclusion == 'failure'
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.5.0
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.5.0
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
@@ -1106,7 +1106,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.5.0
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -1114,7 +1114,7 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.5.0
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -1236,7 +1236,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.5.0
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1244,7 +1244,7 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.5.0
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1273,7 +1273,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@v5.3.0
uses: codecov/codecov-action@v5.1.2
with:
fail_ci_if_error: true
flags: full-suite
@@ -1378,14 +1378,14 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.5.0
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.5.0
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
@@ -1411,7 +1411,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@v5.3.0
uses: codecov/codecov-action@v5.1.2
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
+2 -2
View File
@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.28.4
uses: github/codeql-action/init@v3.28.0
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.28.4
uses: github/codeql-action/analyze@v3.28.0
with:
category: "/language:python"
+3 -3
View File
@@ -17,7 +17,7 @@ jobs:
# - No PRs marked as no-stale
# - No issues (-1)
- name: 60 days stale PRs policy
uses: actions/stale@v9.1.0
uses: actions/stale@v9.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60
@@ -57,7 +57,7 @@ jobs:
# - No issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: 90 days stale issues
uses: actions/stale@v9.1.0
uses: actions/stale@v9.0.0
with:
repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90
@@ -87,7 +87,7 @@ jobs:
# - No Issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: Needs more information stale issues policy
uses: actions/stale@v9.1.0
uses: actions/stale@v9.0.0
with:
repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information"
+1 -1
View File
@@ -10,7 +10,7 @@ on:
- "**strings.json"
env:
DEFAULT_PYTHON: "3.13"
DEFAULT_PYTHON: "3.12"
jobs:
upload:
+5 -5
View File
@@ -17,7 +17,7 @@ on:
- "script/gen_requirements_all.py"
env:
DEFAULT_PYTHON: "3.13"
DEFAULT_PYTHON: "3.12"
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name}}
@@ -91,7 +91,7 @@ jobs:
) > build_constraints.txt
- name: Upload env_file
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.5.0
with:
name: env_file
path: ./.env_file
@@ -99,14 +99,14 @@ jobs:
overwrite: true
- name: Upload build_constraints
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.5.0
with:
name: build_constraints
path: ./build_constraints.txt
overwrite: true
- name: Upload requirements_diff
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.5.0
with:
name: requirements_diff
path: ./requirements_diff.txt
@@ -118,7 +118,7 @@ jobs:
python -m script.gen_requirements_all ci
- name: Upload requirements_all_wheels
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.5.0
with:
name: requirements_all_wheels
path: ./requirements_all_wheels_*.txt
+3 -4
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.1
rev: v0.8.6
hooks:
- id: ruff
args:
@@ -61,14 +61,13 @@ repos:
name: mypy
entry: script/run-in-env.sh mypy
language: script
require_serial: true
types_or: [python, pyi]
require_serial: true
files: ^(homeassistant|pylint)/.+\.(py|pyi)$
- id: pylint
name: pylint
entry: script/run-in-env.sh pylint --ignore-missing-annotations=y
entry: script/run-in-env.sh pylint -j 0 --ignore-missing-annotations=y
language: script
require_serial: true
types_or: [python, pyi]
files: ^(homeassistant|tests)/.+\.(py|pyi)$
- id: gen_requirements_all
-7
View File
@@ -224,7 +224,6 @@ homeassistant.components.gpsd.*
homeassistant.components.greeneye_monitor.*
homeassistant.components.group.*
homeassistant.components.guardian.*
homeassistant.components.habitica.*
homeassistant.components.hardkernel.*
homeassistant.components.hardware.*
homeassistant.components.here_travel_time.*
@@ -237,7 +236,6 @@ homeassistant.components.homeassistant_green.*
homeassistant.components.homeassistant_hardware.*
homeassistant.components.homeassistant_sky_connect.*
homeassistant.components.homeassistant_yellow.*
homeassistant.components.homee.*
homeassistant.components.homekit.*
homeassistant.components.homekit_controller
homeassistant.components.homekit_controller.alarm_control_panel
@@ -263,7 +261,6 @@ homeassistant.components.image_processing.*
homeassistant.components.image_upload.*
homeassistant.components.imap.*
homeassistant.components.imgw_pib.*
homeassistant.components.incomfort.*
homeassistant.components.input_button.*
homeassistant.components.input_select.*
homeassistant.components.input_text.*
@@ -294,7 +291,6 @@ homeassistant.components.lcn.*
homeassistant.components.ld2410_ble.*
homeassistant.components.led_ble.*
homeassistant.components.lektrico.*
homeassistant.components.letpot.*
homeassistant.components.lidarr.*
homeassistant.components.lifx.*
homeassistant.components.light.*
@@ -309,7 +305,6 @@ homeassistant.components.logbook.*
homeassistant.components.logger.*
homeassistant.components.london_underground.*
homeassistant.components.lookin.*
homeassistant.components.lovelace.*
homeassistant.components.luftdaten.*
homeassistant.components.madvr.*
homeassistant.components.manual.*
@@ -375,7 +370,6 @@ homeassistant.components.panel_custom.*
homeassistant.components.peblar.*
homeassistant.components.peco.*
homeassistant.components.persistent_notification.*
homeassistant.components.person.*
homeassistant.components.pi_hole.*
homeassistant.components.ping.*
homeassistant.components.plugwise.*
@@ -390,7 +384,6 @@ homeassistant.components.purpleair.*
homeassistant.components.pushbullet.*
homeassistant.components.pvoutput.*
homeassistant.components.python_script.*
homeassistant.components.qbus.*
homeassistant.components.qnap_qsw.*
homeassistant.components.rabbitair.*
homeassistant.components.radarr.*
+1 -2
View File
@@ -1,5 +1,5 @@
{
// Please keep this file (mostly!) in sync with settings in home-assistant/.devcontainer/devcontainer.json
// Please keep this file in sync with settings in home-assistant/.devcontainer/devcontainer.json
// Added --no-cov to work around TypeError: message must be set
// https://github.com/microsoft/vscode-python/issues/14067
"python.testing.pytestArgs": ["--no-cov"],
@@ -12,7 +12,6 @@
"fileMatch": [
"homeassistant/components/*/manifest.json"
],
// This value differs between working with devcontainer and locally, therefor this value should NOT be in sync!
"url": "./script/json_schemas/manifest_schema.json"
}
]
+15 -17
View File
@@ -682,6 +682,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/iammeter/ @lewei50
/homeassistant/components/iaqualink/ @flz
/tests/components/iaqualink/ @flz
/homeassistant/components/ibeacon/ @bdraco
/tests/components/ibeacon/ @bdraco
/homeassistant/components/icloud/ @Quentame @nzapponi
/tests/components/icloud/ @Quentame @nzapponi
/homeassistant/components/idasen_desk/ @abmantis
@@ -829,8 +831,6 @@ build.json @home-assistant/supervisor
/tests/components/led_ble/ @bdraco
/homeassistant/components/lektrico/ @lektrico
/tests/components/lektrico/ @lektrico
/homeassistant/components/letpot/ @jpelgrom
/tests/components/letpot/ @jpelgrom
/homeassistant/components/lg_netcast/ @Drafteed @splinter98
/tests/components/lg_netcast/ @Drafteed @splinter98
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
@@ -1022,6 +1022,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/nina/ @DeerMaximum
/tests/components/nina/ @DeerMaximum
/homeassistant/components/nissan_leaf/ @filcole
/homeassistant/components/nmbs/ @thibmaek
/homeassistant/components/noaa_tides/ @jdelaney72
/homeassistant/components/nobo_hub/ @echoromeo @oyvindwe
/tests/components/nobo_hub/ @echoromeo @oyvindwe
@@ -1073,8 +1074,8 @@ build.json @home-assistant/supervisor
/tests/components/onewire/ @garbled1 @epenet
/homeassistant/components/onkyo/ @arturpragacz @eclair4151
/tests/components/onkyo/ @arturpragacz @eclair4151
/homeassistant/components/onvif/ @hunterjm @jterrace
/tests/components/onvif/ @hunterjm @jterrace
/homeassistant/components/onvif/ @hunterjm
/tests/components/onvif/ @hunterjm
/homeassistant/components/open_meteo/ @frenck
/tests/components/open_meteo/ @frenck
/homeassistant/components/openai_conversation/ @balloob
@@ -1189,8 +1190,6 @@ build.json @home-assistant/supervisor
/tests/components/pyload/ @tr4nt0r
/homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39
/tests/components/qbittorrent/ @geoffreylagaisse @finder39
/homeassistant/components/qbus/ @Qbus-iot @thomasddn
/tests/components/qbus/ @Qbus-iot @thomasddn
/homeassistant/components/qingping/ @bdraco
/tests/components/qingping/ @bdraco
/homeassistant/components/qld_bushfire/ @exxamalte
@@ -1267,8 +1266,8 @@ build.json @home-assistant/supervisor
/tests/components/rituals_perfume_genie/ @milanmeu @frenck
/homeassistant/components/rmvtransport/ @cgtobi
/tests/components/rmvtransport/ @cgtobi
/homeassistant/components/roborock/ @Lash-L @allenporter
/tests/components/roborock/ @Lash-L @allenporter
/homeassistant/components/roborock/ @Lash-L
/tests/components/roborock/ @Lash-L
/homeassistant/components/roku/ @ctalkington
/tests/components/roku/ @ctalkington
/homeassistant/components/romy/ @xeniter
@@ -1287,7 +1286,6 @@ build.json @home-assistant/supervisor
/tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
/homeassistant/components/russound_rio/ @noahhusby
/tests/components/russound_rio/ @noahhusby
/homeassistant/components/russound_rnet/ @noahhusby
/homeassistant/components/ruuvi_gateway/ @akx
/tests/components/ruuvi_gateway/ @akx
/homeassistant/components/ruuvitag_ble/ @akx
@@ -1381,8 +1379,8 @@ build.json @home-assistant/supervisor
/tests/components/slide_local/ @dontinelli
/homeassistant/components/slimproto/ @marcelveldt
/tests/components/slimproto/ @marcelveldt
/homeassistant/components/sma/ @kellerza @rklomp @erwindouna
/tests/components/sma/ @kellerza @rklomp @erwindouna
/homeassistant/components/sma/ @kellerza @rklomp
/tests/components/sma/ @kellerza @rklomp
/homeassistant/components/smappee/ @bsmappee
/tests/components/smappee/ @bsmappee
/homeassistant/components/smart_meter_texas/ @grahamwetzler
@@ -1408,8 +1406,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/solaredge_local/ @drobtravels @scheric
/homeassistant/components/solarlog/ @Ernst79 @dontinelli
/tests/components/solarlog/ @Ernst79 @dontinelli
/homeassistant/components/solax/ @squishykid @Darsstar
/tests/components/solax/ @squishykid @Darsstar
/homeassistant/components/solax/ @squishykid
/tests/components/solax/ @squishykid
/homeassistant/components/soma/ @ratsept @sebfortier2288
/tests/components/soma/ @ratsept @sebfortier2288
/homeassistant/components/sonarr/ @ctalkington
@@ -1628,15 +1626,15 @@ build.json @home-assistant/supervisor
/tests/components/valve/ @home-assistant/core
/homeassistant/components/velbus/ @Cereal2nd @brefra
/tests/components/velbus/ @Cereal2nd @brefra
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio
/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio
/homeassistant/components/velux/ @Julius2342 @DeerMaximum
/tests/components/velux/ @Julius2342 @DeerMaximum
/homeassistant/components/venstar/ @garbled1 @jhollowe
/tests/components/venstar/ @garbled1 @jhollowe
/homeassistant/components/versasense/ @imstevenxyz
/homeassistant/components/version/ @ludeeus
/tests/components/version/ @ludeeus
/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak
/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja
/homeassistant/components/vicare/ @CFenner
/tests/components/vicare/ @CFenner
/homeassistant/components/vilfo/ @ManneW
+2 -2
View File
@@ -13,7 +13,7 @@ ENV \
ARG QEMU_CPU
# Install uv
RUN pip3 install uv==0.5.21
RUN pip3 install uv==0.5.8
WORKDIR /usr/src
@@ -55,7 +55,7 @@ RUN \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.8/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.7/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
+1 -1
View File
@@ -308,7 +308,7 @@ class AuthStore:
credentials.data = data
self._async_schedule_save()
async def async_load(self) -> None:
async def async_load(self) -> None: # noqa: C901
"""Load the users."""
if self._loaded:
raise RuntimeError("Auth storage is already loaded")
+9 -4
View File
@@ -4,8 +4,9 @@ from __future__ import annotations
import logging
import types
from typing import Any
from typing import Any, Generic
from typing_extensions import TypeVar
import voluptuous as vol
from voluptuous.humanize import humanize_error
@@ -34,6 +35,12 @@ DATA_REQS: HassKey[set[str]] = HassKey("mfa_auth_module_reqs_processed")
_LOGGER = logging.getLogger(__name__)
_MultiFactorAuthModuleT = TypeVar(
"_MultiFactorAuthModuleT",
bound="MultiFactorAuthModule",
default="MultiFactorAuthModule",
)
class MultiFactorAuthModule:
"""Multi-factor Auth Module of validation function."""
@@ -95,9 +102,7 @@ class MultiFactorAuthModule:
raise NotImplementedError
class SetupFlow[_MultiFactorAuthModuleT: MultiFactorAuthModule = MultiFactorAuthModule](
data_entry_flow.FlowHandler
):
class SetupFlow(data_entry_flow.FlowHandler, Generic[_MultiFactorAuthModuleT]):
"""Handler for the setup flow."""
def __init__(
+1 -1
View File
@@ -11,7 +11,7 @@ import uuid
import attr
from attr import Attribute
from attr.setters import validate
from propcache.api import cached_property
from propcache import cached_property
from homeassistant.const import __version__
from homeassistant.data_entry_flow import FlowContext, FlowResult
+5 -5
View File
@@ -17,12 +17,12 @@ POLICY_SCHEMA = vol.Schema({vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA})
__all__ = [
"POLICY_SCHEMA",
"AbstractPermissions",
"OwnerPermissions",
"PermissionLookup",
"PolicyPermissions",
"PolicyType",
"merge_policies",
"PermissionLookup",
"PolicyType",
"AbstractPermissions",
"PolicyPermissions",
"OwnerPermissions",
]
+6 -2
View File
@@ -5,8 +5,9 @@ from __future__ import annotations
from collections.abc import Mapping
import logging
import types
from typing import Any
from typing import Any, Generic
from typing_extensions import TypeVar
import voluptuous as vol
from voluptuous.humanize import humanize_error
@@ -46,6 +47,8 @@ AUTH_PROVIDER_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
_AuthProviderT = TypeVar("_AuthProviderT", bound="AuthProvider", default="AuthProvider")
class AuthProvider:
"""Provider of user authentication."""
@@ -192,8 +195,9 @@ async def load_auth_provider_module(
return module
class LoginFlow[_AuthProviderT: AuthProvider = AuthProvider](
class LoginFlow(
FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]],
Generic[_AuthProviderT],
):
"""Handler for the login flow."""
+1 -1
View File
@@ -119,7 +119,7 @@ def _extract_backup(
Path(
tempdir,
"extracted",
f"homeassistant.tar{'.gz' if backup_meta['compressed'] else ''}",
f"homeassistant.tar{'.gz' if backup_meta["compressed"] else ''}",
),
gzip=backup_meta["compressed"],
key=password_to_key(restore_content.password)
+1 -1
View File
@@ -31,7 +31,7 @@ def _check_import_call_allowed(mapped_args: dict[str, Any]) -> bool:
def _check_file_allowed(mapped_args: dict[str, Any]) -> bool:
# If the file is in /proc we can ignore it.
args = mapped_args["args"]
path = args[0] if type(args[0]) is str else str(args[0])
path = args[0] if type(args[0]) is str else str(args[0]) # noqa: E721
return path.startswith(ALLOWED_FILE_PREFIXES)
-5
View File
@@ -112,11 +112,6 @@ with contextlib.suppress(ImportError):
# Ensure anyio backend is imported to avoid it being imported in the event loop
from anyio._backends import _asyncio # noqa: F401
with contextlib.suppress(ImportError):
# httpx will import trio if it is installed which does
# blocking I/O in the event loop. We want to avoid that.
import trio # noqa: F401
if TYPE_CHECKING:
from .runner import RuntimeConfig
+1 -1
View File
@@ -26,5 +26,5 @@
"iot_class": "local_push",
"loggers": ["aioacaia"],
"quality_scale": "platinum",
"requirements": ["aioacaia==0.1.14"]
"requirements": ["aioacaia==0.1.13"]
}
+1 -1
View File
@@ -70,7 +70,7 @@ class PulseHub:
async def async_notify_update(self, update_type: aiopulse.UpdateType) -> None:
"""Evaluate entities when hub reports that update has occurred."""
LOGGER.debug("Hub %s updated", update_type.name)
LOGGER.debug("Hub {update_type.name} updated")
if update_type == aiopulse.UpdateType.rollers:
await update_devices(self.hass, self.config_entry, self.api.rollers)
@@ -3,9 +3,9 @@
from __future__ import annotations
import logging
import telnetlib # pylint: disable=deprecated-module
from typing import Final
import telnetlib # pylint: disable=deprecated-module
import voluptuous as vol
from homeassistant.components.device_tracker import (
+2 -5
View File
@@ -34,12 +34,9 @@ from .const import (
SERVICE_REMOVE_URL,
)
SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): vol.Any(cv.url, cv.path)})
SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): cv.url})
SERVICE_ADD_URL_SCHEMA = vol.Schema(
{
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_URL): vol.Any(cv.url, cv.path),
}
{vol.Required(CONF_NAME): cv.string, vol.Required(CONF_URL): cv.url}
)
SERVICE_REFRESH_SCHEMA = vol.Schema(
{vol.Optional(CONF_FORCE, default=False): cv.boolean}
@@ -66,7 +66,7 @@ class AdvantageAirZoneMotion(AdvantageAirZoneEntity, BinarySensorEntity):
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone Motion sensor."""
super().__init__(instance, ac_key, zone_key)
self._attr_name = f"{self._zone['name']} motion"
self._attr_name = f'{self._zone["name"]} motion'
self._attr_unique_id += "-motion"
@property
@@ -84,7 +84,7 @@ class AdvantageAirZoneMyZone(AdvantageAirZoneEntity, BinarySensorEntity):
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone MyZone sensor."""
super().__init__(instance, ac_key, zone_key)
self._attr_name = f"{self._zone['name']} myZone"
self._attr_name = f'{self._zone["name"]} myZone'
self._attr_unique_id += "-myzone"
@property
@@ -103,7 +103,7 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity):
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone Vent Sensor."""
super().__init__(instance, ac_key, zone_key=zone_key)
self._attr_name = f"{self._zone['name']} vent"
self._attr_name = f'{self._zone["name"]} vent'
self._attr_unique_id += "-vent"
@property
@@ -131,7 +131,7 @@ class AdvantageAirZoneSignal(AdvantageAirZoneEntity, SensorEntity):
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone wireless signal sensor."""
super().__init__(instance, ac_key, zone_key)
self._attr_name = f"{self._zone['name']} signal"
self._attr_name = f'{self._zone["name"]} signal'
self._attr_unique_id += "-signal"
@property
@@ -165,7 +165,7 @@ class AdvantageAirZoneTemp(AdvantageAirZoneEntity, SensorEntity):
def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
"""Initialize an Advantage Air Zone Temp Sensor."""
super().__init__(instance, ac_key, zone_key)
self._attr_name = f"{self._zone['name']} temperature"
self._attr_name = f'{self._zone["name"]} temperature'
self._attr_unique_id += "-temp"
@property
@@ -18,9 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirGradientConfigEntry
from .const import DOMAIN
from .coordinator import AirGradientCoordinator
from .entity import AirGradientEntity, exception_handler
PARALLEL_UPDATES = 1
from .entity import AirGradientEntity
@dataclass(frozen=True, kw_only=True)
@@ -102,7 +100,6 @@ class AirGradientButton(AirGradientEntity, ButtonEntity):
self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}-{description.key}"
@exception_handler
async def async_press(self) -> None:
"""Press the button."""
await self.entity_description.press_fn(self.coordinator.client)
@@ -1,6 +1,5 @@
"""Config flow for Airgradient."""
from collections.abc import Mapping
from typing import Any
from airgradient import (
@@ -12,15 +11,10 @@ from airgradient import (
from awesomeversion import AwesomeVersion
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
SOURCE_USER,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MODEL
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -43,7 +37,7 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
await self.client.set_configuration_control(ConfigurationControl.LOCAL)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
self.data[CONF_HOST] = host = discovery_info.host
@@ -101,18 +95,10 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(
current_measures.serial_number, raise_on_progress=False
)
if self.source == SOURCE_USER:
self._abort_if_unique_id_configured()
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
self._abort_if_unique_id_configured()
await self.set_configuration_source()
if self.source == SOURCE_USER:
return self.async_create_entry(
title=current_measures.model,
data={CONF_HOST: user_input[CONF_HOST]},
)
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
return self.async_create_entry(
title=current_measures.model,
data={CONF_HOST: user_input[CONF_HOST]},
)
return self.async_show_form(
@@ -120,9 +106,3 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
errors=errors,
)
async def async_step_reconfigure(
self, user_input: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reconfiguration."""
return await self.async_step_user()
@@ -55,11 +55,7 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]):
measures = await self.client.get_current_measures()
config = await self.client.get_config()
except AirGradientError as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": str(error)},
) from error
raise UpdateFailed(error) from error
if measures.firmware_version != self._current_version:
device_registry = dr.async_get(self.hass)
device_entry = device_registry.async_get_device(
+1 -33
View File
@@ -1,11 +1,7 @@
"""Base class for AirGradient entities."""
from collections.abc import Callable, Coroutine
from typing import Any, Concatenate
from airgradient import get_model_name
from airgradient import AirGradientConnectionError, AirGradientError, get_model_name
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -30,31 +26,3 @@ class AirGradientEntity(CoordinatorEntity[AirGradientCoordinator]):
serial_number=coordinator.serial_number,
sw_version=measures.firmware_version,
)
def exception_handler[_EntityT: AirGradientEntity, **_P](
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate AirGradient calls to handle exceptions.
A decorator that wraps the passed in function, catches AirGradient errors.
"""
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
try:
await func(self, *args, **kwargs)
except AirGradientConnectionError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(error)},
) from error
except AirGradientError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unknown_error",
translation_placeholders={"error": str(error)},
) from error
return handler
@@ -19,9 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirGradientConfigEntry
from .const import DOMAIN
from .coordinator import AirGradientCoordinator
from .entity import AirGradientEntity, exception_handler
PARALLEL_UPDATES = 1
from .entity import AirGradientEntity
@dataclass(frozen=True, kw_only=True)
@@ -123,7 +121,6 @@ class AirGradientNumber(AirGradientEntity, NumberEntity):
"""Return the state of the number."""
return self.entity_description.value_fn(self.coordinator.data.config)
@exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Set the selected value."""
await self.entity_description.set_value_fn(self.coordinator.client, int(value))
@@ -29,7 +29,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: done
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
@@ -38,7 +38,7 @@ rules:
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: |
@@ -68,9 +68,9 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
@@ -19,9 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirGradientConfigEntry
from .const import DOMAIN, PM_STANDARD, PM_STANDARD_REVERSE
from .coordinator import AirGradientCoordinator
from .entity import AirGradientEntity, exception_handler
PARALLEL_UPDATES = 1
from .entity import AirGradientEntity
@dataclass(frozen=True, kw_only=True)
@@ -218,7 +216,6 @@ class AirGradientSelect(AirGradientEntity, SelectEntity):
"""Return the state of the select."""
return self.entity_description.value_fn(self.coordinator.data.config)
@exception_handler
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.entity_description.set_value_fn(self.coordinator.client, option)
@@ -35,8 +35,6 @@ from .const import PM_STANDARD, PM_STANDARD_REVERSE
from .coordinator import AirGradientCoordinator
from .entity import AirGradientEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirGradientMeasurementSensorEntityDescription(SensorEntityDescription):
@@ -139,15 +137,6 @@ MEASUREMENT_SENSOR_TYPES: tuple[AirGradientMeasurementSensorEntityDescription, .
entity_registry_enabled_default=False,
value_fn=lambda status: status.raw_total_volatile_organic_component,
),
AirGradientMeasurementSensorEntityDescription(
key="pm02_raw",
translation_key="raw_pm02",
device_class=SensorDeviceClass.PM25,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda status: status.raw_pm02,
),
)
CONFIG_SENSOR_TYPES: tuple[AirGradientConfigSensorEntityDescription, ...] = (
@@ -17,9 +17,7 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"invalid_version": "This firmware version is unsupported. Please upgrade the firmware of the device to at least version 3.1.1.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
"invalid_version": "This firmware version is unsupported. Please upgrade the firmware of the device to at least version 3.1.1."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -121,9 +119,6 @@
"raw_nitrogen": {
"name": "Raw NOx"
},
"raw_pm02": {
"name": "Raw PM2.5"
},
"display_pm_standard": {
"name": "[%key:component::airgradient::entity::select::display_pm_standard::name%]",
"state": {
@@ -167,16 +162,5 @@
"name": "Post data to Airgradient"
}
}
},
"exceptions": {
"communication_error": {
"message": "An error occurred while communicating with the Airgradient device: {error}"
},
"unknown_error": {
"message": "An unknown error occurred while communicating with the Airgradient device: {error}"
},
"update_error": {
"message": "An error occurred while communicating with the Airgradient device: {error}"
}
}
}
@@ -20,9 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirGradientConfigEntry
from .const import DOMAIN
from .coordinator import AirGradientCoordinator
from .entity import AirGradientEntity, exception_handler
PARALLEL_UPDATES = 1
from .entity import AirGradientEntity
@dataclass(frozen=True, kw_only=True)
@@ -101,13 +99,11 @@ class AirGradientSwitch(AirGradientEntity, SwitchEntity):
"""Return the state of the switch."""
return self.entity_description.value_fn(self.coordinator.data.config)
@exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_value_fn(self.coordinator.client, True)
await self.coordinator.async_request_refresh()
@exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_value_fn(self.coordinator.client, False)
@@ -2,7 +2,7 @@
from datetime import timedelta
from propcache.api import cached_property
from propcache import cached_property
from homeassistant.components.update import UpdateDeviceClass, UpdateEntity
from homeassistant.core import HomeAssistant
@@ -11,7 +11,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AirGradientConfigEntry, AirGradientCoordinator
from .entity import AirGradientEntity
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(hours=1)
+12 -9
View File
@@ -21,6 +21,7 @@ from .const import (
ATTR_API_CAT_DESCRIPTION,
ATTR_API_CAT_LEVEL,
ATTR_API_CATEGORY,
ATTR_API_PM25,
ATTR_API_POLLUTANT,
ATTR_API_REPORT_DATE,
ATTR_API_REPORT_HOUR,
@@ -90,16 +91,18 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
max_aqi_desc = obv[ATTR_API_CATEGORY][ATTR_API_CAT_DESCRIPTION]
max_aqi_poll = pollutant
# Copy Report Details
data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE]
data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR]
data[ATTR_API_REPORT_TZ] = obv[ATTR_API_REPORT_TZ]
# Copy other data from PM2.5 Value
if obv[ATTR_API_AQI_PARAM] == ATTR_API_PM25:
# Copy Report Details
data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE]
data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR]
data[ATTR_API_REPORT_TZ] = obv[ATTR_API_REPORT_TZ]
# Copy Station Details
data[ATTR_API_STATE] = obv[ATTR_API_STATE]
data[ATTR_API_STATION] = obv[ATTR_API_STATION]
data[ATTR_API_STATION_LATITUDE] = obv[ATTR_API_STATION_LATITUDE]
data[ATTR_API_STATION_LONGITUDE] = obv[ATTR_API_STATION_LONGITUDE]
# Copy Station Details
data[ATTR_API_STATE] = obv[ATTR_API_STATE]
data[ATTR_API_STATION] = obv[ATTR_API_STATION]
data[ATTR_API_STATION_LATITUDE] = obv[ATTR_API_STATION_LATITUDE]
data[ATTR_API_STATION_LONGITUDE] = obv[ATTR_API_STATION_LONGITUDE]
# Store Overall AQI
data[ATTR_API_AQI] = max_aqi
+2 -1
View File
@@ -155,7 +155,8 @@ class AirthingsHeaterEnergySensor(
self._id = airthings_device.device_id
self._attr_device_info = DeviceInfo(
configuration_url=(
f"https://dashboard.airthings.com/devices/{airthings_device.device_id}"
"https://dashboard.airthings.com/devices/"
f"{airthings_device.device_id}"
),
identifiers={(DOMAIN, airthings_device.device_id)},
name=airthings_device.name,
@@ -67,21 +67,18 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
"humidity": SensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
"pressure": SensorEntityDescription(
key="pressure",
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
native_unit_of_measurement=UnitOfPressure.MBAR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
"battery": SensorEntityDescription(
key="battery",
@@ -89,28 +86,24 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=0,
),
"co2": SensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"voc": SensorEntityDescription(
key="voc",
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"illuminance": SensorEntityDescription(
key="illuminance",
translation_key="illuminance",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
}
@@ -50,7 +50,7 @@ SENSOR_DESCRIPTIONS = (
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda settings, status, measurements, history: int(
history.get(
f"Outdoor {'AQI(US)' if settings['is_aqi_usa'] else 'AQI(CN)'}", -1
f'Outdoor {"AQI(US)" if settings["is_aqi_usa"] else "AQI(CN)"}', -1
)
),
translation_key="outdoor_air_quality_index",
+2 -48
View File
@@ -5,14 +5,7 @@ from __future__ import annotations
import logging
from typing import Any
from aioairzone.const import (
AZD_FIRMWARE,
AZD_FULL_NAME,
AZD_MAC,
AZD_MODEL,
AZD_WEBSERVER,
DEFAULT_SYSTEM_ID,
)
from aioairzone.const import AZD_MAC, AZD_WEBSERVER, DEFAULT_SYSTEM_ID
from aioairzone.localapi import AirzoneLocalApi, ConnectionOptions
from homeassistant.config_entries import ConfigEntry
@@ -24,7 +17,6 @@ from homeassistant.helpers import (
entity_registry as er,
)
from .const import DOMAIN, MANUFACTURER
from .coordinator import AirzoneUpdateCoordinator
PLATFORMS: list[Platform] = [
@@ -86,7 +78,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b
options = ConnectionOptions(
entry.data[CONF_HOST],
entry.data[CONF_PORT],
entry.data[CONF_ID],
entry.data.get(CONF_ID, DEFAULT_SYSTEM_ID),
)
airzone = AirzoneLocalApi(aiohttp_client.async_get_clientsession(hass), options)
@@ -96,22 +88,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b
entry.runtime_data = coordinator
device_registry = dr.async_get(hass)
ws_data: dict[str, Any] | None = coordinator.data.get(AZD_WEBSERVER)
if ws_data is not None:
mac = ws_data.get(AZD_MAC, "")
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
identifiers={(DOMAIN, f"{entry.entry_id}_ws")},
manufacturer=MANUFACTURER,
model=ws_data.get(AZD_MODEL),
name=ws_data.get(AZD_FULL_NAME),
sw_version=ws_data.get(AZD_FIRMWARE),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -120,25 +96,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> bool:
"""Migrate an old entry."""
if entry.version == 1 and entry.minor_version < 2:
# Add missing CONF_ID
system_id = entry.data.get(CONF_ID, DEFAULT_SYSTEM_ID)
new_data = entry.data.copy()
new_data[CONF_ID] = system_id
hass.config_entries.async_update_entry(
entry,
data=new_data,
minor_version=2,
)
_LOGGER.info(
"Migration to configuration version %s.%s successful",
entry.version,
entry.minor_version,
)
return True
@@ -10,12 +10,12 @@ from aioairzone.exceptions import AirzoneError, InvalidSystem
from aioairzone.localapi import AirzoneLocalApi, ConnectionOptions
import voluptuous as vol
from homeassistant.components import dhcp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_ID, CONF_PORT
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DOMAIN
@@ -44,7 +44,6 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN):
_discovered_ip: str | None = None
_discovered_mac: str | None = None
MINOR_VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -54,9 +53,6 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {}
if user_input is not None:
if CONF_ID not in user_input:
user_input[CONF_ID] = DEFAULT_SYSTEM_ID
self._async_abort_entries_match(user_input)
airzone = AirzoneLocalApi(
@@ -64,7 +60,7 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN):
ConnectionOptions(
user_input[CONF_HOST],
user_input[CONF_PORT],
user_input[CONF_ID],
user_input.get(CONF_ID, DEFAULT_SYSTEM_ID),
),
)
@@ -88,9 +84,6 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN):
)
title = f"Airzone {user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
if user_input[CONF_ID] != DEFAULT_SYSTEM_ID:
title += f" #{user_input[CONF_ID]}"
return self.async_create_entry(title=title, data=user_input)
return self.async_show_form(
@@ -100,7 +93,7 @@ class AirZoneConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
self, discovery_info: dhcp.DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
self._discovered_ip = discovery_info.ip
+2 -4
View File
@@ -68,9 +68,8 @@ class AirzoneSystemEntity(AirzoneEntity):
model=self.get_airzone_value(AZD_MODEL),
name=f"System {self.system_id}",
sw_version=self.get_airzone_value(AZD_FIRMWARE),
via_device=(DOMAIN, f"{entry.entry_id}_ws"),
)
if AZD_WEBSERVER in self.coordinator.data:
self._attr_device_info["via_device"] = (DOMAIN, f"{entry.entry_id}_ws")
self._attr_unique_id = entry.unique_id or entry.entry_id
@property
@@ -103,9 +102,8 @@ class AirzoneHotWaterEntity(AirzoneEntity):
manufacturer=MANUFACTURER,
model="DHW",
name=self.get_airzone_value(AZD_NAME),
via_device=(DOMAIN, f"{entry.entry_id}_ws"),
)
if AZD_WEBSERVER in self.coordinator.data:
self._attr_device_info["via_device"] = (DOMAIN, f"{entry.entry_id}_ws")
self._attr_unique_id = entry.unique_id or entry.entry_id
def get_airzone_value(self, key: str) -> Any:
@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==0.9.9"]
"requirements": ["aioairzone==0.9.7"]
}
@@ -7,7 +7,7 @@ from datetime import timedelta
import logging
from typing import TYPE_CHECKING, Any, Final, final
from propcache.api import cached_property
from propcache import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
+14 -19
View File
@@ -474,30 +474,25 @@ class ClimateCapabilities(AlexaEntity):
# If we support two modes, one being off, we allow turning on too.
supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if (
(
self.entity.domain == climate.DOMAIN
and climate.HVACMode.OFF
in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or [])
)
or (
self.entity.domain == climate.DOMAIN
and (
supported_features
& (
climate.ClimateEntityFeature.TURN_ON
| climate.ClimateEntityFeature.TURN_OFF
)
self.entity.domain == climate.DOMAIN
and climate.HVACMode.OFF
in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or [])
or self.entity.domain == climate.DOMAIN
and (
supported_features
& (
climate.ClimateEntityFeature.TURN_ON
| climate.ClimateEntityFeature.TURN_OFF
)
)
or (
self.entity.domain == water_heater.DOMAIN
and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF)
)
or self.entity.domain == water_heater.DOMAIN
and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF)
):
yield AlexaPowerController(self.entity)
if self.entity.domain == climate.DOMAIN or (
self.entity.domain == water_heater.DOMAIN
if (
self.entity.domain == climate.DOMAIN
or self.entity.domain == water_heater.DOMAIN
and (
supported_features
& water_heater.WaterHeaterEntityFeature.OPERATION_MODE
@@ -317,8 +317,9 @@ async def async_enable_proactive_mode(
if should_doorbell:
old_state = data["old_state"]
if new_state.domain == event.DOMAIN or (
new_state.state == STATE_ON
if (
new_state.domain == event.DOMAIN
or new_state.state == STATE_ON
and (old_state is None or old_state.state != STATE_ON)
):
await async_send_doorbell_event_message(
@@ -14,6 +14,7 @@ from androidtvremote2 import (
)
import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntry,
@@ -30,7 +31,6 @@ from homeassistant.helpers.selector import (
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_APP_ICON, CONF_APP_NAME, CONF_APPS, CONF_ENABLE_IME, DOMAIN
from .helpers import create_api, get_enable_ime
@@ -142,7 +142,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
_LOGGER.debug("Android TV device found via zeroconf: %s", discovery_info)
@@ -27,7 +27,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import device_registry as dr, intent, llm, template
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import ulid as ulid_util
from homeassistant.util import ulid
from . import AnthropicConfigEntry
from .const import (
@@ -164,7 +164,7 @@ class AnthropicConversationEntity(
]
if user_input.conversation_id is None:
conversation_id = ulid_util.ulid_now()
conversation_id = ulid.ulid_now()
messages = []
elif user_input.conversation_id in self.history:
@@ -177,8 +177,8 @@ class AnthropicConversationEntity(
# a new conversation was started. If the user picks their own, they
# want to track a conversation and we respect it.
try:
ulid_util.ulid_to_bytes(user_input.conversation_id)
conversation_id = ulid_util.ulid_now()
ulid.ulid_to_bytes(user_input.conversation_id)
conversation_id = ulid.ulid_now()
except ValueError:
conversation_id = user_input.conversation_id
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["anthropic==0.44.0"]
"requirements": ["anthropic==0.31.2"]
}
@@ -44,10 +44,7 @@ class APCUPSdData(dict[str, str]):
@property
def serial_no(self) -> str | None:
"""Return the unique serial number of the UPS, if available."""
sn = self.get("SERIALNO")
# We had user reports that some UPS models simply return "Blank" as serial number, in
# which case we fall back to `None` to indicate that it is actually not available.
return None if sn == "Blank" else sn
return self.get("SERIALNO")
class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]):
@@ -34,7 +34,6 @@ from homeassistant.helpers.schema_config_entry_flow import (
SchemaFlowFormStep,
SchemaOptionsFlowHandler,
)
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN
@@ -205,7 +204,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle device found via zeroconf."""
if discovery_info.ip_address.version == 6:
@@ -38,7 +38,7 @@ from homeassistant.loader import (
from homeassistant.util import slugify
from homeassistant.util.hass_dict import HassKey
__all__ = ["AuthorizationServer", "ClientCredential", "async_import_client_credential"]
__all__ = ["ClientCredential", "AuthorizationServer", "async_import_client_credential"]
_LOGGER = logging.getLogger(__name__)
@@ -50,7 +50,7 @@ async def async_setup_entry(
descriptions: list[AprilaireHumidifierDescription] = []
if coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) in (1, 2):
if coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) in (0, 1, 2):
descriptions.append(
AprilaireHumidifierDescription(
key="humidifier",
@@ -67,7 +67,7 @@ async def async_setup_entry(
)
)
if coordinator.data.get(Attribute.DEHUMIDIFICATION_AVAILABLE) == 1:
if coordinator.data.get(Attribute.DEHUMIDIFICATION_AVAILABLE) in (0, 1):
descriptions.append(
AprilaireHumidifierDescription(
key="dehumidifier",
@@ -9,10 +9,10 @@ from arcam.fmj.client import Client, ConnectionFailed
from arcam.fmj.utils import get_uniqueid_from_host, get_uniqueid_from_udn
import voluptuous as vol
from homeassistant.components import ssdp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo
from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN
@@ -88,12 +88,12 @@ class ArcamFmjFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def async_step_ssdp(
self, discovery_info: SsdpServiceInfo
self, discovery_info: ssdp.SsdpServiceInfo
) -> ConfigFlowResult:
"""Handle a discovered device."""
host = str(urlparse(discovery_info.ssdp_location).hostname)
port = DEFAULT_PORT
uuid = get_uniqueid_from_udn(discovery_info.upnp[ATTR_UPNP_UDN])
uuid = get_uniqueid_from_udn(discovery_info.upnp[ssdp.ATTR_UPNP_UDN])
if not uuid:
return self.async_abort(reason="cannot_connect")
@@ -46,24 +46,24 @@ from .websocket_api import async_register_websocket_api
__all__ = (
"DOMAIN",
"EVENT_RECORDING",
"OPTION_PREFERRED",
"SAMPLES_PER_CHUNK",
"SAMPLE_CHANNELS",
"SAMPLE_RATE",
"SAMPLE_WIDTH",
"async_create_default_pipeline",
"async_get_pipelines",
"async_migrate_engine",
"async_setup",
"async_pipeline_from_audio_stream",
"async_update_pipeline",
"AudioSettings",
"Pipeline",
"PipelineEvent",
"PipelineEventType",
"PipelineNotFound",
"WakeWordSettings",
"async_create_default_pipeline",
"async_get_pipelines",
"async_migrate_engine",
"async_pipeline_from_audio_stream",
"async_setup",
"async_update_pipeline",
"EVENT_RECORDING",
"OPTION_PREFERRED",
"SAMPLES_PER_CHUNK",
"SAMPLE_RATE",
"SAMPLE_WIDTH",
"SAMPLE_CHANNELS",
)
CONFIG_SCHEMA = vol.Schema(
@@ -50,7 +50,6 @@ from homeassistant.util import (
language as language_util,
ulid as ulid_util,
)
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.limited_size_dict import LimitedSizeDict
from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadSpeexEnhancer
@@ -92,8 +91,6 @@ ENGINE_LANGUAGE_PAIRS = (
("tts_engine", "tts_language"),
)
KEY_ASSIST_PIPELINE: HassKey[PipelineData] = HassKey(DOMAIN)
def validate_language(data: dict[str, Any]) -> Any:
"""Validate language settings."""
@@ -251,7 +248,7 @@ async def async_create_default_pipeline(
The default pipeline will use the homeassistant conversation agent and the
specified stt / tts engines.
"""
pipeline_data = hass.data[KEY_ASSIST_PIPELINE]
pipeline_data: PipelineData = hass.data[DOMAIN]
pipeline_store = pipeline_data.pipeline_store
pipeline_settings = _async_resolve_default_pipeline_settings(
hass,
@@ -286,7 +283,7 @@ def _async_get_pipeline_from_conversation_entity(
@callback
def async_get_pipeline(hass: HomeAssistant, pipeline_id: str | None = None) -> Pipeline:
"""Get a pipeline by id or the preferred pipeline."""
pipeline_data = hass.data[KEY_ASSIST_PIPELINE]
pipeline_data: PipelineData = hass.data[DOMAIN]
if pipeline_id is None:
# A pipeline was not specified, use the preferred one
@@ -309,7 +306,7 @@ def async_get_pipeline(hass: HomeAssistant, pipeline_id: str | None = None) -> P
@callback
def async_get_pipelines(hass: HomeAssistant) -> list[Pipeline]:
"""Get all pipelines."""
pipeline_data = hass.data[KEY_ASSIST_PIPELINE]
pipeline_data: PipelineData = hass.data[DOMAIN]
return list(pipeline_data.pipeline_store.data.values())
@@ -332,7 +329,7 @@ async def async_update_pipeline(
prefer_local_intents: bool | UndefinedType = UNDEFINED,
) -> None:
"""Update a pipeline."""
pipeline_data = hass.data[KEY_ASSIST_PIPELINE]
pipeline_data: PipelineData = hass.data[DOMAIN]
updates: dict[str, Any] = pipeline.to_json()
updates.pop("id")
@@ -590,7 +587,7 @@ class PipelineRun:
):
raise InvalidPipelineStagesError(self.start_stage, self.end_stage)
pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE]
pipeline_data: PipelineData = self.hass.data[DOMAIN]
if self.pipeline.id not in pipeline_data.pipeline_debug:
pipeline_data.pipeline_debug[self.pipeline.id] = LimitedSizeDict(
size_limit=STORED_PIPELINE_RUNS
@@ -618,7 +615,7 @@ class PipelineRun:
def process_event(self, event: PipelineEvent) -> None:
"""Log an event and call listener."""
self.event_callback(event)
pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE]
pipeline_data: PipelineData = self.hass.data[DOMAIN]
if self.id not in pipeline_data.pipeline_debug[self.pipeline.id]:
# This run has been evicted from the logged pipeline runs already
return
@@ -653,7 +650,7 @@ class PipelineRun:
)
)
pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE]
pipeline_data: PipelineData = self.hass.data[DOMAIN]
pipeline_data.pipeline_runs.remove_run(self)
async def prepare_wake_word_detection(self) -> None:
@@ -1024,18 +1021,9 @@ class PipelineRun:
raise RuntimeError("Recognize intent was not prepared")
if self.pipeline.conversation_language == MATCH_ALL:
# LLMs support all languages ('*') so use languages from the
# pipeline for intent fallback.
#
# We prioritize the STT and TTS languages because they may be more
# specific, such as "zh-CN" instead of just "zh". This is necessary
# for languages whose intents are split out by region when
# preferring local intent matching.
input_language = (
self.pipeline.stt_language
or self.pipeline.tts_language
or self.pipeline.language
)
# LLMs support all languages ('*') so use pipeline language for
# intent fallback.
input_language = self.pipeline.language
else:
input_language = self.pipeline.conversation_language
@@ -1065,8 +1053,7 @@ class PipelineRun:
)
processed_locally = self.intent_agent == conversation.HOME_ASSISTANT_AGENT
agent_id = user_input.agent_id
intent_response: intent.IntentResponse | None = None
conversation_result: conversation.ConversationResult | None = None
if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT:
# Sentence triggers override conversation agent
if (
@@ -1076,12 +1063,14 @@ class PipelineRun:
)
) is not None:
# Sentence trigger matched
agent_id = "sentence_trigger"
intent_response = intent.IntentResponse(
trigger_response = intent.IntentResponse(
self.pipeline.conversation_language
)
intent_response.async_set_speech(trigger_response_text)
trigger_response.async_set_speech(trigger_response_text)
conversation_result = conversation.ConversationResult(
response=trigger_response,
conversation_id=user_input.conversation_id,
)
# Try local intents first, if preferred.
elif self.pipeline.prefer_local_intents and (
intent_response := await conversation.async_handle_intents(
@@ -1089,31 +1078,13 @@ class PipelineRun:
)
):
# Local intent matched
agent_id = conversation.HOME_ASSISTANT_AGENT
processed_locally = True
# It was already handled, create response and add to chat history
if intent_response is not None:
async with conversation.async_get_chat_session(
self.hass, user_input
) as chat_session:
speech: str = intent_response.speech.get("plain", {}).get(
"speech", ""
)
chat_session.async_add_message(
conversation.ChatMessage(
role="assistant",
agent_id=agent_id,
content=speech,
native=intent_response,
)
)
conversation_result = conversation.ConversationResult(
response=intent_response,
conversation_id=chat_session.conversation_id,
conversation_id=user_input.conversation_id,
)
processed_locally = True
else:
if conversation_result is None:
# Fall back to pipeline conversation agent
conversation_result = await conversation.async_converse(
hass=self.hass,
@@ -1124,10 +1095,6 @@ class PipelineRun:
language=user_input.language,
agent_id=user_input.agent_id,
)
speech = conversation_result.response.speech.get("plain", {}).get(
"speech", ""
)
except Exception as src_error:
_LOGGER.exception("Unexpected error during intent recognition")
raise IntentRecognitionError(
@@ -1147,6 +1114,10 @@ class PipelineRun:
)
)
speech: str = conversation_result.response.speech.get("plain", {}).get(
"speech", ""
)
return speech
async def prepare_text_to_speech(self) -> None:
@@ -1247,7 +1218,7 @@ class PipelineRun:
return
# Forward to device audio capture
pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE]
pipeline_data: PipelineData = self.hass.data[DOMAIN]
audio_queue = pipeline_data.device_audio_queues.get(self._device_id)
if audio_queue is None:
return
@@ -1492,9 +1463,9 @@ class PipelineInput:
if stt_audio_buffer:
# Send audio in the buffer first to speech-to-text, then move on to stt_stream.
# This is basically an async itertools.chain.
async def buffer_then_audio_stream() -> AsyncGenerator[
EnhancedAudioChunk
]:
async def buffer_then_audio_stream() -> (
AsyncGenerator[EnhancedAudioChunk]
):
# Buffered audio
for chunk in stt_audio_buffer:
yield chunk
@@ -1904,7 +1875,7 @@ class PipelineStore(Store[SerializedPipelineStorageCollection]):
return old_data
@singleton(KEY_ASSIST_PIPELINE, async_=True)
@singleton(DOMAIN)
async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData:
"""Set up the pipeline storage collection."""
pipeline_store = PipelineStorageCollection(
@@ -9,8 +9,8 @@ from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import collection, entity_registry as er, restore_state
from .const import OPTION_PREFERRED
from .pipeline import KEY_ASSIST_PIPELINE, AssistDevice
from .const import DOMAIN, OPTION_PREFERRED
from .pipeline import AssistDevice, PipelineData, PipelineStorageCollection
from .vad import VadSensitivity
@@ -30,7 +30,7 @@ def get_chosen_pipeline(
if state is None or state.state == OPTION_PREFERRED:
return None
pipeline_store = hass.data[KEY_ASSIST_PIPELINE].pipeline_store
pipeline_store: PipelineStorageCollection = hass.data[DOMAIN].pipeline_store
return next(
(item.id for item in pipeline_store.async_items() if item.name == state.state),
None,
@@ -80,7 +80,7 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
"""When entity is added to Home Assistant."""
await super().async_added_to_hass()
pipeline_data = self.hass.data[KEY_ASSIST_PIPELINE]
pipeline_data: PipelineData = self.hass.data[DOMAIN]
pipeline_store = pipeline_data.pipeline_store
self.async_on_remove(
pipeline_store.async_add_change_set_listener(self._pipelines_updated)
@@ -116,7 +116,9 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
@callback
def _update_options(self) -> None:
"""Handle pipeline update."""
pipeline_store = self.hass.data[KEY_ASSIST_PIPELINE].pipeline_store
pipeline_store: PipelineStorageCollection = self.hass.data[
DOMAIN
].pipeline_store
options = [OPTION_PREFERRED]
options.extend(sorted(item.name for item in pipeline_store.async_items()))
self._attr_options = options
@@ -1,6 +1,9 @@
"""Assist pipeline Websocket API."""
import asyncio
# Suppressing disable=deprecated-module is needed for Python 3.11
import audioop # pylint: disable=deprecated-module
import base64
from collections.abc import AsyncGenerator, Callable
import contextlib
@@ -8,7 +11,6 @@ import logging
import math
from typing import Any, Final
import audioop # pylint: disable=deprecated-module
import voluptuous as vol
from homeassistant.components import conversation, stt, tts, websocket_api
@@ -20,6 +22,7 @@ from homeassistant.util import language as language_util
from .const import (
DEFAULT_PIPELINE_TIMEOUT,
DEFAULT_WAKE_WORD_TIMEOUT,
DOMAIN,
EVENT_RECORDING,
SAMPLE_CHANNELS,
SAMPLE_RATE,
@@ -27,9 +30,9 @@ from .const import (
)
from .error import PipelineNotFound
from .pipeline import (
KEY_ASSIST_PIPELINE,
AudioSettings,
DeviceAudioQueue,
PipelineData,
PipelineError,
PipelineEvent,
PipelineEventType,
@@ -281,7 +284,7 @@ def websocket_list_runs(
msg: dict[str, Any],
) -> None:
"""List pipeline runs for which debug data is available."""
pipeline_data = hass.data[KEY_ASSIST_PIPELINE]
pipeline_data: PipelineData = hass.data[DOMAIN]
pipeline_id = msg["pipeline_id"]
if pipeline_id not in pipeline_data.pipeline_debug:
@@ -317,7 +320,7 @@ def websocket_list_devices(
msg: dict[str, Any],
) -> None:
"""List assist devices."""
pipeline_data = hass.data[KEY_ASSIST_PIPELINE]
pipeline_data: PipelineData = hass.data[DOMAIN]
ent_reg = er.async_get(hass)
connection.send_result(
msg["id"],
@@ -348,7 +351,7 @@ def websocket_get_run(
msg: dict[str, Any],
) -> None:
"""Get debug data for a pipeline run."""
pipeline_data = hass.data[KEY_ASSIST_PIPELINE]
pipeline_data: PipelineData = hass.data[DOMAIN]
pipeline_id = msg["pipeline_id"]
pipeline_run_id = msg["pipeline_run_id"]
@@ -453,7 +456,7 @@ async def websocket_device_capture(
msg: dict[str, Any],
) -> None:
"""Capture raw audio from a satellite device and forward to client."""
pipeline_data = hass.data[KEY_ASSIST_PIPELINE]
pipeline_data: PipelineData = hass.data[DOMAIN]
device_id = msg["device_id"]
# Number of seconds to record audio in wall clock time
@@ -30,8 +30,8 @@ from .websocket_api import async_register_websocket_api
__all__ = [
"DOMAIN",
"AssistSatelliteAnnouncement",
"AssistSatelliteConfiguration",
"AssistSatelliteEntity",
"AssistSatelliteConfiguration",
"AssistSatelliteEntityDescription",
"AssistSatelliteEntityFeature",
"AssistSatelliteWakeWord",
@@ -96,11 +96,7 @@ class AssistSatelliteAnnouncement:
media_id: str
"""Media ID to be played."""
original_media_id: str
"""The raw media ID before processing."""
media_id_source: Literal["url", "media_id", "tts"]
"""Source of the media ID."""
class AssistSatelliteEntity(entity.Entity):
@@ -191,10 +187,47 @@ class AssistSatelliteEntity(entity.Entity):
"""
await self._cancel_running_pipeline()
media_id_source: Literal["url", "media_id", "tts"] | None = None
if message is None:
message = ""
announcement = await self._resolve_announcement_media_id(message, media_id)
if not media_id:
media_id_source = "tts"
# Synthesize audio and get URL
pipeline_id = self._resolve_pipeline()
pipeline = async_get_pipeline(self.hass, pipeline_id)
tts_options: dict[str, Any] = {}
if pipeline.tts_voice is not None:
tts_options[tts.ATTR_VOICE] = pipeline.tts_voice
if self.tts_options is not None:
tts_options.update(self.tts_options)
media_id = tts_generate_media_source_id(
self.hass,
message,
engine=pipeline.tts_engine,
language=pipeline.tts_language,
options=tts_options,
)
if media_source.is_media_source_id(media_id):
if not media_id_source:
media_id_source = "media_id"
media = await media_source.async_resolve_media(
self.hass,
media_id,
None,
)
media_id = media.url
if not media_id_source:
media_id_source = "url"
# Resolve to full URL
media_id = async_process_play_media_url(self.hass, media_id)
if self._is_announcing:
raise SatelliteBusyError
@@ -204,7 +237,9 @@ class AssistSatelliteEntity(entity.Entity):
try:
# Block until announcement is finished
await self.async_announce(announcement)
await self.async_announce(
AssistSatelliteAnnouncement(message, media_id, media_id_source)
)
finally:
self._is_announcing = False
self._set_state(AssistSatelliteState.IDLE)
@@ -393,54 +428,3 @@ class AssistSatelliteEntity(entity.Entity):
vad_sensitivity = vad.VadSensitivity(vad_sensitivity_state.state)
return vad.VadSensitivity.to_seconds(vad_sensitivity)
async def _resolve_announcement_media_id(
self, message: str, media_id: str | None
) -> AssistSatelliteAnnouncement:
"""Resolve the media ID."""
media_id_source: Literal["url", "media_id", "tts"] | None = None
if media_id:
original_media_id = media_id
else:
media_id_source = "tts"
# Synthesize audio and get URL
pipeline_id = self._resolve_pipeline()
pipeline = async_get_pipeline(self.hass, pipeline_id)
tts_options: dict[str, Any] = {}
if pipeline.tts_voice is not None:
tts_options[tts.ATTR_VOICE] = pipeline.tts_voice
if self.tts_options is not None:
tts_options.update(self.tts_options)
media_id = tts_generate_media_source_id(
self.hass,
message,
engine=pipeline.tts_engine,
language=pipeline.tts_language,
options=tts_options,
)
original_media_id = media_id
if media_source.is_media_source_id(media_id):
if not media_id_source:
media_id_source = "media_id"
media = await media_source.async_resolve_media(
self.hass,
media_id,
None,
)
media_id = media.url
if not media_id_source:
media_id_source = "url"
# Resolve to full URL
media_id = async_process_play_media_url(self.hass, media_id)
return AssistSatelliteAnnouncement(
message, media_id, original_media_id, media_id_source
)
@@ -1,69 +0,0 @@
"""Assist Satellite intents."""
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er, intent
from .const import DOMAIN, AssistSatelliteEntityFeature
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Set up the intents."""
intent.async_register(hass, BroadcastIntentHandler())
class BroadcastIntentHandler(intent.IntentHandler):
"""Broadcast a message."""
intent_type = intent.INTENT_BROADCAST
description = "Broadcast a message through the home"
@property
def slot_schema(self) -> dict | None:
"""Return a slot schema."""
return {vol.Required("message"): str}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Broadcast a message."""
hass = intent_obj.hass
ent_reg = er.async_get(hass)
# Find all assist satellite entities that are not the one invoking the intent
entities = {
entity: entry
for entity in hass.states.async_entity_ids(DOMAIN)
if (entry := ent_reg.async_get(entity))
and entry.supported_features & AssistSatelliteEntityFeature.ANNOUNCE
}
if intent_obj.device_id:
entities = {
entity: entry
for entity, entry in entities.items()
if entry.device_id != intent_obj.device_id
}
await hass.services.async_call(
DOMAIN,
"announce",
{"message": intent_obj.slots["message"]["value"]},
blocking=True,
context=intent_obj.context,
target={"entity_id": list(entities)},
)
response = intent_obj.create_response()
response.async_set_speech("Done")
response.response_type = intent.IntentResponseType.ACTION_DONE
response.async_set_results(
success_results=[
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.ENTITY,
id=entity,
name=state.name if (state := hass.states.get(entity)) else entity,
)
for entity in entities
]
)
return response
@@ -9,7 +9,7 @@ from dataclasses import dataclass
import logging
from typing import Any, Protocol, cast
from propcache.api import cached_property
from propcache import cached_property
import voluptuous as vol
from homeassistant.components import websocket_api
@@ -636,9 +636,9 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
alias = ""
if "trigger" in run_variables:
if "description" in run_variables["trigger"]:
reason = f" by {run_variables['trigger']['description']}"
reason = f' by {run_variables["trigger"]["description"]}'
if "alias" in run_variables["trigger"]:
alias = f" trigger '{run_variables['trigger']['alias']}'"
alias = f' trigger \'{run_variables["trigger"]["alias"]}\''
self._logger.debug("Automation%s triggered%s", alias, reason)
# Create a new context referring to the old context.
@@ -11,12 +11,11 @@ from python_awair.exceptions import AuthError, AwairError
from python_awair.user import AwairUser
import voluptuous as vol
from homeassistant.components import onboarding
from homeassistant.components import onboarding, zeroconf
from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DEVICE, CONF_HOST
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, LOGGER
@@ -30,7 +29,7 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN):
host: str
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
+7 -14
View File
@@ -10,6 +10,7 @@ from urllib.parse import urlsplit
import voluptuous as vol
from homeassistant.components import dhcp, ssdp, zeroconf
from homeassistant.config_entries import (
SOURCE_IGNORE,
SOURCE_REAUTH,
@@ -31,14 +32,6 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.ssdp import (
ATTR_UPNP_FRIENDLY_NAME,
ATTR_UPNP_PRESENTATION_URL,
ATTR_UPNP_SERIAL,
SsdpServiceInfo,
)
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.helpers.typing import VolDictType
from homeassistant.util.network import is_link_local
@@ -197,7 +190,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
return await self.async_step_user()
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
self, discovery_info: dhcp.DhcpServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for a DHCP discovered Axis device."""
return await self._process_discovered_device(
@@ -210,21 +203,21 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
)
async def async_step_ssdp(
self, discovery_info: SsdpServiceInfo
self, discovery_info: ssdp.SsdpServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for a SSDP discovered Axis device."""
url = urlsplit(discovery_info.upnp[ATTR_UPNP_PRESENTATION_URL])
url = urlsplit(discovery_info.upnp[ssdp.ATTR_UPNP_PRESENTATION_URL])
return await self._process_discovered_device(
{
CONF_HOST: url.hostname,
CONF_MAC: format_mac(discovery_info.upnp[ATTR_UPNP_SERIAL]),
CONF_NAME: f"{discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]}",
CONF_MAC: format_mac(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]),
CONF_NAME: f"{discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME]}",
CONF_PORT: url.port,
}
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Prepare configuration for a Zeroconf discovered Axis device."""
return await self._process_discovered_device(
@@ -2,10 +2,10 @@
"config": {
"step": {
"user": {
"title": "Set up Azure Data Explorer",
"title": "Setup your Azure Data Explorer integration",
"description": "Enter connection details",
"data": {
"cluster_ingest_uri": "Cluster ingestion URI",
"cluster_ingest_uri": "Cluster Ingest URI",
"authority_id": "Authority ID",
"client_id": "Client ID",
"client_secret": "Client secret",
@@ -14,7 +14,7 @@
"use_queued_ingestion": "Use queued ingestion"
},
"data_description": {
"cluster_ingest_uri": "Ingestion URI of the cluster",
"cluster_ingest_uri": "Ingest-URI of the cluster",
"use_queued_ingestion": "Must be enabled when using ADX free cluster"
}
}
@@ -2,26 +2,26 @@
"config": {
"step": {
"user": {
"title": "Set up Azure Event Hub",
"title": "Set up your Azure Event Hub integration",
"data": {
"event_hub_instance_name": "Event Hub instance name",
"use_connection_string": "Use connection string"
"event_hub_instance_name": "Event Hub Instance Name",
"use_connection_string": "Use Connection String"
}
},
"conn_string": {
"title": "Connection string method",
"title": "Connection String method",
"description": "Please enter the connection string for: {event_hub_instance_name}",
"data": {
"event_hub_connection_string": "Event Hub connection string"
"event_hub_connection_string": "Event Hub Connection String"
}
},
"sas": {
"title": "SAS credentials method",
"title": "SAS Credentials method",
"description": "Please enter the SAS (shared access signature) credentials for: {event_hub_instance_name}",
"data": {
"event_hub_namespace": "Event Hub namespace",
"event_hub_sas_policy": "Event Hub SAS policy",
"event_hub_sas_key": "Event Hub SAS key"
"event_hub_namespace": "Event Hub Namespace",
"event_hub_sas_policy": "Event Hub SAS Policy",
"event_hub_sas_key": "Event Hub SAS Key"
}
}
},
@@ -38,7 +38,7 @@
"options": {
"step": {
"init": {
"title": "Options for Azure Event Hub.",
"title": "Options for the Azure Event Hub.",
"data": {
"send_interval": "Interval between sending batches to the hub."
}
+1 -21
View File
@@ -27,7 +27,6 @@ from .manager import (
IncorrectPasswordError,
ManagerBackup,
NewBackup,
RestoreBackupEvent,
WrittenBackup,
)
from .models import AddonInfo, AgentBackup, Folder
@@ -36,6 +35,7 @@ from .websocket import async_register_websocket_handlers
__all__ = [
"AddonInfo",
"AgentBackup",
"ManagerBackup",
"BackupAgent",
"BackupAgentError",
"BackupAgentPlatformProtocol",
@@ -46,9 +46,7 @@ __all__ = [
"Folder",
"IncorrectPasswordError",
"LocalBackupAgent",
"ManagerBackup",
"NewBackup",
"RestoreBackupEvent",
"WrittenBackup",
]
@@ -88,26 +86,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
password=None,
)
async def async_handle_create_automatic_service(call: ServiceCall) -> None:
"""Service handler for creating automatic backups."""
config_data = backup_manager.config.data
await backup_manager.async_create_backup(
agent_ids=config_data.create_backup.agent_ids,
include_addons=config_data.create_backup.include_addons,
include_all_addons=config_data.create_backup.include_all_addons,
include_database=config_data.create_backup.include_database,
include_folders=config_data.create_backup.include_folders,
include_homeassistant=True, # always include HA
name=config_data.create_backup.name,
password=config_data.create_backup.password,
with_automatic_settings=True,
)
if not with_hassio:
hass.services.async_register(DOMAIN, "create", async_handle_create_service)
hass.services.async_register(
DOMAIN, "create_automatic", async_handle_create_automatic_service
)
async_register_http_views(hass)
+1 -1
View File
@@ -7,7 +7,7 @@ from collections.abc import AsyncIterator, Callable, Coroutine
from pathlib import Path
from typing import Any, Protocol
from propcache.api import cached_property
from propcache import cached_property
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
+17 -111
View File
@@ -5,10 +5,8 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass, field, replace
import datetime as dt
from datetime import datetime, timedelta
from enum import StrEnum
import random
from typing import TYPE_CHECKING, Self, TypedDict
from cronsim import CronSim
@@ -24,17 +22,11 @@ from .models import BackupManagerError, Folder
if TYPE_CHECKING:
from .manager import BackupManager, ManagerBackup
CRON_PATTERN_DAILY = "{m} {h} * * *"
CRON_PATTERN_WEEKLY = "{m} {h} * * {d}"
# The default time for automatic backups to run is at 04:45.
# This time is chosen to be compatible with the time of the recorder's
# nightly job which runs at 04:12.
DEFAULT_BACKUP_TIME = dt.time(4, 45)
# Randomize the start time of the backup by up to 60 minutes to avoid
# all backups running at the same time.
BACKUP_START_TIME_JITTER = 60 * 60
# The time of the automatic backup event should be compatible with
# the time of the recorder's nightly job which runs at 04:12.
# Run the backup at 04:45.
CRON_PATTERN_DAILY = "45 4 * * *"
CRON_PATTERN_WEEKLY = "45 4 * * {}"
class StoredBackupConfig(TypedDict):
@@ -77,12 +69,6 @@ class BackupConfigData:
else:
last_completed = None
if time_str := data["schedule"]["time"]:
time = dt_util.parse_time(time_str)
else:
time = None
days = [Day(day) for day in data["schedule"]["days"]]
return cls(
create_backup=CreateBackupConfig(
agent_ids=data["create_backup"]["agent_ids"],
@@ -99,12 +85,7 @@ class BackupConfigData:
copies=retention["copies"],
days=retention["days"],
),
schedule=BackupSchedule(
days=days,
recurrence=ScheduleRecurrence(data["schedule"]["recurrence"]),
state=ScheduleState(data["schedule"].get("state", ScheduleState.NEVER)),
time=time,
),
schedule=BackupSchedule(state=ScheduleState(data["schedule"]["state"])),
)
def to_dict(self) -> StoredBackupConfig:
@@ -151,7 +132,7 @@ class BackupConfig:
*,
create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED,
retention: RetentionParametersDict | UndefinedType = UNDEFINED,
schedule: ScheduleParametersDict | UndefinedType = UNDEFINED,
schedule: ScheduleState | UndefinedType = UNDEFINED,
) -> None:
"""Update config."""
if create_backup is not UNDEFINED:
@@ -162,7 +143,7 @@ class BackupConfig:
self.data.retention = new_retention
self.data.retention.apply(self._manager)
if schedule is not UNDEFINED:
new_schedule = BackupSchedule(**schedule)
new_schedule = BackupSchedule(state=schedule)
if new_schedule.to_dict() != self.data.schedule.to_dict():
self.data.schedule = new_schedule
self.data.schedule.apply(self._manager)
@@ -256,46 +237,11 @@ class RetentionParametersDict(TypedDict, total=False):
class StoredBackupSchedule(TypedDict):
"""Represent the stored backup schedule configuration."""
days: list[Day]
recurrence: ScheduleRecurrence
state: ScheduleState
time: str | None
class ScheduleParametersDict(TypedDict, total=False):
"""Represent parameters for backup schedule."""
days: list[Day]
recurrence: ScheduleRecurrence
state: ScheduleState
time: dt.time | None
class Day(StrEnum):
"""Represent the day(s) in a custom schedule recurrence."""
MONDAY = "mon"
TUESDAY = "tue"
WEDNESDAY = "wed"
THURSDAY = "thu"
FRIDAY = "fri"
SATURDAY = "sat"
SUNDAY = "sun"
class ScheduleRecurrence(StrEnum):
"""Represent the schedule recurrence."""
NEVER = "never"
DAILY = "daily"
CUSTOM_DAYS = "custom_days"
class ScheduleState(StrEnum):
"""Represent the schedule recurrence.
This is deprecated and can be remove in HA Core 2025.8.
"""
"""Represent the schedule state."""
NEVER = "never"
DAILY = "daily"
@@ -312,15 +258,8 @@ class ScheduleState(StrEnum):
class BackupSchedule:
"""Represent the backup schedule."""
days: list[Day] = field(default_factory=list)
recurrence: ScheduleRecurrence = ScheduleRecurrence.NEVER
# Although no longer used, state is kept for backwards compatibility.
# It can be removed in HA Core 2025.8.
state: ScheduleState = ScheduleState.NEVER
time: dt.time | None = None
cron_event: CronSim | None = field(init=False, default=None)
next_automatic_backup: datetime | None = field(init=False, default=None)
next_automatic_backup_additional = False
@callback
def apply(
@@ -329,27 +268,17 @@ class BackupSchedule:
) -> None:
"""Apply a new schedule.
There are only three possible recurrence types: never, daily, or custom_days
There are only three possible state types: never, daily, or weekly.
"""
if self.recurrence is ScheduleRecurrence.NEVER or (
self.recurrence is ScheduleRecurrence.CUSTOM_DAYS and not self.days
):
if self.state is ScheduleState.NEVER:
self._unschedule_next(manager)
return
time = self.time if self.time is not None else DEFAULT_BACKUP_TIME
if self.recurrence is ScheduleRecurrence.DAILY:
if self.state is ScheduleState.DAILY:
self._schedule_next(CRON_PATTERN_DAILY, manager)
else:
self._schedule_next(
CRON_PATTERN_DAILY.format(m=time.minute, h=time.hour),
manager,
)
else: # ScheduleRecurrence.CUSTOM_DAYS
self._schedule_next(
CRON_PATTERN_WEEKLY.format(
m=time.minute,
h=time.hour,
d=",".join(day.value for day in self.days),
),
CRON_PATTERN_WEEKLY.format(self.state.value),
manager,
)
@@ -370,23 +299,12 @@ class BackupSchedule:
if next_time < now:
# schedule a backup at next daily time once
# if we missed the last scheduled backup
time = self.time if self.time is not None else DEFAULT_BACKUP_TIME
cron_event = CronSim(
CRON_PATTERN_DAILY.format(m=time.minute, h=time.hour), now
)
cron_event = CronSim(CRON_PATTERN_DAILY, now)
next_time = next(cron_event)
# reseed the cron event attribute
# add a day to the next time to avoid scheduling at the same time again
self.cron_event = CronSim(cron_pattern, now + timedelta(days=1))
# Compare the computed next time with the next time from the cron pattern
# to determine if an additional backup has been scheduled
cron_event_configured = CronSim(cron_pattern, now)
next_configured_time = next(cron_event_configured)
self.next_automatic_backup_additional = next_time < next_configured_time
else:
self.next_automatic_backup_additional = False
async def _create_backup(now: datetime) -> None:
"""Create backup."""
manager.remove_next_backup_event = None
@@ -411,29 +329,17 @@ class BackupSchedule:
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected error creating automatic backup")
if self.time is None:
# randomize the start time of the backup by up to 60 minutes if the time is
# not set to avoid all backups running at the same time
next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER))
LOGGER.debug("Scheduling next automatic backup at %s", next_time)
self.next_automatic_backup = next_time
manager.remove_next_backup_event = async_track_point_in_time(
manager.hass, _create_backup, next_time
)
def to_dict(self) -> StoredBackupSchedule:
"""Convert backup schedule to a dict."""
return StoredBackupSchedule(
days=self.days,
recurrence=self.recurrence,
state=self.state,
time=self.time.isoformat() if self.time else None,
)
return StoredBackupSchedule(state=self.state)
@callback
def _unschedule_next(self, manager: BackupManager) -> None:
"""Unschedule the next backup."""
self.next_automatic_backup = None
if (remove_next_event := manager.remove_next_backup_event) is not None:
remove_next_event()
manager.remove_next_backup_event = None
+2 -74
View File
@@ -4,23 +4,18 @@ from __future__ import annotations
import asyncio
from http import HTTPStatus
import threading
from typing import IO, cast
from typing import cast
from aiohttp import BodyPartReader
from aiohttp.hdrs import CONTENT_DISPOSITION
from aiohttp.web import FileResponse, Request, Response, StreamResponse
from multidict import istr
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import slugify
from . import util
from .agent import BackupAgent
from .const import DATA_MANAGER
from .manager import BackupManager
@callback
@@ -48,13 +43,8 @@ class DownloadBackupView(HomeAssistantView):
agent_id = request.query.getone("agent_id")
except KeyError:
return Response(status=HTTPStatus.BAD_REQUEST)
try:
password = request.query.getone("password")
except KeyError:
password = None
hass = request.app[KEY_HASS]
manager = hass.data[DATA_MANAGER]
manager = request.app[KEY_HASS].data[DATA_MANAGER]
if agent_id not in manager.backup_agents:
return Response(status=HTTPStatus.BAD_REQUEST)
agent = manager.backup_agents[agent_id]
@@ -68,24 +58,6 @@ class DownloadBackupView(HomeAssistantView):
headers = {
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
}
if not password:
return await self._send_backup_no_password(
request, headers, backup_id, agent_id, agent, manager
)
return await self._send_backup_with_password(
hass, request, headers, backup_id, agent_id, password, agent, manager
)
async def _send_backup_no_password(
self,
request: Request,
headers: dict[istr, str],
backup_id: str,
agent_id: str,
agent: BackupAgent,
manager: BackupManager,
) -> StreamResponse | FileResponse | Response:
if agent_id in manager.local_backup_agents:
local_agent = manager.local_backup_agents[agent_id]
path = local_agent.get_backup_path(backup_id)
@@ -98,50 +70,6 @@ class DownloadBackupView(HomeAssistantView):
await response.write(chunk)
return response
async def _send_backup_with_password(
self,
hass: HomeAssistant,
request: Request,
headers: dict[istr, str],
backup_id: str,
agent_id: str,
password: str,
agent: BackupAgent,
manager: BackupManager,
) -> StreamResponse | FileResponse | Response:
reader: IO[bytes]
if agent_id in manager.local_backup_agents:
local_agent = manager.local_backup_agents[agent_id]
path = local_agent.get_backup_path(backup_id)
try:
reader = await hass.async_add_executor_job(open, path.as_posix(), "rb")
except FileNotFoundError:
return Response(status=HTTPStatus.NOT_FOUND)
else:
stream = await agent.async_download_backup(backup_id)
reader = cast(IO[bytes], util.AsyncIteratorReader(hass, stream))
worker_done_event = asyncio.Event()
def on_done() -> None:
"""Call by the worker thread when it's done."""
hass.loop.call_soon_threadsafe(worker_done_event.set)
stream = util.AsyncIteratorWriter(hass)
worker = threading.Thread(
target=util.decrypt_backup, args=[reader, stream, password, on_done]
)
try:
worker.start()
response = StreamResponse(status=HTTPStatus.OK, headers=headers)
await response.prepare(request)
async for chunk in stream:
await response.write(chunk)
return response
finally:
reader.close()
await worker_done_event.wait()
class UploadBackupView(HomeAssistantView):
"""Generate backup view."""
@@ -2,9 +2,6 @@
"services": {
"create": {
"service": "mdi:cloud-upload"
},
"create_automatic": {
"service": "mdi:cloud-upload"
}
}
}
+13 -84
View File
@@ -10,11 +10,11 @@ from enum import StrEnum
import hashlib
import io
import json
from pathlib import Path, PurePath
from pathlib import Path
import shutil
import tarfile
import time
from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast
from typing import TYPE_CHECKING, Any, Protocol, TypedDict
import aiohttp
from securetar import SecureTarFile, atomic_contents_add
@@ -31,7 +31,6 @@ from homeassistant.helpers import (
from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util
from . import util as backup_util
from .agent import (
BackupAgent,
BackupAgentError,
@@ -49,13 +48,7 @@ from .const import (
)
from .models import AgentBackup, BackupManagerError, Folder
from .store import BackupStore
from .util import (
AsyncIteratorReader,
make_backup_dir,
read_backup,
validate_password,
validate_password_stream,
)
from .util import make_backup_dir, read_backup, validate_password
@dataclass(frozen=True, kw_only=True, slots=True)
@@ -147,7 +140,6 @@ class RestoreBackupState(StrEnum):
"""Receive backup state enum."""
COMPLETED = "completed"
CORE_RESTART = "core_restart"
FAILED = "failed"
IN_PROGRESS = "in_progress"
@@ -218,7 +210,7 @@ class BackupReaderWriter(abc.ABC):
include_database: bool,
include_folders: list[Folder] | None,
include_homeassistant: bool,
on_progress: Callable[[CreateBackupEvent], None],
on_progress: Callable[[ManagerStateEvent], None],
password: str | None,
) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]:
"""Create a backup."""
@@ -239,7 +231,6 @@ class BackupReaderWriter(abc.ABC):
backup_id: str,
*,
agent_id: str,
on_progress: Callable[[RestoreBackupEvent], None],
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
password: str | None,
restore_addons: list[str] | None,
@@ -257,14 +248,6 @@ class BackupReaderWriterError(HomeAssistantError):
class IncorrectPasswordError(BackupReaderWriterError):
"""Raised when the password is incorrect."""
_message = "The password provided is incorrect."
class DecryptOnDowloadNotSupported(BackupManagerError):
"""Raised when on-the-fly decryption is not supported."""
_message = "On-the-fly decryption is not supported for this backup."
class BackupManager:
"""Define the format that backup managers can have."""
@@ -447,21 +430,18 @@ class BackupManager:
return_exceptions=True,
)
for idx, result in enumerate(sync_backup_results):
agent_id = agent_ids[idx]
if isinstance(result, BackupReaderWriterError):
# writer errors will affect all agents
# no point in continuing
raise BackupManagerError(str(result)) from result
if isinstance(result, BackupAgentError):
agent_errors[agent_id] = result
LOGGER.error("Upload failed for %s: %s", agent_id, result)
LOGGER.error("Error uploading to %s: %s", agent_ids[idx], result)
agent_errors[agent_ids[idx]] = result
continue
if isinstance(result, Exception):
# trap bugs from agents
agent_errors[agent_id] = result
LOGGER.error(
"Unexpected error for %s: %s", agent_id, result, exc_info=result
)
agent_errors[agent_ids[idx]] = result
LOGGER.error("Unexpected error: %s", result, exc_info=result)
continue
if isinstance(result, BaseException):
raise result
@@ -773,7 +753,7 @@ class BackupManager:
backup_name = (
name
or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}"
or f"{"Automatic" if with_automatic_settings else "Custom"} backup {HAVERSION}"
)
try:
@@ -943,7 +923,6 @@ class BackupManager:
backup_id=backup_id,
open_stream=open_backup,
agent_id=agent_id,
on_progress=self.async_on_backup_event,
password=password,
restore_addons=restore_addons,
restore_database=restore_database,
@@ -1008,41 +987,6 @@ class BackupManager:
translation_placeholders={"failed_agents": ", ".join(agent_errors)},
)
async def async_can_decrypt_on_download(
self,
backup_id: str,
*,
agent_id: str,
password: str | None,
) -> None:
"""Check if we are able to decrypt the backup on download."""
try:
agent = self.backup_agents[agent_id]
except KeyError as err:
raise BackupManagerError(f"Invalid agent selected: {agent_id}") from err
if not await agent.async_get_backup(backup_id):
raise BackupManagerError(
f"Backup {backup_id} not found in agent {agent_id}"
)
reader: IO[bytes]
if agent_id in self.local_backup_agents:
local_agent = self.local_backup_agents[agent_id]
path = local_agent.get_backup_path(backup_id)
reader = await self.hass.async_add_executor_job(open, path.as_posix(), "rb")
else:
backup_stream = await agent.async_download_backup(backup_id)
reader = cast(IO[bytes], AsyncIteratorReader(self.hass, backup_stream))
try:
validate_password_stream(reader, password)
except backup_util.IncorrectPassword as err:
raise IncorrectPasswordError from err
except backup_util.UnsupportedSecureTarVersion as err:
raise DecryptOnDowloadNotSupported from err
except backup_util.DecryptError as err:
raise BackupManagerError(str(err)) from err
finally:
reader.close()
class KnownBackups:
"""Track known backups."""
@@ -1133,7 +1077,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
include_database: bool,
include_folders: list[Folder] | None,
include_homeassistant: bool,
on_progress: Callable[[CreateBackupEvent], None],
on_progress: Callable[[ManagerStateEvent], None],
password: str | None,
) -> tuple[NewBackup, asyncio.Task[WrittenBackup]]:
"""Initiate generating a backup."""
@@ -1173,7 +1117,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
date_str: str,
extra_metadata: dict[str, bool | str],
include_database: bool,
on_progress: Callable[[CreateBackupEvent], None],
on_progress: Callable[[ManagerStateEvent], None],
password: str | None,
) -> WrittenBackup:
"""Generate a backup."""
@@ -1287,17 +1231,6 @@ class CoreBackupReaderWriter(BackupReaderWriter):
if not database_included:
excludes = excludes + EXCLUDE_DATABASE_FROM_BACKUP
def is_excluded_by_filter(path: PurePath) -> bool:
"""Filter to filter excludes."""
for exclude in excludes:
if not path.match(exclude):
continue
LOGGER.debug("Ignoring %s because of %s", path, exclude)
return True
return False
outer_secure_tarfile = SecureTarFile(
tar_file_path, "w", gzip=False, bufsize=BUF_SIZE
)
@@ -1316,7 +1249,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
atomic_contents_add(
tar_file=core_tar,
origin_path=Path(self._hass.config.path()),
file_filter=is_excluded_by_filter,
excludes=excludes,
arcname="data",
)
return (tar_file_path, tar_file_path.stat().st_size)
@@ -1381,7 +1314,6 @@ class CoreBackupReaderWriter(BackupReaderWriter):
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
*,
agent_id: str,
on_progress: Callable[[RestoreBackupEvent], None],
password: str | None,
restore_addons: list[str] | None,
restore_database: bool,
@@ -1426,7 +1358,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
validate_password, path, password
)
if not password_valid:
raise IncorrectPasswordError
raise IncorrectPasswordError("The password provided is incorrect.")
def _write_restore_file() -> None:
"""Write the restore file."""
@@ -1444,9 +1376,6 @@ class CoreBackupReaderWriter(BackupReaderWriter):
)
await self._hass.async_add_executor_job(_write_restore_file)
on_progress(
RestoreBackupEvent(stage=None, state=RestoreBackupState.CORE_RESTART)
)
await self._hass.services.async_call("homeassistant", "restart", blocking=True)
@@ -8,5 +8,5 @@
"integration_type": "system",
"iot_class": "calculated",
"quality_scale": "internal",
"requirements": ["cronsim==2.6", "securetar==2025.1.3"]
"requirements": ["cronsim==2.6", "securetar==2024.11.0"]
}
@@ -1,2 +1 @@
create:
create_automatic:
+3 -42
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, TypedDict
from typing import TYPE_CHECKING, TypedDict
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store
@@ -16,7 +16,6 @@ if TYPE_CHECKING:
STORE_DELAY_SAVE = 30
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
STORAGE_VERSION_MINOR = 2
class StoredBackupData(TypedDict):
@@ -26,52 +25,14 @@ class StoredBackupData(TypedDict):
config: StoredBackupConfig
class _BackupStore(Store[StoredBackupData]):
"""Class to help storing backup data."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize storage class."""
super().__init__(
hass,
STORAGE_VERSION,
STORAGE_KEY,
minor_version=STORAGE_VERSION_MINOR,
)
async def _async_migrate_func(
self,
old_major_version: int,
old_minor_version: int,
old_data: dict[str, Any],
) -> dict[str, Any]:
"""Migrate to the new version."""
data = old_data
if old_major_version == 1:
if old_minor_version < 2:
# Version 1.2 adds configurable backup time and custom days
data["config"]["schedule"]["time"] = None
if (state := data["config"]["schedule"]["state"]) in ("daily", "never"):
data["config"]["schedule"]["days"] = []
data["config"]["schedule"]["recurrence"] = state
else:
data["config"]["schedule"]["days"] = [state]
data["config"]["schedule"]["recurrence"] = "custom_days"
# Note: We allow reading data with major version 2.
# Reject if major version is higher than 2.
if old_major_version > 2:
raise NotImplementedError
return data
class BackupStore:
"""Store backup config."""
def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None:
"""Initialize the backup store."""
"""Initialize the backup manager."""
self._hass = hass
self._manager = manager
self._store = _BackupStore(hass)
self._store: Store[StoredBackupData] = Store(hass, STORAGE_VERSION, STORAGE_KEY)
async def load(self) -> StoredBackupData | None:
"""Load the store."""
@@ -13,10 +13,6 @@
"create": {
"name": "Create backup",
"description": "Creates a new backup."
},
"create_automatic": {
"name": "Create automatic backup",
"description": "Creates a new backup with automatic backup settings."
}
}
}
+3 -185
View File
@@ -3,51 +3,22 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable
import copy
from io import BytesIO
import json
from pathlib import Path, PurePath
from pathlib import Path
from queue import SimpleQueue
import tarfile
from typing import IO, Self, cast
from typing import cast
import aiohttp
from securetar import SecureTarError, SecureTarFile, SecureTarReadError
from securetar import SecureTarFile
from homeassistant.backup_restore import password_to_key
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util.json import JsonObjectType, json_loads_object
from .const import BUF_SIZE, LOGGER
from .models import AddonInfo, AgentBackup, Folder
class DecryptError(HomeAssistantError):
"""Error during decryption."""
_message = "Unexpected error during decryption."
class UnsupportedSecureTarVersion(DecryptError):
"""Unsupported securetar version."""
_message = "Unsupported securetar version."
class IncorrectPassword(DecryptError):
"""Invalid password or corrupted backup."""
_message = "Invalid password or corrupted backup."
class BackupEmpty(DecryptError):
"""No tar files found in the backup."""
_message = "No tar files found in the backup."
def make_backup_dir(path: Path) -> None:
"""Create a backup directory if it does not exist."""
path.mkdir(exist_ok=True)
@@ -135,159 +106,6 @@ def validate_password(path: Path, password: str | None) -> bool:
return False
class AsyncIteratorReader:
"""Wrap an AsyncIterator."""
def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None:
"""Initialize the wrapper."""
self._hass = hass
self._stream = stream
self._buffer: bytes | None = None
self._pos: int = 0
async def _next(self) -> bytes | None:
"""Get the next chunk from the iterator."""
return await anext(self._stream, None)
def read(self, n: int = -1, /) -> bytes:
"""Read data from the iterator."""
result = bytearray()
while n < 0 or len(result) < n:
if not self._buffer:
self._buffer = asyncio.run_coroutine_threadsafe(
self._next(), self._hass.loop
).result()
self._pos = 0
if not self._buffer:
# The stream is exhausted
break
chunk = self._buffer[self._pos : self._pos + n]
result.extend(chunk)
n -= len(chunk)
self._pos += len(chunk)
if self._pos == len(self._buffer):
self._buffer = None
return bytes(result)
def close(self) -> None:
"""Close the iterator."""
class AsyncIteratorWriter:
"""Wrap an AsyncIterator."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the wrapper."""
self._hass = hass
self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
def __aiter__(self) -> Self:
"""Return the iterator."""
return self
async def __anext__(self) -> bytes:
"""Get the next chunk from the iterator."""
if data := await self._queue.get():
return data
raise StopAsyncIteration
def write(self, s: bytes, /) -> int:
"""Write data to the iterator."""
asyncio.run_coroutine_threadsafe(self._queue.put(s), self._hass.loop).result()
return len(s)
def validate_password_stream(
input_stream: IO[bytes],
password: str | None,
) -> None:
"""Decrypt a backup."""
with (
tarfile.open(fileobj=input_stream, mode="r|", bufsize=BUF_SIZE) as input_tar,
):
for obj in input_tar:
if not obj.name.endswith((".tar", ".tgz", ".tar.gz")):
continue
istf = SecureTarFile(
None, # Not used
gzip=False,
key=password_to_key(password) if password is not None else None,
mode="r",
fileobj=input_tar.extractfile(obj),
)
with istf.decrypt(obj) as decrypted:
if istf.securetar_header.plaintext_size is None:
raise UnsupportedSecureTarVersion
try:
decrypted.read(1) # Read a single byte to trigger the decryption
except SecureTarReadError as err:
raise IncorrectPassword from err
return
raise BackupEmpty
def decrypt_backup(
input_stream: IO[bytes],
output_stream: IO[bytes],
password: str | None,
on_done: Callable[[], None],
) -> None:
"""Decrypt a backup."""
try:
with (
tarfile.open(
fileobj=input_stream, mode="r|", bufsize=BUF_SIZE
) as input_tar,
tarfile.open(
fileobj=output_stream, mode="w|", bufsize=BUF_SIZE
) as output_tar,
):
_decrypt_backup(input_tar, output_tar, password)
except (DecryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error decrypting backup: %s", err)
finally:
output_stream.write(b"") # Write an empty chunk to signal the end of the stream
on_done()
def _decrypt_backup(
input_tar: tarfile.TarFile,
output_tar: tarfile.TarFile,
password: str | None,
) -> None:
"""Decrypt a backup."""
for obj in input_tar:
# We compare with PurePath to avoid issues with different path separators,
# for example when backup.json is added as "./backup.json"
if PurePath(obj.name) == PurePath("backup.json"):
# Rewrite the backup.json file to indicate that the backup is decrypted
if not (reader := input_tar.extractfile(obj)):
raise DecryptError
metadata = json_loads_object(reader.read())
metadata["protected"] = False
updated_metadata_b = json.dumps(metadata).encode()
metadata_obj = copy.deepcopy(obj)
metadata_obj.size = len(updated_metadata_b)
output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b))
continue
if not obj.name.endswith((".tar", ".tgz", ".tar.gz")):
output_tar.addfile(obj, input_tar.extractfile(obj))
continue
istf = SecureTarFile(
None, # Not used
gzip=False,
key=password_to_key(password) if password is not None else None,
mode="r",
fileobj=input_tar.extractfile(obj),
)
with istf.decrypt(obj) as decrypted:
if (plaintext_size := istf.securetar_header.plaintext_size) is None:
raise UnsupportedSecureTarVersion
decrypted_obj = copy.deepcopy(obj)
decrypted_obj.size = plaintext_size
output_tar.addfile(decrypted_obj, decrypted)
async def receive_file(
hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path
) -> None:
+4 -62
View File
@@ -6,15 +6,10 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from .config import Day, ScheduleRecurrence
from .config import ScheduleState
from .const import DATA_MANAGER, LOGGER
from .manager import (
DecryptOnDowloadNotSupported,
IncorrectPasswordError,
ManagerStateEvent,
)
from .manager import IncorrectPasswordError, ManagerStateEvent
from .models import Folder
@@ -29,7 +24,6 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) ->
websocket_api.async_register_command(hass, handle_details)
websocket_api.async_register_command(hass, handle_info)
websocket_api.async_register_command(hass, handle_can_decrypt_on_download)
websocket_api.async_register_command(hass, handle_create)
websocket_api.async_register_command(hass, handle_create_with_automatic_settings)
websocket_api.async_register_command(hass, handle_delete)
@@ -60,8 +54,6 @@ async def handle_info(
"backups": [backup.as_frontend_json() for backup in backups.values()],
"last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup,
"last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup,
"next_automatic_backup": manager.config.data.schedule.next_automatic_backup,
"next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional,
},
)
@@ -155,38 +147,6 @@ async def handle_restore(
connection.send_result(msg["id"])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "backup/can_decrypt_on_download",
vol.Required("backup_id"): str,
vol.Required("agent_id"): str,
vol.Required("password"): str,
}
)
@websocket_api.async_response
async def handle_can_decrypt_on_download(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Check if the supplied password is correct."""
try:
await hass.data[DATA_MANAGER].async_can_decrypt_on_download(
msg["backup_id"],
agent_id=msg["agent_id"],
password=msg.get("password"),
)
except IncorrectPasswordError:
connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
except DecryptOnDowloadNotSupported:
connection.send_error(
msg["id"], "decrypt_not_supported", "Decrypt on download not supported"
)
else:
connection.send_result(msg["id"])
@websocket_api.require_admin
@websocket_api.websocket_command(
{
@@ -321,18 +281,10 @@ async def handle_config_info(
) -> None:
"""Send the stored backup config."""
manager = hass.data[DATA_MANAGER]
config = manager.config.data.to_dict()
# Remove state from schedule, it's not needed in the frontend
# mypy doesn't like deleting from TypedDict, ignore it
del config["schedule"]["state"] # type: ignore[misc]
connection.send_result(
msg["id"],
{
"config": config
| {
"next_automatic_backup": manager.config.data.schedule.next_automatic_backup,
"next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional,
}
"config": manager.config.data.to_dict(),
},
)
@@ -362,17 +314,7 @@ async def handle_config_info(
vol.Optional("days"): vol.Any(int, None),
},
),
vol.Optional("schedule"): vol.Schema(
{
vol.Optional("days"): vol.Any(
vol.All([vol.Coerce(Day)], vol.Unique()),
),
vol.Optional("recurrence"): vol.All(
str, vol.Coerce(ScheduleRecurrence)
),
vol.Optional("time"): vol.Any(cv.time, None),
}
),
vol.Optional("schedule"): vol.All(str, vol.Coerce(ScheduleState)),
}
)
@websocket_api.async_response
+2 -2
View File
@@ -10,9 +10,9 @@ from aiobafi6 import Device, Service
from aiobafi6.discovery import PORT
import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_IP_ADDRESS
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, RUN_TIMEOUT
from .models import BAFDiscovery
@@ -44,7 +44,7 @@ class BAFFlowHandler(ConfigFlow, domain=DOMAIN):
self.discovery: BAFDiscovery | None = None
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
if discovery_info.ip_address.version == 6:
+1 -1
View File
@@ -20,7 +20,7 @@ class BalboaEntity(Entity):
"""Initialize the control."""
mac = client.mac_address
model = client.model
self._attr_unique_id = f"{model}-{key}-{mac.replace(':', '')[-6:]}"
self._attr_unique_id = f'{model}-{key}-{mac.replace(":","")[-6:]}'
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac)},
name=model,
@@ -10,10 +10,10 @@ from mozart_api.exceptions import ApiException
from mozart_api.mozart_client import MozartClient
import voluptuous as vol
from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MODEL
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.util.ssl import get_default_context
from .const import (
@@ -131,10 +131,7 @@ def _no_overlapping(configs: list[dict]) -> list[dict]:
for i, tup in enumerate(intervals):
if len(intervals) > i + 1 and tup.below > intervals[i + 1].above:
raise vol.Invalid(
"Ranges for bayesian numeric state entities must not overlap, "
f"but {ent_id} has overlapping ranges, above:{tup.above}, "
f"below:{tup.below} overlaps with above:{intervals[i + 1].above}, "
f"below:{intervals[i + 1].below}."
f"Ranges for bayesian numeric state entities must not overlap, but {ent_id} has overlapping ranges, above:{tup.above}, below:{tup.below} overlaps with above:{intervals[i+1].above}, below:{intervals[i+1].below}."
)
return configs
@@ -209,10 +206,7 @@ async def async_setup_platform(
broken_observations: list[dict[str, Any]] = []
for observation in observations:
if CONF_P_GIVEN_F not in observation:
text = (
f"{name}/{observation.get(CONF_ENTITY_ID, '')}"
f"{observation.get(CONF_VALUE_TEMPLATE, '')}"
)
text: str = f"{name}/{observation.get(CONF_ENTITY_ID,'')}{observation.get(CONF_VALUE_TEMPLATE,'')}"
raise_no_prob_given_false(hass, text)
_LOGGER.error("Missing prob_given_false YAML entry for %s", text)
broken_observations.append(observation)
@@ -7,7 +7,7 @@ from enum import StrEnum
import logging
from typing import Literal, final
from propcache.api import cached_property
from propcache import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -15,10 +15,10 @@ from blebox_uniapi.error import (
from blebox_uniapi.session import ApiHost
import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import get_maybe_authenticated_session
from .const import (
@@ -84,7 +84,7 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
hass = self.hass
@@ -5,7 +5,7 @@
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]"
},
"description": "Enter your Blue Current API token",
"description": "Enter your Blue Current api token",
"title": "Authentication"
}
},
@@ -19,7 +19,7 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "Wrong account: Please authenticate with the API token for {email}."
"wrong_account": "Wrong account: Please authenticate with the api key for {email}."
}
},
"entity": {
@@ -13,7 +13,7 @@ import yarl
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.util import yaml as yaml_util
from homeassistant.util import yaml
from .models import Blueprint
from .schemas import BLUEPRINT_SCHEMA, is_blueprint_config
@@ -115,7 +115,7 @@ def _extract_blueprint_from_community_topic(
block_content = html.unescape(block_content.strip())
try:
data = yaml_util.parse_yaml(block_content)
data = yaml.parse_yaml(block_content)
except HomeAssistantError:
if block_syntax == "yaml":
raise
@@ -136,7 +136,7 @@ def _extract_blueprint_from_community_topic(
)
return ImportedBlueprint(
f"{post['username']}/{topic['slug']}", block_content, blueprint
f'{post["username"]}/{topic["slug"]}', block_content, blueprint
)
@@ -167,13 +167,14 @@ async def fetch_blueprint_from_github_url(
resp = await session.get(import_url, raise_for_status=True)
raw_yaml = await resp.text()
data = yaml_util.parse_yaml(raw_yaml)
data = yaml.parse_yaml(raw_yaml)
assert isinstance(data, dict)
blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA)
parsed_import_url = yarl.URL(import_url)
suggested_filename = f"{parsed_import_url.parts[1]}/{parsed_import_url.parts[-1]}"
suggested_filename = suggested_filename.removesuffix(".yaml")
if suggested_filename.endswith(".yaml"):
suggested_filename = suggested_filename[:-5]
return ImportedBlueprint(suggested_filename, raw_yaml, blueprint)
@@ -204,7 +205,7 @@ async def fetch_blueprint_from_github_gist_url(
continue
content = info["content"]
data = yaml_util.parse_yaml(content)
data = yaml.parse_yaml(content)
if not is_blueprint_config(data):
continue
@@ -235,7 +236,7 @@ async def fetch_blueprint_from_website_url(
resp = await session.get(url, raise_for_status=True)
raw_yaml = await resp.text()
data = yaml_util.parse_yaml(raw_yaml)
data = yaml.parse_yaml(raw_yaml)
assert isinstance(data, dict)
blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA)
@@ -252,7 +253,7 @@ async def fetch_blueprint_from_generic_url(
resp = await session.get(url, raise_for_status=True)
raw_yaml = await resp.text()
data = yaml_util.parse_yaml(raw_yaml)
data = yaml.parse_yaml(raw_yaml)
assert isinstance(data, dict)
blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA)
+5 -7
View File
@@ -23,7 +23,7 @@ from homeassistant.const import (
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import yaml as yaml_util
from homeassistant.util import yaml
from .const import (
BLUEPRINT_FOLDER,
@@ -79,7 +79,7 @@ class Blueprint:
self.domain = data_domain
missing = yaml_util.extract_inputs(data) - set(self.inputs)
missing = yaml.extract_inputs(data) - set(self.inputs)
if missing:
raise InvalidBlueprint(
@@ -117,7 +117,7 @@ class Blueprint:
def yaml(self) -> str:
"""Dump blueprint as YAML."""
return yaml_util.dump(self.data)
return yaml.dump(self.data)
@callback
def validate(self) -> list[str] | None:
@@ -179,7 +179,7 @@ class BlueprintInputs:
@callback
def async_substitute(self) -> dict:
"""Get the blueprint value with the inputs substituted."""
processed = yaml_util.substitute(self.blueprint.data, self.inputs_with_default)
processed = yaml.substitute(self.blueprint.data, self.inputs_with_default)
combined = {**processed, **self.config_with_inputs}
# From config_with_inputs
combined.pop(CONF_USE_BLUEPRINT)
@@ -225,9 +225,7 @@ class DomainBlueprints:
def _load_blueprint(self, blueprint_path: str) -> Blueprint:
"""Load a blueprint."""
try:
blueprint_data = yaml_util.load_yaml_dict(
self.blueprint_folder / blueprint_path
)
blueprint_data = yaml.load_yaml_dict(self.blueprint_folder / blueprint_path)
except FileNotFoundError as err:
raise FailedToLoad(
self.domain,
@@ -13,7 +13,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.util import yaml as yaml_util
from homeassistant.util import yaml
from . import importer, models
from .const import DOMAIN
@@ -174,7 +174,7 @@ async def ws_save_blueprint(
domain = msg["domain"]
try:
yaml_data = cast(dict[str, Any], yaml_util.parse_yaml(msg["yaml"]))
yaml_data = cast(dict[str, Any], yaml.parse_yaml(msg["yaml"]))
blueprint = models.Blueprint(
yaml_data, expected_domain=domain, schema=BLUEPRINT_SCHEMA
)
@@ -263,7 +263,7 @@ async def ws_substitute_blueprint(
try:
config = blueprint_inputs.async_substitute()
except yaml_util.UndefinedSubstitution as err:
except yaml.UndefinedSubstitution as err:
connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err))
return
+10 -14
View File
@@ -14,13 +14,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import BluesoundCoordinator
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.MEDIA_PLAYER,
]
PLATFORMS = [Platform.MEDIA_PLAYER]
@dataclass
@@ -29,7 +26,6 @@ class BluesoundRuntimeData:
player: Player
sync_status: SyncStatus
coordinator: BluesoundCoordinator
type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData]
@@ -37,6 +33,9 @@ type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Bluesound."""
if DOMAIN not in hass.data:
hass.data[DOMAIN] = []
return True
@@ -47,16 +46,13 @@ async def async_setup_entry(
host = config_entry.data[CONF_HOST]
port = config_entry.data[CONF_PORT]
session = async_get_clientsession(hass)
player = Player(host, port, session=session, default_timeout=10)
try:
sync_status = await player.sync_status(timeout=1)
except PlayerUnreachableError as ex:
raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex
async with Player(host, port, session=session, default_timeout=10) as player:
try:
sync_status = await player.sync_status(timeout=1)
except PlayerUnreachableError as ex:
raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex
coordinator = BluesoundCoordinator(hass, player, sync_status)
await coordinator.async_config_entry_first_refresh()
config_entry.runtime_data = BluesoundRuntimeData(player, sync_status, coordinator)
config_entry.runtime_data = BluesoundRuntimeData(player, sync_status)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
@@ -7,10 +7,10 @@ from pyblu import Player, SyncStatus
from pyblu.errors import PlayerUnreachableError
import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
from .media_player import DEFAULT_PORT
@@ -72,7 +72,7 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by zeroconf discovery."""
if discovery_info.port is not None:
@@ -1,160 +0,0 @@
"""Define a base coordinator for Bluesound entities."""
from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
import contextlib
from dataclasses import dataclass, replace
from datetime import timedelta
import logging
from pyblu import Input, Player, Preset, Status, SyncStatus
from pyblu.errors import PlayerUnreachableError
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
NODE_OFFLINE_CHECK_TIMEOUT = timedelta(minutes=3)
PRESET_AND_INPUTS_INTERVAL = timedelta(minutes=15)
@dataclass
class BluesoundData:
"""Define a class to hold Bluesound data."""
sync_status: SyncStatus
status: Status
presets: list[Preset]
inputs: list[Input]
def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]]:
"""Cancel a task."""
async def _cancel_task() -> None:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
return _cancel_task
class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]):
"""Define an object to hold Bluesound data."""
def __init__(
self, hass: HomeAssistant, player: Player, sync_status: SyncStatus
) -> None:
"""Initialize."""
self.player = player
self._inital_sync_status = sync_status
super().__init__(
hass,
logger=_LOGGER,
name=sync_status.name,
)
async def _async_setup(self) -> None:
assert self.config_entry is not None
preset = await self.player.presets()
inputs = await self.player.inputs()
status = await self.player.status()
self.async_set_updated_data(
BluesoundData(
sync_status=self._inital_sync_status,
status=status,
presets=preset,
inputs=inputs,
)
)
status_loop_task = self.hass.async_create_background_task(
self._poll_status_loop(),
name=f"bluesound.poll_status_loop_{self.data.sync_status.id}",
)
self.config_entry.async_on_unload(cancel_task(status_loop_task))
sync_status_loop_task = self.hass.async_create_background_task(
self._poll_sync_status_loop(),
name=f"bluesound.poll_sync_status_loop_{self.data.sync_status.id}",
)
self.config_entry.async_on_unload(cancel_task(sync_status_loop_task))
presets_and_inputs_loop_task = self.hass.async_create_background_task(
self._poll_presets_and_inputs_loop(),
name=f"bluesound.poll_presets_and_inputs_loop_{self.data.sync_status.id}",
)
self.config_entry.async_on_unload(cancel_task(presets_and_inputs_loop_task))
async def _async_update_data(self) -> BluesoundData:
return self.data
async def _poll_presets_and_inputs_loop(self) -> None:
while True:
await asyncio.sleep(PRESET_AND_INPUTS_INTERVAL.total_seconds())
try:
preset = await self.player.presets()
inputs = await self.player.inputs()
self.async_set_updated_data(
replace(
self.data,
presets=preset,
inputs=inputs,
)
)
except PlayerUnreachableError as ex:
self.async_set_update_error(ex)
except asyncio.CancelledError:
return
except Exception as ex: # noqa: BLE001 - this loop should never stop
self.async_set_update_error(ex)
async def _poll_status_loop(self) -> None:
"""Loop which polls the status of the player."""
while True:
try:
status = await self.player.status(
etag=self.data.status.etag, poll_timeout=120, timeout=125
)
self.async_set_updated_data(
replace(
self.data,
status=status,
)
)
except PlayerUnreachableError as ex:
self.async_set_update_error(ex)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds())
except asyncio.CancelledError:
return
except Exception as ex: # noqa: BLE001 - this loop should never stop
self.async_set_update_error(ex)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds())
async def _poll_sync_status_loop(self) -> None:
"""Loop which polls the sync status of the player."""
while True:
try:
sync_status = await self.player.sync_status(
etag=self.data.sync_status.etag, poll_timeout=120, timeout=125
)
self.async_set_updated_data(
replace(
self.data,
sync_status=sync_status,
)
)
except PlayerUnreachableError as ex:
self.async_set_update_error(ex)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds())
except asyncio.CancelledError:
raise
except Exception as ex: # noqa: BLE001 - this loop should never stop
self.async_set_update_error(ex)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds())
@@ -2,12 +2,15 @@
from __future__ import annotations
from asyncio import Task
import asyncio
from asyncio import CancelledError, Task
from contextlib import suppress
from datetime import datetime, timedelta
import logging
from typing import TYPE_CHECKING, Any
from pyblu import Input, Player, Preset, Status, SyncStatus
from pyblu.errors import PlayerUnreachableError
import voluptuous as vol
from homeassistant.components import media_source
@@ -20,7 +23,7 @@ from homeassistant.components.media_player import (
async_process_play_media_url,
)
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import (
@@ -33,11 +36,9 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
import homeassistant.util.dt as dt_util
from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN
from .coordinator import BluesoundCoordinator
from .utils import dispatcher_join_signal, dispatcher_unjoin_signal, format_unique_id
if TYPE_CHECKING:
@@ -55,6 +56,11 @@ SERVICE_JOIN = "join"
SERVICE_SET_TIMER = "set_sleep_timer"
SERVICE_UNJOIN = "unjoin"
NODE_OFFLINE_CHECK_TIMEOUT = 180
NODE_RETRY_INITIATION = timedelta(minutes=3)
SYNC_STATUS_INTERVAL = timedelta(minutes=5)
POLL_TIMEOUT = 120
@@ -65,10 +71,10 @@ async def async_setup_entry(
) -> None:
"""Set up the Bluesound entry."""
bluesound_player = BluesoundPlayer(
config_entry.runtime_data.coordinator,
config_entry.data[CONF_HOST],
config_entry.data[CONF_PORT],
config_entry.runtime_data.player,
config_entry.runtime_data.sync_status,
)
platform = entity_platform.async_get_current_platform()
@@ -83,10 +89,11 @@ async def async_setup_entry(
)
platform.async_register_entity_service(SERVICE_UNJOIN, None, "async_unjoin")
hass.data[DATA_BLUESOUND].append(bluesound_player)
async_add_entities([bluesound_player], update_before_add=True)
class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity):
class BluesoundPlayer(MediaPlayerEntity):
"""Representation of a Bluesound Player."""
_attr_media_content_type = MediaType.MUSIC
@@ -95,15 +102,12 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
def __init__(
self,
coordinator: BluesoundCoordinator,
host: str,
port: int,
player: Player,
sync_status: SyncStatus,
) -> None:
"""Initialize the media player."""
super().__init__(coordinator)
sync_status = coordinator.data.sync_status
self.host = host
self.port = port
self._poll_status_loop_task: Task[None] | None = None
@@ -111,14 +115,15 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
self._id = sync_status.id
self._last_status_update: datetime | None = None
self._sync_status = sync_status
self._status: Status = coordinator.data.status
self._inputs: list[Input] = coordinator.data.inputs
self._presets: list[Preset] = coordinator.data.presets
self._status: Status | None = None
self._inputs: list[Input] = []
self._presets: list[Preset] = []
self._group_name: str | None = None
self._group_list: list[str] = []
self._bluesound_device_name = sync_status.name
self._player = player
self._last_status_update = dt_util.utcnow()
self._is_leader = False
self._leader: BluesoundPlayer | None = None
self._attr_unique_id = format_unique_id(sync_status.mac, port)
# there should always be one player with the default port per mac
@@ -141,10 +146,52 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
via_device=(DOMAIN, format_mac(sync_status.mac)),
)
async def _poll_status_loop(self) -> None:
"""Loop which polls the status of the player."""
while True:
try:
await self.async_update_status()
except PlayerUnreachableError:
_LOGGER.error(
"Node %s:%s is offline, retrying later", self.host, self.port
)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
except CancelledError:
_LOGGER.debug(
"Stopping the polling of node %s:%s", self.host, self.port
)
return
except: # noqa: E722 - this loop should never stop
_LOGGER.exception(
"Unexpected error for %s:%s, retrying later", self.host, self.port
)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
async def _poll_sync_status_loop(self) -> None:
"""Loop which polls the sync status of the player."""
while True:
try:
await self.update_sync_status()
except PlayerUnreachableError:
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
except CancelledError:
raise
except: # noqa: E722 - all errors must be caught for this loop
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
async def async_added_to_hass(self) -> None:
"""Start the polling task."""
await super().async_added_to_hass()
self._poll_status_loop_task = self.hass.async_create_background_task(
self._poll_status_loop(),
name=f"bluesound.poll_status_loop_{self.host}:{self.port}",
)
self._poll_sync_status_loop_task = self.hass.async_create_background_task(
self._poll_sync_status_loop(),
name=f"bluesound.poll_sync_status_loop_{self.host}:{self.port}",
)
assert self._sync_status.id is not None
self.async_on_remove(
async_dispatcher_connect(
@@ -165,24 +212,105 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
"""Stop the polling task."""
await super().async_will_remove_from_hass()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._sync_status = self.coordinator.data.sync_status
self._status = self.coordinator.data.status
self._inputs = self.coordinator.data.inputs
self._presets = self.coordinator.data.presets
assert self._poll_status_loop_task is not None
if self._poll_status_loop_task.cancel():
# the sleeps in _poll_loop will raise CancelledError
with suppress(CancelledError):
await self._poll_status_loop_task
self._last_status_update = dt_util.utcnow()
assert self._poll_sync_status_loop_task is not None
if self._poll_sync_status_loop_task.cancel():
# the sleeps in _poll_sync_status_loop will raise CancelledError
with suppress(CancelledError):
await self._poll_sync_status_loop_task
self.hass.data[DATA_BLUESOUND].remove(self)
async def async_update(self) -> None:
"""Update internal status of the entity."""
if not self.available:
return
with suppress(PlayerUnreachableError):
await self.async_update_presets()
await self.async_update_captures()
async def async_update_status(self) -> None:
"""Use the poll session to always get the status of the player."""
etag = None
if self._status is not None:
etag = self._status.etag
try:
status = await self._player.status(
etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5
)
self._attr_available = True
self._last_status_update = dt_util.utcnow()
self._status = status
self.async_write_ha_state()
except PlayerUnreachableError:
self._attr_available = False
self._last_status_update = None
self._status = None
self.async_write_ha_state()
_LOGGER.error(
"Client connection error, marking %s as offline",
self._bluesound_device_name,
)
raise
async def update_sync_status(self) -> None:
"""Update the internal status."""
etag = None
if self._sync_status:
etag = self._sync_status.etag
sync_status = await self._player.sync_status(
etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5
)
self._sync_status = sync_status
self._group_list = self.rebuild_bluesound_group()
if sync_status.leader is not None:
self._is_leader = False
leader_id = f"{sync_status.leader.ip}:{sync_status.leader.port}"
leader_device = [
device
for device in self.hass.data[DATA_BLUESOUND]
if device.id == leader_id
]
if leader_device and leader_id != self.id:
self._leader = leader_device[0]
else:
self._leader = None
_LOGGER.error("Leader not found %s", leader_id)
else:
if self._leader is not None:
self._leader = None
followers = self._sync_status.followers
self._is_leader = followers is not None
self.async_write_ha_state()
async def async_update_captures(self) -> None:
"""Update Capture sources."""
inputs = await self._player.inputs()
self._inputs = inputs
async def async_update_presets(self) -> None:
"""Update Presets."""
presets = await self._player.presets()
self._presets = presets
@property
def state(self) -> MediaPlayerState:
"""Return the state of the device."""
if self.available is False:
if self._status is None:
return MediaPlayerState.OFF
if self.is_grouped and not self.is_leader:
@@ -199,7 +327,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def media_title(self) -> str | None:
"""Title of current playing media."""
if self.available is False or (self.is_grouped and not self.is_leader):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
return self._status.name
@@ -207,7 +335,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def media_artist(self) -> str | None:
"""Artist of current playing media (Music track only)."""
if self.available is False:
if self._status is None:
return None
if self.is_grouped and not self.is_leader:
@@ -218,7 +346,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def media_album_name(self) -> str | None:
"""Artist of current playing media (Music track only)."""
if self.available is False or (self.is_grouped and not self.is_leader):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
return self._status.album
@@ -226,7 +354,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def media_image_url(self) -> str | None:
"""Image url of current playing media."""
if self.available is False or (self.is_grouped and not self.is_leader):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
url = self._status.image
@@ -241,7 +369,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def media_position(self) -> int | None:
"""Position of current playing media in seconds."""
if self.available is False or (self.is_grouped and not self.is_leader):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
mediastate = self.state
@@ -260,7 +388,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def media_duration(self) -> int | None:
"""Duration of current playing media in seconds."""
if self.available is False or (self.is_grouped and not self.is_leader):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
duration = self._status.total_seconds
@@ -277,11 +405,16 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def volume_level(self) -> float | None:
"""Volume level of the media player (0..1)."""
volume = self._status.volume
volume = None
if self._status is not None:
volume = self._status.volume
if self.is_grouped:
volume = self._sync_status.volume
if volume is None:
return None
return volume / 100
@property
@@ -314,7 +447,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def source_list(self) -> list[str] | None:
"""List of available input sources."""
if self.available is False or (self.is_grouped and not self.is_leader):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
sources = [x.text for x in self._inputs]
@@ -325,7 +458,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def source(self) -> str | None:
"""Name of the current input source."""
if self.available is False or (self.is_grouped and not self.is_leader):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
if self._status.input_id is not None:
@@ -342,7 +475,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def supported_features(self) -> MediaPlayerEntityFeature:
"""Flag of media commands that are supported."""
if self.available is False:
if self._status is None:
return MediaPlayerEntityFeature(0)
if self.is_grouped and not self.is_leader:
@@ -444,21 +577,16 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
if self.sync_status.leader is None and self.sync_status.followers is None:
return []
config_entries: list[BluesoundConfigEntry] = (
self.hass.config_entries.async_entries(DOMAIN)
)
sync_status_list = [
x.runtime_data.coordinator.data.sync_status for x in config_entries
]
player_entities: list[BluesoundPlayer] = self.hass.data[DATA_BLUESOUND]
leader_sync_status: SyncStatus | None = None
if self.sync_status.leader is None:
leader_sync_status = self.sync_status
else:
required_id = f"{self.sync_status.leader.ip}:{self.sync_status.leader.port}"
for sync_status in sync_status_list:
if sync_status.id == required_id:
leader_sync_status = sync_status
for x in player_entities:
if x.sync_status.id == required_id:
leader_sync_status = x.sync_status
break
if leader_sync_status is None or leader_sync_status.followers is None:
@@ -466,9 +594,9 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
follower_ids = [f"{x.ip}:{x.port}" for x in leader_sync_status.followers]
follower_names = [
sync_status.name
for sync_status in sync_status_list
if sync_status.id in follower_ids
x.sync_status.name
for x in player_entities
if x.sync_status.id in follower_ids
]
follower_names.insert(0, leader_sync_status.name)
return follower_names
+18 -57
View File
@@ -22,7 +22,6 @@ from bluetooth_adapters import (
adapter_model,
adapter_unique_name,
get_adapters,
get_manufacturer_from_mac,
)
from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME
from habluetooth import (
@@ -52,7 +51,7 @@ from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import async_delete_issue
from homeassistant.loader import async_get_bluetooth
from . import passive_update_processor, websocket_api
from . import passive_update_processor
from .api import (
_get_manager,
async_address_present,
@@ -67,7 +66,6 @@ from .api import (
async_rediscover_address,
async_register_callback,
async_register_scanner,
async_remove_scanner,
async_scanner_by_source,
async_scanner_count,
async_scanner_devices_by_address,
@@ -79,9 +77,6 @@ from .const import (
CONF_ADAPTER,
CONF_DETAILS,
CONF_PASSIVE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL,
DOMAIN,
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS,
@@ -97,24 +92,9 @@ if TYPE_CHECKING:
from homeassistant.helpers.typing import ConfigType
__all__ = [
"FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS",
"MONOTONIC_TIME",
"SOURCE_LOCAL",
"BaseHaRemoteScanner",
"BaseHaScanner",
"BluetoothCallback",
"BluetoothCallbackMatcher",
"BluetoothChange",
"BluetoothScannerDevice",
"BluetoothScanningMode",
"BluetoothServiceInfo",
"BluetoothServiceInfoBleak",
"HaBluetoothConnector",
"HomeAssistantRemoteScanner",
"async_address_present",
"async_ble_device_from_address",
"async_discovered_service_info",
"async_get_advertisement_callback",
"async_get_fallback_availability_interval",
"async_get_learned_advertising_interval",
"async_get_scanner",
@@ -123,12 +103,26 @@ __all__ = [
"async_rediscover_address",
"async_register_callback",
"async_register_scanner",
"async_remove_scanner",
"async_set_fallback_availability_interval",
"async_track_unavailable",
"async_scanner_by_source",
"async_scanner_count",
"async_scanner_devices_by_address",
"async_set_fallback_availability_interval",
"async_track_unavailable",
"async_get_advertisement_callback",
"BaseHaScanner",
"HomeAssistantRemoteScanner",
"BluetoothCallbackMatcher",
"BluetoothChange",
"BluetoothServiceInfo",
"BluetoothServiceInfoBleak",
"BluetoothScanningMode",
"BluetoothCallback",
"BluetoothScannerDevice",
"HaBluetoothConnector",
"BaseHaRemoteScanner",
"SOURCE_LOCAL",
"FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS",
"MONOTONIC_TIME",
]
_LOGGER = logging.getLogger(__name__)
@@ -238,7 +232,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
set_manager(manager)
await storage_setup_task
await manager.async_setup()
websocket_api.async_setup(hass)
hass.async_create_background_task(
_async_start_adapter_discovery(hass, manager, bluetooth_adapters),
@@ -319,38 +312,6 @@ async def async_update_device(
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry for a bluetooth scanner."""
if source_entry_id := entry.data.get(CONF_SOURCE_CONFIG_ENTRY_ID):
if not (source_entry := hass.config_entries.async_get_entry(source_entry_id)):
# Cleanup the orphaned entry using a call_soon to ensure
# we can return before the entry is removed
hass.loop.call_soon(
hass_callback(
lambda: hass.async_create_task(
hass.config_entries.async_remove(entry.entry_id),
"remove orphaned bluetooth entry {entry.entry_id}",
)
)
)
address = entry.unique_id
assert address is not None
assert source_entry is not None
source_domain = entry.data[CONF_SOURCE_DOMAIN]
if mac_manufacturer := await get_manufacturer_from_mac(address):
manufacturer = f"{mac_manufacturer} ({source_domain})"
else:
manufacturer = source_domain
details = AdapterDetails(
address=address,
product=entry.data.get(CONF_SOURCE_MODEL),
manufacturer=manufacturer,
)
await async_update_device(
hass,
entry,
source_entry.title,
details,
)
return True
manager = _get_manager(hass)
address = entry.unique_id
assert address is not None
@@ -132,7 +132,7 @@ class ActiveBluetoothDataUpdateCoordinator[_T](PassiveBluetoothDataUpdateCoordin
)
self.last_poll_successful = False
return
except Exception:
except Exception: # noqa: BLE001
if self.last_poll_successful:
self.logger.exception("%s: Failure while polling", self.address)
self.last_poll_successful = False
@@ -127,7 +127,7 @@ class ActiveBluetoothProcessorCoordinator[_DataT](
)
self.last_poll_successful = False
return
except Exception:
except Exception: # noqa: BLE001
if self.last_poll_successful:
self.logger.exception("%s: Failure while polling", self.address)
self.last_poll_successful = False
+1 -12
View File
@@ -178,20 +178,9 @@ def async_register_scanner(
hass: HomeAssistant,
scanner: BaseHaScanner,
connection_slots: int | None = None,
source_domain: str | None = None,
source_model: str | None = None,
source_config_entry_id: str | None = None,
) -> CALLBACK_TYPE:
"""Register a BleakScanner."""
return _get_manager(hass).async_register_hass_scanner(
scanner, connection_slots, source_domain, source_model, source_config_entry_id
)
@hass_callback
def async_remove_scanner(hass: HomeAssistant, source: str) -> None:
"""Permanently remove a BleakScanner by source address."""
return _get_manager(hass).async_remove_scanner(source)
return _get_manager(hass).async_register_scanner(scanner, connection_slots)
@hass_callback
@@ -18,12 +18,7 @@ from habluetooth import get_manager
import voluptuous as vol
from homeassistant.components import onboarding
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.core import callback
from homeassistant.helpers.schema_config_entry_flow import (
SchemaFlowFormStep,
@@ -31,16 +26,7 @@ from homeassistant.helpers.schema_config_entry_flow import (
)
from homeassistant.helpers.typing import DiscoveryInfoType
from .const import (
CONF_ADAPTER,
CONF_DETAILS,
CONF_PASSIVE,
CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL,
DOMAIN,
)
from .const import CONF_ADAPTER, CONF_DETAILS, CONF_PASSIVE, DOMAIN
from .util import adapter_title
OPTIONS_SCHEMA = vol.Schema(
@@ -77,8 +63,6 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
self, discovery_info: DiscoveryInfoType
) -> ConfigFlowResult:
"""Handle a flow initialized by discovery."""
if discovery_info and CONF_SOURCE in discovery_info:
return await self.async_step_external_scanner(discovery_info)
self._adapter = cast(str, discovery_info[CONF_ADAPTER])
self._details = cast(AdapterDetails, discovery_info[CONF_DETAILS])
await self.async_set_unique_id(self._details[ADAPTER_ADDRESS])
@@ -183,24 +167,6 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
),
)
async def async_step_external_scanner(
self, user_input: dict[str, Any]
) -> ConfigFlowResult:
"""Handle a flow initialized by an external scanner."""
source = user_input[CONF_SOURCE]
await self.async_set_unique_id(source)
data = {
CONF_SOURCE: source,
CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL],
CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN],
CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID],
}
self._abort_if_unique_id_configured(updates=data)
manager = get_manager()
scanner = manager.async_scanner_by_source(source)
assert scanner is not None
return self.async_create_entry(title=scanner.name, data=data)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -211,10 +177,8 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> SchemaOptionsFlowHandler | RemoteAdapterOptionsFlowHandler:
) -> SchemaOptionsFlowHandler:
"""Get the options flow for this handler."""
if CONF_SOURCE in config_entry.data:
return RemoteAdapterOptionsFlowHandler()
return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW)
@classmethod
@@ -222,13 +186,3 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool:
"""Return options flow support for this handler."""
return bool((manager := get_manager()) and manager.supports_passive_scan)
class RemoteAdapterOptionsFlowHandler(OptionsFlow):
"""Handle a option flow for remote adapters."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle options flow."""
return self.async_abort(reason="remote_adapters_not_supported")

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