mirror of
https://github.com/home-assistant/core.git
synced 2026-05-04 11:54:35 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bda95f4d7a |
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
- "**strings.json"
|
||||
|
||||
env:
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
DEFAULT_PYTHON: "3.12"
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*
|
||||
|
||||
Vendored
+1
-2
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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__(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,5 +26,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioacaia"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioacaia==0.1.14"]
|
||||
"requirements": ["aioacaia==0.1.13"]
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user