Compare commits

..

2 Commits

Author SHA1 Message Date
Franck Nijhof 28f41a2310 Merge branch 'dev' into drop-ignore-missing-annotations 2026-06-06 15:35:11 +02:00
epenet f2361ef5aa Drop ignore-missing-annotations from pylint 2026-04-07 11:25:00 +00:00
715 changed files with 4324 additions and 22729 deletions
+6 -6
View File
@@ -38,7 +38,7 @@ jobs:
base_image_version: ${{ env.BASE_IMAGE_VERSION }}
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -102,7 +102,7 @@ jobs:
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -245,7 +245,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -292,7 +292,7 @@ jobs:
contents: read
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -471,7 +471,7 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -518,7 +518,7 @@ jobs:
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -40,7 +40,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
+11 -11
View File
@@ -31,12 +31,12 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# - github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
# - github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
#
# Container images used:
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
@@ -90,7 +90,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -133,7 +133,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
sparse-checkout: |
@@ -352,7 +352,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -372,7 +372,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Create gh-aw temp directory
@@ -961,7 +961,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1100,7 +1100,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1127,7 +1127,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1325,7 +1325,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
@@ -1383,7 +1383,7 @@ jobs:
steps:
- name: Setup Scripts
id: setup
uses: github/gh-aw-actions/setup@73ed520ae4ecd087a485e1991605595978b32ac1 # v0.78.1
uses: github/gh-aw-actions/setup@97f280b14527ca95859c0facba201aeccb2c097f # v0.77.3
with:
destination: ${{ runner.temp }}/gh-aw/actions
job-name: ${{ github.job }}
+26 -26
View File
@@ -98,7 +98,7 @@ jobs:
skip_coverage: ${{ steps.info.outputs.skip_coverage }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Generate partial Python venv restore key
@@ -264,7 +264,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Register problem matchers
@@ -291,7 +291,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run zizmor
@@ -318,7 +318,7 @@ jobs:
- script/hassfest/docker/Dockerfile
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Register hadolint problem matcher
@@ -341,7 +341,7 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
@@ -404,7 +404,7 @@ jobs:
echo "version=$(grep '^uv==' requirements.txt | cut -d'=' -f3)" >> "$GITHUB_OUTPUT"
- name: Set up uv
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ steps.read-uv-version.outputs.version }}
- name: Create Python virtual environment
@@ -469,7 +469,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -512,7 +512,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
@@ -548,7 +548,7 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
@@ -576,7 +576,7 @@ jobs:
&& github.event_name == 'pull_request'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Dependency review
@@ -603,7 +603,7 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ matrix.python-version }}
@@ -654,7 +654,7 @@ jobs:
|| github.event.inputs.pylint-only == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
@@ -680,7 +680,7 @@ jobs:
run: |
. venv/bin/activate
python --version
pylint --ignore-missing-annotations=y homeassistant
pylint homeassistant
- name: Run pylint (partially)
if: needs.info.outputs.test_full_suite == 'false'
shell: bash
@@ -689,7 +689,7 @@ jobs:
run: |
. venv/bin/activate
python --version
pylint --ignore-missing-annotations=y $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
pylint $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
pylint-tests:
name: Check pylint on tests
@@ -707,7 +707,7 @@ jobs:
&& (needs.info.outputs.tests_glob || needs.info.outputs.test_full_suite == 'true')
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
@@ -758,7 +758,7 @@ jobs:
|| github.event.inputs.mypy-only == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
@@ -825,7 +825,7 @@ jobs:
- base
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -889,7 +889,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1030,7 +1030,7 @@ jobs:
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1179,7 +1179,7 @@ jobs:
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1317,7 +1317,7 @@ jobs:
if: needs.info.outputs.skip_coverage != 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download all coverage artifacts
@@ -1326,7 +1326,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
fail_ci_if_error: true
flags: full-suite
@@ -1355,7 +1355,7 @@ jobs:
group: ${{ fromJson(needs.info.outputs.test_groups) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install additional OS dependencies
@@ -1476,7 +1476,7 @@ jobs:
- pytest-partial
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download all coverage artifacts
@@ -1485,7 +1485,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
@@ -1513,7 +1513,7 @@ jobs:
with:
pattern: test-results-*
- name: Upload test results to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
report_type: test_results
fail_ci_if_error: true
+3 -3
View File
@@ -23,16 +23,16 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
category: "/language:python"
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
+3 -3
View File
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -116,7 +116,7 @@ jobs:
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -167,7 +167,7 @@ jobs:
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
-1
View File
@@ -96,7 +96,6 @@ homeassistant.components.aprs.*
homeassistant.components.apsystems.*
homeassistant.components.aqualogic.*
homeassistant.components.aquostv.*
homeassistant.components.aqvify.*
homeassistant.components.aranet.*
homeassistant.components.arcam_fmj.*
homeassistant.components.arris_tg2492lg.*
Generated
+2 -8
View File
@@ -162,8 +162,6 @@ CLAUDE.md @home-assistant/core
/tests/components/apsystems/ @mawoka-myblock @SonnenladenGmbH
/homeassistant/components/aquacell/ @Jordi1990
/tests/components/aquacell/ @Jordi1990
/homeassistant/components/aqvify/ @astrandb
/tests/components/aqvify/ @astrandb
/homeassistant/components/aranet/ @aschmitz @thecode @anrijs
/tests/components/aranet/ @aschmitz @thecode @anrijs
/homeassistant/components/arcam_fmj/ @elupus
@@ -576,8 +574,8 @@ CLAUDE.md @home-assistant/core
/tests/components/flo/ @dmulcahey
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor
/homeassistant/components/fluss/ @fluss @Marcello17
/tests/components/fluss/ @fluss @Marcello17
/homeassistant/components/fluss/ @fluss
/tests/components/fluss/ @fluss
/homeassistant/components/flux_led/ @icemanch
/tests/components/flux_led/ @icemanch
/homeassistant/components/forecast_solar/ @klaasnicolaas @frenck
@@ -947,8 +945,6 @@ CLAUDE.md @home-assistant/core
/tests/components/kiosker/ @Claeysson
/homeassistant/components/kitchen_sink/ @home-assistant/core
/tests/components/kitchen_sink/ @home-assistant/core
/homeassistant/components/klik_aan_klik_uit/ @Phunkafizer
/tests/components/klik_aan_klik_uit/ @Phunkafizer
/homeassistant/components/kmtronic/ @dgomes
/tests/components/kmtronic/ @dgomes
/homeassistant/components/knocki/ @joostlek @jgatto1 @JakeBosh
@@ -1086,8 +1082,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/mediaroom/ @dgomes
/homeassistant/components/melcloud/ @erwindouna
/tests/components/melcloud/ @erwindouna
/homeassistant/components/melcloud_home/ @erwindouna
/tests/components/melcloud_home/ @erwindouna
/homeassistant/components/melissa/ @kennedyshead
/tests/components/melissa/ @kennedyshead
/homeassistant/components/melnor/ @vanstinator
@@ -116,6 +116,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Migrate old config entry."""
# This means the user has downgraded from a future version
if entry.version > 2:
return False
# 1.1 Migrate config_entry to add advanced ssl settings
if entry.version == 1 and entry.minor_version == 1:
new_minor_version = 2
@@ -65,6 +65,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version > 1:
# This means the user has downgraded from a future version
return False
if entry.version == 1 and entry.minor_version < 3:
if CONF_SITE in entry.data:
# Site in data (wrong place), just move to login data
@@ -6,7 +6,7 @@ from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .entity import AmazonServiceEntity
# Coordinator is used to centralize the data updates
@@ -49,5 +49,4 @@ class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity):
async def async_press(self) -> None:
"""Handle button press action."""
async with alexa_api_call(self.coordinator):
await self.coordinator.api.call_routine(self._routine)
await self.coordinator.api.call_routine(self._routine)
@@ -1,7 +1,5 @@
"""Support for Alexa Devices."""
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import timedelta
from aioamazondevices.api import AmazonEchoApi
@@ -21,11 +19,7 @@ from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -35,65 +29,6 @@ from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
SCAN_INTERVAL = 300
@asynccontextmanager
async def alexa_api_call(
coordinator: DataUpdateCoordinator | None = None,
) -> AsyncGenerator[None]:
"""Handle common Alexa API exceptions as HomeAssistantError."""
try:
yield
except CannotAuthenticate as err:
if coordinator:
coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except CannotConnect as err:
if coordinator:
coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotRetrieveData, ValueError) as err:
if coordinator:
coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
@asynccontextmanager
async def alexa_config_entry_errors() -> AsyncGenerator[None]:
"""Handle common Alexa API exceptions as ConfigEntry errors."""
try:
yield
except CannotAuthenticate as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except (CannotConnect, TimeoutError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotRetrieveData, ValueError, KeyError, StopIteration) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator]
@@ -178,12 +113,6 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except ValueError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
else:
current_devices = set(data.keys())
if stale_devices := self.previous_devices - current_devices:
@@ -240,8 +169,26 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
async def sync_history_state(self) -> None:
"""Sync history state."""
async with alexa_config_entry_errors():
try:
self._vocal_records = await self.api.sync_history_state()
except CannotAuthenticate as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(e)},
) from e
except CannotConnect as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(e)},
) from e
except BaseException as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(e)},
) from e
async def history_state_event_handler(
self, vocal_records: dict[str, AmazonVocalRecord]
@@ -257,8 +204,26 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
async def sync_media_state(self) -> None:
"""Sync media state."""
async with alexa_config_entry_errors():
try:
await self.api.sync_media_state()
except CannotAuthenticate as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except (CannotConnect, TimeoutError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotRetrieveData, ValueError) as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
async def media_state_event_handler(
self, media_state: dict[str, AmazonMediaState]
@@ -12,18 +12,7 @@ from homeassistant.helpers.device_registry import DeviceEntry
from .coordinator import AmazonConfigEntry
TO_REDACT = {
CONF_NAME,
CONF_PASSWORD,
CONF_USERNAME,
"access_token",
"adp_token",
"device_private_key",
"refresh_token",
"store_authentication_cookie",
"title",
"website_cookies",
}
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"}
async def async_get_config_entry_diagnostics(
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==14.0.3"]
"requirements": ["aioamazondevices==14.0.0"]
}
@@ -22,8 +22,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import _LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator, alexa_api_call
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
@@ -215,15 +216,16 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
provider = media_type.value if isinstance(media_type, MediaType) else media_type
await self.async_call_alexa_music(media_id, provider)
@alexa_api_call
async def async_call_alexa_music(
self, search_phrase: str, provider_id: str
) -> None:
"""Call alexa music."""
async with alexa_api_call(self.coordinator):
await self.coordinator.api.call_alexa_music(
self.device, search_phrase, provider_id
)
await self.coordinator.api.call_alexa_music(
self.device, search_phrase, provider_id
)
@alexa_api_call
async def async_set_device_volume(self, volume: int) -> None:
"""Set the device volume."""
_LOGGER.debug(
@@ -231,8 +233,7 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
self.device.serial_number,
volume,
)
async with alexa_api_call(self.coordinator):
await self.coordinator.api.set_device_volume(self.device, volume)
await self.coordinator.api.set_device_volume(self.device, volume)
async def async_set_volume_level(self, volume: float) -> None:
"""Set the volume level (0.0 to 1.0)."""
@@ -262,12 +263,12 @@ class AlexaDevicesMediaPlayer(AmazonEntity, MediaPlayerEntity):
await self.async_set_volume_level(target_volume / 100)
self._prev_volume = None
@alexa_api_call
async def _send_media_command(self, command: AmazonMediaControls) -> None:
_LOGGER.debug(
"Sending media command '%s' to %s", command, self.device.serial_number
)
async with alexa_api_call(self.coordinator):
await self.coordinator.api.send_media_command(self.device, command)
await self.coordinator.api.send_media_command(self.device, command)
async def async_media_stop(self) -> None:
"""Send stop command."""
@@ -12,8 +12,9 @@ from homeassistant.components.notify import NotifyEntity, NotifyEntityDescriptio
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry, alexa_api_call
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import alexa_api_call
PARALLEL_UPDATES = 1
@@ -79,11 +80,10 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
entity_description: AmazonNotifyEntityDescription
@alexa_api_call
async def async_send_message(
self, message: str, title: str | None = None, **kwargs: Any
) -> None:
"""Send a message."""
async with alexa_api_call(self.coordinator):
await self.entity_description.method(
self.coordinator.api, self.device, message
)
await self.entity_description.method(self.coordinator.api, self.device, message)
@@ -11,7 +11,7 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from .const import DOMAIN, INFO_SKILLS_MAPPING
from .coordinator import AmazonConfigEntry, alexa_api_call
from .coordinator import AmazonConfigEntry
ATTR_TEXT_COMMAND = "text_command"
ATTR_SOUND = "sound"
@@ -85,15 +85,13 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
translation_key="invalid_sound_value",
translation_placeholders={"sound": value},
)
async with alexa_api_call():
await coordinator.api.call_alexa_sound(
coordinator.data[device.serial_number], value
)
await coordinator.api.call_alexa_sound(
coordinator.data[device.serial_number], value
)
elif attribute == ATTR_TEXT_COMMAND:
async with alexa_api_call():
await coordinator.api.call_alexa_text_command(
coordinator.data[device.serial_number], value
)
await coordinator.api.call_alexa_text_command(
coordinator.data[device.serial_number], value
)
elif attribute == ATTR_INFO_SKILL:
info_skill = INFO_SKILLS_MAPPING.get(value)
if info_skill not in ALEXA_INFO_SKILLS:
@@ -102,10 +100,9 @@ async def _async_execute_action(call: ServiceCall, attribute: str) -> None:
translation_key="invalid_info_skill_value",
translation_placeholders={"info_skill": value},
)
async with alexa_api_call():
await coordinator.api.call_alexa_info_skill(
coordinator.data[device.serial_number], info_skill
)
await coordinator.api.call_alexa_info_skill(
coordinator.data[device.serial_number], info_skill
)
async def async_send_sound_notification(call: ServiceCall) -> None:
@@ -14,9 +14,13 @@ from homeassistant.components.switch import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry, alexa_api_call
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import async_remove_dnd_from_virtual_group, async_update_unique_id
from .utils import (
alexa_api_call,
async_remove_dnd_from_virtual_group,
async_update_unique_id,
)
PARALLEL_UPDATES = 1
@@ -86,6 +90,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
entity_description: AmazonSwitchEntityDescription
@alexa_api_call
async def _switch_set_state(self, state: bool) -> None:
"""Set desired switch state."""
method = getattr(self.coordinator.api, self.entity_description.method)
@@ -93,8 +98,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
if TYPE_CHECKING:
assert method is not None
async with alexa_api_call(self.coordinator):
await method(self.device, state)
await method(self.device, state)
self.coordinator.data[self.device.serial_number].sensors[
self.entity_description.key
].value = state
@@ -1,19 +1,54 @@
"""Utils for Alexa Devices."""
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
from aioamazondevices.const.schedules import (
NOTIFICATION_ALARM,
NOTIFICATION_REMINDER,
NOTIFICATION_TIMER,
)
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.entity_registry as er
from .const import _LOGGER, DOMAIN
from .coordinator import AmazonDevicesCoordinator
from .entity import AmazonEntity
def alexa_api_call[_T: AmazonEntity, **_P](
func: Callable[Concatenate[_T, _P], Awaitable[None]],
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
"""Catch Alexa API call exceptions."""
@wraps(func)
async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
"""Wrap all command methods."""
try:
await func(self, *args, **kwargs)
except CannotConnect as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(err)},
) from err
except CannotRetrieveData as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
return cmd_wrapper
async def async_update_unique_id(
@@ -75,6 +75,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnovaConfigEntry) -> b
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 1:
# This means the user has downgraded from a future version
return False
if entry.version == 1 and entry.minor_version == 1:
new_data = {**entry.data}
if CONF_DEVICES in new_data:
@@ -178,6 +178,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
"""Migrate entry."""
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 2:
# This means the user has downgraded from a future version
return False
if entry.version == 2 and entry.minor_version == 1:
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
device_registry = dr.async_get(hass)
@@ -1,6 +1,7 @@
"""Coordinator for the Anthropic integration."""
import datetime
import re
import anthropic
@@ -19,12 +20,15 @@ UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1)
type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator]
_model_short_form = re.compile(r"[^\d]-\d$")
@callback
def model_alias(model_id: str) -> str:
"""Resolve alias from versioned model name."""
if model_id[-2:-1] != "-" and not model_id.endswith("-preview"):
model_id = model_id[:-9]
if model_id.endswith("-4"):
if _model_short_form.search(model_id):
return model_id + "-0"
return model_id
@@ -9,5 +9,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["anthropic==0.108.0"]
"requirements": ["anthropic==0.96.0"]
}
@@ -65,18 +65,18 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
]
self._model_list_cache[entry.entry_id] = model_list
family = (
model.removeprefix("claude-")
.removesuffix("-preview")
.translate(str.maketrans("", "", "0123456789-."))
or "haiku"
)
if "opus" in model:
family = "claude-opus"
elif "sonnet" in model:
family = "claude-sonnet"
else:
family = "claude-haiku"
suggested_model = next(
(
model_option["value"]
for model_option in sorted(
(m for m in model_list if f"claude-{family}" in m["value"]),
(m for m in model_list if family in m["value"]),
key=lambda x: x["value"],
reverse=True,
)
+1
View File
@@ -59,6 +59,7 @@ ATTR_EXTERNAL_URL = "external_url"
ATTR_INTERNAL_URL = "internal_url"
ATTR_LOCATION_NAME = "location_name"
ATTR_INSTALLATION_TYPE = "installation_type"
ATTR_REQUIRES_API_PASSWORD = "requires_api_password"
ATTR_UUID = "uuid"
ATTR_VERSION = "version"
@@ -1,28 +0,0 @@
"""The Aqvify integration."""
import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AqvifyConfigEntry, AqvifyCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: AqvifyConfigEntry) -> bool:
"""Set up Aqvify from a config entry."""
coordinator = AqvifyCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AqvifyConfigEntry) -> bool:
"""Unload Aqvify config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,113 +0,0 @@
"""Config flow for the Aqvify integration."""
from collections.abc import Mapping
import logging
from typing import Any
from aiohttp import ClientResponseError
from pyaqvify import AqvifyAPI, AqvifyAuthException
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
}
)
class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Aqvify."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
hub = AqvifyAPI(
user_input[CONF_API_KEY],
websession=async_get_clientsession(self.hass),
)
try:
account_data = await hub.async_get_account_id()
except AqvifyAuthException:
errors["base"] = "invalid_auth"
except ClientResponseError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(account_data.account_id)
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), data_updates=user_input
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title="Aqvify", data=user_input)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders={
"aqvify_url": "https://app.aqvify.com/User",
},
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle re-authentication confirmation."""
errors = {}
if user_input is not None:
api_client = AqvifyAPI(
user_input[CONF_API_KEY],
websession=async_get_clientsession(self.hass),
)
try:
account_data = await api_client.async_get_account_id()
except AqvifyAuthException:
errors["base"] = "invalid_auth"
except ClientResponseError:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(account_data.account_id)
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data_updates=user_input
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def async_step_reconfigure(
self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
"""User initiated reconfiguration."""
return await self.async_step_user()
-3
View File
@@ -1,3 +0,0 @@
"""Constants for the Aqvify integration."""
DOMAIN = "aqvify"
@@ -1,137 +0,0 @@
"""Coordinator for Aqvify integration."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from aiohttp import ClientResponseError
from pyaqvify import AqvifyAPI, AqvifyAuthException, AqvifyDeviceData, AqvifyDevices
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(seconds=60)
type AqvifyConfigEntry = ConfigEntry[AqvifyCoordinator]
@dataclass
class AqvifyCoordinatorData:
"""Data class for storing coordinator data."""
devices: AqvifyDevices
device_data: dict[str, AqvifyDeviceData]
class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
"""Data update coordinator for Aqvify devices."""
config_entry: AqvifyConfigEntry
def __init__(self, hass: HomeAssistant, entry: AqvifyConfigEntry) -> None:
"""Initialize the Aqvify data update coordinator."""
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
config_entry=entry,
)
self.api_client = AqvifyAPI(
entry.data[CONF_API_KEY], websession=async_get_clientsession(hass)
)
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
await self.api_client.async_get_account_id()
except AqvifyAuthException:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_api_key",
) from None
except ClientResponseError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
except TimeoutError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="api_timeout",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
async def _async_update_data(self) -> AqvifyCoordinatorData:
"""Fetch device state."""
try:
devices = await self.api_client.async_get_devices()
except AqvifyAuthException:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_api_key",
) from None
except ClientResponseError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
except TimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_timeout",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
device_data = {}
for device in devices.devices.values():
try:
device_key = str(device.device_key)
device_data[
device_key
] = await self.api_client.async_get_device_latest_data(device_key)
except AqvifyAuthException:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_api_key",
) from None
except ClientResponseError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
except TimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_timeout",
translation_placeholders={
"entry": self.config_entry.title,
},
) from err
return AqvifyCoordinatorData(
devices=devices,
device_data=device_data,
)
@@ -1,30 +0,0 @@
"""Diagnostics platform for Aqvify integration."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from .coordinator import AqvifyConfigEntry
TO_REDACT = [CONF_API_KEY]
TO_REDACT_AQVIFY = ["name"]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AqvifyConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
device_list_raw_data = entry.runtime_data.data.devices.raw
device_data_raw_data = {
key: device.raw_data
for key, device in entry.runtime_data.data.device_data.items()
}
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"devices": async_redact_data(device_list_raw_data, TO_REDACT_AQVIFY),
"device_data": device_data_raw_data,
}
-35
View File
@@ -1,35 +0,0 @@
"""Defines a base Aqvify entity."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AqvifyCoordinator
class AqvifyBaseEntity(CoordinatorEntity[AqvifyCoordinator]):
"""Defines a base Aqvify entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AqvifyCoordinator,
description: EntityDescription,
device_key: str,
) -> None:
"""Initialize the Aqvify entity."""
super().__init__(coordinator)
account_id = self.coordinator.config_entry.unique_id
self.device_key = device_key
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{account_id}_{device_key}")},
name=coordinator.data.devices.devices[device_key].name,
manufacturer="Aqvify",
configuration_url="https://app.aqvify.com",
serial_number=device_key,
)
self._attr_unique_id = f"{account_id}_{device_key}_{description.key}"
self.entity_description = description
@@ -1,12 +0,0 @@
{
"entity": {
"sensor": {
"meter_value": {
"default": "mdi:waves-arrow-up"
},
"water_level": {
"default": "mdi:waves"
}
}
}
}
@@ -1,12 +0,0 @@
{
"domain": "aqvify",
"name": "Aqvify",
"codeowners": ["@astrandb"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aqvify",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyaqvify"],
"quality_scale": "bronze",
"requirements": ["pyaqvify==0.0.9"]
}
@@ -1,69 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
No actions in this integration.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
The integration does not provide any actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration do not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: todo
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
-79
View File
@@ -1,79 +0,0 @@
"""Sensor platform for Aqvify integration."""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from pyaqvify import AqvifyDeviceData
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
StateType,
)
from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AqvifyConfigEntry
from .entity import AqvifyBaseEntity
# Coordinator is used to centralize the data updates.
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AqvifySensorEntityDescription(SensorEntityDescription):
"""Description of an Aqvify sensor entity."""
value_fn: Callable[[AqvifyDeviceData], float | int | None]
ENTITIES: tuple[AqvifySensorEntityDescription, ...] = (
AqvifySensorEntityDescription(
key="meter_value",
translation_key="meter_value",
native_unit_of_measurement=UnitOfLength.METERS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=2,
value_fn=lambda value: value.meter_value,
),
AqvifySensorEntityDescription(
key="water_level",
translation_key="water_level",
native_unit_of_measurement=UnitOfLength.METERS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=2,
value_fn=lambda value: value.water_level,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AqvifyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Aqvify sensor entities from a config entry."""
async_add_entities(
AqvifySensor(entry.runtime_data, description, device_key)
for description in ENTITIES
for device_key in entry.runtime_data.data.devices.devices
)
class AqvifySensor(AqvifyBaseEntity, SensorEntity):
"""Representation of an Aqvify sensor entity."""
entity_description: AqvifySensorEntityDescription
@property
def native_value(self) -> StateType | datetime | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(
self.coordinator.data.device_data[self.device_key]
)
@@ -1,56 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The entered API key corresponds to a different account."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::aqvify::config::step::user::data_description::api_key%]"
},
"description": "Reauthentication required. Please enter your updated API key."
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "Your Aqvify API key"
},
"description": "Navigate to your [Aqvify account]({aqvify_url}), copy your API key, and paste it below."
}
}
},
"entity": {
"sensor": {
"meter_value": {
"name": "Meter value"
},
"water_level": {
"name": "Water level"
}
}
},
"exceptions": {
"api_error": {
"message": "An error occurred while communicating with the Aqvify API for {entry}"
},
"api_timeout": {
"message": "Timeout occurred while communicating with the Aqvify API for {entry}"
},
"invalid_api_key": {
"message": "Invalid API key. Please verify your API key and try to reauthenticate."
}
}
}
@@ -1816,11 +1816,6 @@ class PipelineInput:
await self.run.text_to_speech(tts_input)
except PipelineError as err:
if self.run.tts_stream:
# Clean up TTS stream
self.run.tts_stream.delete()
self.run.tts_stream = None
self.run.process_event(
PipelineEvent(
PipelineEventType.ERROR,
@@ -1890,17 +1885,15 @@ class PipelineInput:
):
prepare_tasks.append(self.run.prepare_recognize_intent(self.session))
if prepare_tasks:
await asyncio.gather(*prepare_tasks)
# Do TTS prepare separately so we don't create a ResultStream if the
# pipeline is invalid.
if (
start_stage_index
<= PIPELINE_STAGE_ORDER.index(PipelineStage.TTS)
<= end_stage_index
):
await self.run.prepare_text_to_speech()
prepare_tasks.append(self.run.prepare_text_to_speech())
if prepare_tasks:
await asyncio.gather(*prepare_tasks)
class PipelinePreferred(CollectionError):
@@ -3,11 +3,8 @@
from dataclasses import asdict
import logging
from pathlib import Path
import re
from typing import Any
from hassil.parse_expression import parse_sentence
from hassil.parser import ParseError
from hassil.util import (
PUNCTUATION_END,
PUNCTUATION_END_WORD,
@@ -167,7 +164,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
[cv.string],
has_one_non_empty_item,
has_no_punctuation,
is_valid_sentence,
),
}
],
@@ -205,8 +201,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
def has_no_punctuation(value: list[str]) -> list[str]:
"""Validate result does not contain punctuation."""
for sentence in value:
# Exclude {list_references} which may contain punctuation characters.
sentence = _remove_list_references(sentence)
if (
PUNCTUATION_START.search(sentence)
or PUNCTUATION_END.search(sentence)
@@ -218,21 +212,6 @@ def has_no_punctuation(value: list[str]) -> list[str]:
return value
def _remove_list_references(sentence: str) -> str:
"""Remove {list_references} from a sentence for linting."""
return re.sub(r"(?<!\\)\{[^{}]*\}", "", sentence)
def is_valid_sentence(value: list[str]) -> list[str]:
"""Validate result can be parsed by hassil."""
for sentence in value:
try:
parse_sentence(sentence)
except ParseError as err:
raise vol.Invalid(f"invalid sentence: {err}") from err
return value
def has_one_non_empty_item(value: list[str]) -> list[str]:
"""Validate result has at least one item."""
if len(value) < 1:
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.7.0"]
"requirements": ["hassil==3.6.0"]
}
-10
View File
@@ -24,13 +24,3 @@ OPEN_STATUS: dict[int, str] = {
LIGHT_MAX_KELVINS = 6500 # 154 Mireds
LIGHT_MIN_KELVINS = 2700 # 370 Mireds
CO2_LEVEL: dict[int, str] = {
0: "excellent",
1: "good",
2: "acceptable",
3: "medium",
4: "poor",
5: "unhealthy",
6: "hazardous",
}
+3 -3
View File
@@ -90,10 +90,10 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
if feature.has_tilt:
self._attr_supported_features |= (
CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT
CoverEntityFeature.SET_TILT_POSITION
| CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
)
if feature.is_calibrated:
self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION
if feature.tilt_only:
self._attr_supported_features &= ~(
@@ -18,12 +18,6 @@
}
},
"sensor": {
"co2_level": {
"default": "mdi:molecule-co2"
},
"open_status": {
"default": "mdi:window-open"
},
"power_consumption": {
"default": "mdi:lightning-bolt"
}
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["blebox_uniapi"],
"requirements": ["blebox-uniapi==2.5.5"],
"requirements": ["blebox-uniapi==2.5.4"],
"zeroconf": ["_bbxsrv._tcp.local."]
}
+2 -30
View File
@@ -14,7 +14,6 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
PERCENTAGE,
UnitOfApparentPower,
@@ -32,7 +31,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import BleBoxConfigEntry
from .const import CO2_LEVEL, OPEN_STATUS
from .const import OPEN_STATUS
from .coordinator import BleBoxCoordinator
from .entity import BleBoxEntity
@@ -51,25 +50,21 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = (
key="pm1",
device_class=SensorDeviceClass.PM1,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="pm2_5",
device_class=SensorDeviceClass.PM25,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="pm10",
device_class=SensorDeviceClass.PM10,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="powerConsumption",
@@ -81,88 +76,65 @@ SENSOR_TYPES: tuple[BleBoxSensorEntityDescription, ...] = (
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="wind",
device_class=SensorDeviceClass.WIND_SPEED,
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="illuminance",
device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement=LIGHT_LUX,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="forwardActiveEnergy",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
BleBoxSensorEntityDescription(
key="reverseActiveEnergy",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
BleBoxSensorEntityDescription(
key="reactivePower",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="activePower",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="apparentPower",
device_class=SensorDeviceClass.APPARENT_POWER,
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="voltage",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="frequency",
device_class=SensorDeviceClass.FREQUENCY,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="openStatus",
translation_key="open_status",
device_class=SensorDeviceClass.ENUM,
icon="mdi:window-open",
options=list(OPEN_STATUS.values()),
value_fn=lambda v: OPEN_STATUS.get(int(v)) if v is not None else None,
),
BleBoxSensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
),
BleBoxSensorEntityDescription(
key="co2Definition",
translation_key="co2_level",
device_class=SensorDeviceClass.ENUM,
options=list(CO2_LEVEL.values()),
value_fn=lambda v: CO2_LEVEL.get(int(v)) if v is not None else None,
),
)
@@ -37,18 +37,6 @@
},
"entity": {
"sensor": {
"co2_level": {
"name": "Carbon dioxide level",
"state": {
"acceptable": "Acceptable",
"excellent": "Excellent",
"good": "Good",
"hazardous": "Hazardous",
"medium": "Medium",
"poor": "Poor",
"unhealthy": "Unhealthy"
}
},
"open_status": {
"state": {
"ajar": "Ajar",
+4
View File
@@ -169,7 +169,9 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
try:
await self._camera.save_recent_clips(output_dir=file_path)
except OSError as err:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ServiceValidationError(
str(err),
translation_domain=DOMAIN,
translation_key="cant_write",
) from err
@@ -189,7 +191,9 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera):
try:
await self._camera.video_to_file(filename)
except OSError as err:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ServiceValidationError(
str(err),
translation_domain=DOMAIN,
translation_key="cant_write",
) from err
+1 -1
View File
@@ -54,7 +54,7 @@
},
"exceptions": {
"cant_write": {
"message": "Can't write to file, check logs for details."
"message": "Can't write to file."
},
"failed_arm": {
"message": "Blink failed to arm camera."
@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.16",
"habluetooth==6.8.3"
"habluetooth==6.8.1"
]
}
+10 -18
View File
@@ -1,18 +1,19 @@
"""The Brands integration."""
from collections import deque
from collections.abc import Container, Mapping
from http import HTTPStatus
import logging
from pathlib import Path
from random import SystemRandom
import time
from typing import Any, Final
from typing import Any, Final, override
from aiohttp import ClientError, hdrs, web
from aiohttp import ClientError, web
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant, callback, valid_domain
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -108,23 +109,18 @@ def _read_brand_file(brand_dir: Path, image: str) -> bytes | None:
class _BrandsBaseView(HomeAssistantView):
"""Base view for serving brand images."""
requires_auth = False
use_query_token_for_auth = True
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the view."""
self._hass = hass
self._cache_dir = Path(hass.config.cache_path(DOMAIN))
def _authenticate(self, request: web.Request) -> None:
"""Authenticate the request using Bearer token or query token."""
access_tokens: deque[str] = self._hass.data[DOMAIN]
authenticated = (
request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens
)
if not authenticated:
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
raise web.HTTPForbidden
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
return self._hass.data[DOMAIN]
async def _serve_from_custom_integration(
self,
@@ -240,8 +236,6 @@ class BrandsIntegrationView(_BrandsBaseView):
image: str,
) -> web.Response:
"""Handle GET request for an integration brand image."""
self._authenticate(request)
if not valid_domain(domain) or image not in ALLOWED_IMAGES:
return web.Response(status=HTTPStatus.NOT_FOUND)
@@ -274,8 +268,6 @@ class BrandsHardwareView(_BrandsBaseView):
image: str,
) -> web.Response:
"""Handle GET request for a hardware brand image."""
self._authenticate(request)
if not CATEGORY_RE.match(category):
return web.Response(status=HTTPStatus.NOT_FOUND)
# Hardware images have dynamic names like "manufacturer_model.png"
@@ -230,6 +230,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
entry.minor_version,
)
if entry.version > 1:
# Downgraded from a future version; cannot migrate.
return False
# 1.1 -> 1.2: Add CONF_HEATING_CIRCUITS. Attempt to discover available
# heating circuits from the device; fall back to [1] (pre-multi-circuit
# default) if the device is unreachable or the endpoint is unsupported.
@@ -183,6 +183,7 @@ class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity):
try:
await self.coordinator.client.thermostat(**data, circuit=self._circuit)
except BSBLANError as err:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_data_error",
+14 -18
View File
@@ -2,7 +2,7 @@
import asyncio
import collections
from collections.abc import Awaitable, Callable, Coroutine
from collections.abc import Awaitable, Callable, Container, Coroutine, Mapping
from contextlib import suppress
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
@@ -12,16 +12,16 @@ import logging
import os
from random import SystemRandom
import time
from typing import Any, Final, final
from typing import Any, Final, final, override
from aiohttp import hdrs, web
from aiohttp import web
import attr
from propcache.api import cached_property, under_cached_property
import voluptuous as vol
from webrtc_models import RTCIceCandidateInit
from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.media_player import (
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
@@ -776,30 +776,26 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
class CameraView(HomeAssistantView):
"""Base CameraView."""
requires_auth = False
use_query_token_for_auth = True
def __init__(self, component: EntityComponent[Camera]) -> None:
"""Initialize a basic camera view."""
self.component = component
@callback
@override
def get_valid_auth_tokens(self, match_info: Mapping[str, str]) -> Container[str]:
"""Return valid auth tokens, which can be used for query token authentication."""
if (camera := self.component.get_entity(match_info["entity_id"])) is None:
return ()
return camera.access_tokens
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
if (camera := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
authenticated = (
request[KEY_AUTHENTICATED]
or request.query.get("token") in camera.access_tokens
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
raise web.HTTPUnauthorized
# Invalid sigAuth or camera access token
raise web.HTTPForbidden
if not camera.is_on:
_LOGGER.debug("Camera is off")
raise web.HTTPServiceUnavailable
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/chacon_dio",
"iot_class": "cloud_push",
"loggers": ["dio_chacon_api"],
"requirements": ["dio-chacon-wifi-api==1.3.0"]
"requirements": ["dio-chacon-wifi-api==1.2.2"]
}
@@ -87,6 +87,10 @@ async def async_migrate_entry(
) -> bool:
"""Migrate old entry."""
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1 and config_entry.minor_version == 1:
device_registry = dr.async_get(hass)
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.7.0", "home-assistant-intents==2026.6.1"]
"requirements": ["hassil==3.6.0", "home-assistant-intents==2026.6.1"]
}
@@ -1,11 +1,8 @@
"""Offer sentence based automation rules."""
from collections.abc import Awaitable, Callable
import re
from typing import Any
from hassil.parse_expression import parse_sentence
from hassil.parser import ParseError
from hassil.recognize import RecognizeResult
from hassil.util import (
PUNCTUATION_END,
@@ -34,8 +31,6 @@ TRIGGER_CALLBACK_TYPE = Callable[
def has_no_punctuation(value: list[str]) -> list[str]:
"""Validate result does not contain punctuation."""
for sentence in value:
# Exclude {list_references} which may contain punctuation characters.
sentence = _remove_list_references(sentence)
if (
PUNCTUATION_START.search(sentence)
or PUNCTUATION_END.search(sentence)
@@ -47,21 +42,6 @@ def has_no_punctuation(value: list[str]) -> list[str]:
return value
def _remove_list_references(sentence: str) -> str:
"""Remove {list_references} from a sentence for linting."""
return re.sub(r"(?<!\\)\{[^{}]*\}", "", sentence)
def is_valid_sentence(value: list[str]) -> list[str]:
"""Validate result can be parsed by hassil."""
for sentence in value:
try:
parse_sentence(sentence)
except ParseError as err:
raise vol.Invalid(f"invalid sentence: {err}") from err
return value
def has_one_non_empty_item(value: list[str]) -> list[str]:
"""Validate result has at least one item."""
if len(value) < 1:
@@ -78,11 +58,7 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_PLATFORM): DOMAIN,
vol.Required(CONF_COMMAND): vol.All(
cv.ensure_list,
[cv.string],
has_one_non_empty_item,
has_no_punctuation,
is_valid_sentence,
cv.ensure_list, [cv.string], has_one_non_empty_item, has_no_punctuation
),
}
)
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pydaikin"],
"requirements": ["pydaikin==2.18.1"],
"requirements": ["pydaikin==2.17.2"],
"zeroconf": ["_dkapi._tcp.local."]
}
@@ -5,7 +5,7 @@ import logging
from typing import Any
from aiohttp import ClientError, ClientResponseError
from data_grand_lyon_ha import DataGrandLyonClient, TclStop, find_tcl_stop_by_id
from data_grand_lyon_ha import DataGrandLyonClient
import voluptuous as vol
from homeassistant.config_entries import (
@@ -18,12 +18,6 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import (
CONF_LINE,
@@ -49,6 +43,13 @@ STEP_RECONFIGURE_SCHEMA = vol.Schema(
}
)
STEP_STOP_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_LINE): str,
vol.Required(CONF_STOP_ID): vol.Coerce(int),
}
)
STEP_VELOV_STATION_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_STATION_ID): vol.Coerce(int),
@@ -178,126 +179,33 @@ class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN):
class StopSubentryFlowHandler(ConfigSubentryFlow):
"""Handle a subentry flow for adding a Data Grand Lyon stop."""
def __init__(self) -> None:
"""Initialize the flow."""
self._stops: list[TclStop] = []
self._selected_stop: TclStop | None = None
self._selected_stop_id: int | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Pick a stop from the list fetched from the API, or enter one manually."""
if not self._stops:
if error := await self._async_load_stops():
return self.async_abort(reason=error)
"""Handle the user step to add a new stop."""
entry = self._get_entry()
errors: dict[str, str] = {}
if user_input is not None:
try:
stop_id = int(user_input[CONF_STOP_ID])
except ValueError:
errors[CONF_STOP_ID] = "invalid_stop_id"
else:
self._selected_stop_id = stop_id
self._selected_stop = find_tcl_stop_by_id(self._stops, stop_id)
return await self.async_step_pick_line()
line = user_input[CONF_LINE]
stop_id = user_input[CONF_STOP_ID]
unique_id = f"{line}_{stop_id}"
options = [
SelectOptionDict(value=str(stop.id), label=_stop_label(stop))
for stop in sorted(
self._stops, key=lambda s: (s.nom, s.commune or "", s.id or 0)
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="already_configured")
name = f"{line} - Stop {stop_id}"
return self.async_create_entry(
title=name,
data={CONF_LINE: line, CONF_STOP_ID: stop_id},
unique_id=unique_id,
)
]
schema = vol.Schema(
{
vol.Required(CONF_STOP_ID): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.DROPDOWN,
sort=False,
custom_value=True,
)
)
}
)
return self.async_show_form(
step_id="user",
data_schema=schema,
errors=errors,
data_schema=STEP_STOP_DATA_SCHEMA,
)
async def async_step_pick_line(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Pick a line from the selected stop's desserte, or enter one manually."""
assert self._selected_stop_id is not None
if user_input is not None:
return self._create_stop(
line=user_input[CONF_LINE], stop_id=self._selected_stop_id
)
options = self._selected_stop.desserte if self._selected_stop else []
schema = vol.Schema(
{
vol.Required(CONF_LINE): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.DROPDOWN,
custom_value=True,
)
)
}
)
return self.async_show_form(step_id="pick_line", data_schema=schema)
async def _async_load_stops(self) -> str | None:
"""Fetch TCL stops from the API, returning an error key on failure."""
entry = self._get_entry()
session = async_get_clientsession(self.hass)
client = DataGrandLyonClient(
session=session,
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
)
try:
self._stops = await client.get_tcl_stops()
except ClientResponseError as err:
if err.status in (401, 403):
return "invalid_auth"
return "cannot_connect"
except ClientError, TimeoutError:
return "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error fetching Data Grand Lyon TCL stops")
return "unknown"
return None
def _create_stop(self, line: str, stop_id: int) -> SubentryFlowResult:
"""Create the stop subentry, aborting on duplicate."""
entry = self._get_entry()
unique_id = f"{line}_{stop_id}"
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="already_configured")
return self.async_create_entry(
title=f"{line} - Stop {stop_id}",
data={CONF_LINE: line, CONF_STOP_ID: stop_id},
unique_id=unique_id,
)
def _stop_label(stop: TclStop) -> str:
label = stop.nom
# variable extracted to please codespell.
address = stop.adresse # codespell:ignore adresse
if address or stop.commune:
label += " (" + ", ".join(filter(None, [address, stop.commune])) + ")"
label += f" - {stop.id}"
return label
class VelovStationSubentryFlowHandler(ConfigSubentryFlow):
"""Handle a subentry flow for adding a Vélo'v station."""
@@ -46,30 +46,17 @@
"config_subentries": {
"stop": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"entry_type": "Transit stop",
"error": {
"invalid_stop_id": "Stop ID must be a number."
},
"initiate_flow": {
"user": "Add transit stop"
},
"step": {
"pick_line": {
"data": {
"line": "Line"
}
},
"user": {
"data": {
"stop_id": "Stop"
},
"data_description": {
"stop_id": "Search by stop name, address or city, or enter a stop ID directly."
"line": "Line",
"stop_id": "Stop ID"
}
}
}
+2 -20
View File
@@ -5,7 +5,6 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -28,19 +27,7 @@ async def async_setup_entry(
BinarySensorDeviceClass.MOISTURE,
),
DemoBinarySensor(
"binary_2",
"Movement Backyard",
True,
BinarySensorDeviceClass.MOTION,
),
DemoBinarySensor(
"binary_3",
"Outside Temperature",
False,
BinarySensorDeviceClass.BATTERY_CHARGING,
device_id="sensor_1",
entity_category=EntityCategory.DIAGNOSTIC,
entity_name="Battery Charging",
"binary_2", "Movement Backyard", True, BinarySensorDeviceClass.MOTION
),
]
)
@@ -59,9 +46,6 @@ class DemoBinarySensor(BinarySensorEntity):
device_name: str,
state: bool,
device_class: BinarySensorDeviceClass,
device_id: str | None = None,
entity_category: EntityCategory | None = None,
entity_name: str | None = None,
) -> None:
"""Initialize the demo sensor."""
self._unique_id = unique_id
@@ -70,12 +54,10 @@ class DemoBinarySensor(BinarySensorEntity):
self._attr_device_info = DeviceInfo(
identifiers={
# Serial numbers are unique identifiers within a specific domain
(DOMAIN, device_id or unique_id)
(DOMAIN, self.unique_id)
},
name=device_name,
)
self._attr_entity_category = entity_category
self._attr_name = entity_name
@property
def unique_id(self) -> str:
@@ -54,6 +54,10 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
config_entry.minor_version,
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
if config_entry.minor_version < 2:
new_options = {**config_entry.options}
+6 -2
View File
@@ -51,7 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo
tcp_port=entry.options[CONF_PORT],
udp_port=entry.options[CONF_PORT],
)
queries.append(resolver_ipv4.query_dns(hostname, "A"))
queries.append(resolver_ipv4.query(hostname, "A"))
if entry.data[CONF_IPV6]:
resolver_ipv6 = aiodns.DNSResolver(
@@ -59,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo
tcp_port=entry.options[CONF_PORT_IPV6],
udp_port=entry.options[CONF_PORT_IPV6],
)
queries.append(resolver_ipv6.query_dns(hostname, "AAAA"))
queries.append(resolver_ipv6.query(hostname, "AAAA"))
async def _close_resolvers() -> None:
if resolver_ipv4 is not None:
@@ -111,6 +111,10 @@ async def async_migrate_entry(
) -> bool:
"""Migrate old entry to a newer version."""
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version < 2 and config_entry.minor_version < 2:
_LOGGER.debug(
"Migrating configuration from version %s.%s",
@@ -72,7 +72,7 @@ async def async_validate_hostname(
_resolver = aiodns.DNSResolver(
nameservers=[resolver], udp_port=port, tcp_port=port
)
result = bool(await _resolver.query_dns(hostname, qtype))
result = bool(await _resolver.query(hostname, qtype))
return result
+4 -15
View File
@@ -8,7 +8,6 @@ from typing import TYPE_CHECKING, Literal
import aiodns
from aiodns.error import DNSError
import pycares
from homeassistant.components.sensor import SensorEntity
from homeassistant.const import CONF_NAME, CONF_PORT
@@ -149,7 +148,7 @@ class WanIpSensor(SensorEntity):
response = None
try:
async with asyncio.timeout(10):
response = await self._resolver.query_dns(self.hostname, self.querytype)
response = await self._resolver.query(self.hostname, self.querytype)
except TimeoutError as err:
_LOGGER.debug("Timeout while resolving host: %s", err)
await self._resolver.close()
@@ -158,19 +157,9 @@ class WanIpSensor(SensorEntity):
await self._resolver.close()
if response:
if TYPE_CHECKING:
assert all(
isinstance(res.data, (pycares.ARecordData, pycares.AAAARecordData))
for res in response.answer
)
_ips = []
for res in response.answer:
if TYPE_CHECKING:
assert isinstance(
res.data, (pycares.ARecordData, pycares.AAAARecordData)
)
_ips.append(res.data.addr)
sorted_ips = sort_ips(_ips, querytype=self.querytype)
sorted_ips = sort_ips(
[res.host for res in response], querytype=self.querytype
)
self._attr_native_value = sorted_ips[0]
self._attr_extra_state_attributes["ip_addresses"] = sorted_ips
self._attr_available = True
-1
View File
@@ -7,4 +7,3 @@ from homeassistant.const import Platform
DOMAIN = "duco"
PLATFORMS = [Platform.FAN, Platform.SENSOR]
SCAN_INTERVAL = timedelta(seconds=10)
BOX_NODE_ID = 1
+1 -7
View File
@@ -3,7 +3,7 @@
from dataclasses import asdict
from typing import Any
from duco_connectivity.exceptions import DucoConnectionError, DucoError
from duco_connectivity.exceptions import DucoConnectionError
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
@@ -52,12 +52,6 @@ async def async_get_config_entry_diagnostics(
translation_domain=DOMAIN,
translation_key="connection_error",
) from err
except DucoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": repr(err)},
) from err
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
if api_info_obj.reported_api_version is not None:
+3 -3
View File
@@ -1,6 +1,6 @@
"""Base entity for the Duco integration."""
from duco_connectivity.models import Node, NodeType
from duco_connectivity.models import Node
from homeassistant.const import ATTR_VIA_DEVICE
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@@ -25,7 +25,7 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]):
identifiers={(DOMAIN, f"{mac}_{node.node_id}")},
manufacturer="Duco",
model=coordinator.board_info.box_name
if node.general.node_type == NodeType.BOX
if node.general.node_type == "BOX"
else node.general.node_type,
name=node.general.name or f"Node {node.node_id}",
)
@@ -34,7 +34,7 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]):
"connections": {(CONNECTION_NETWORK_MAC, mac)},
"serial_number": coordinator.board_info.serial_board_box,
}
if node.general.node_type == NodeType.BOX
if node.general.node_type == "BOX"
else {ATTR_VIA_DEVICE: (DOMAIN, f"{mac}_1")}
)
self._attr_device_info = device_info
+2 -8
View File
@@ -24,7 +24,7 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import BOX_NODE_ID, DOMAIN
from .const import DOMAIN
from .coordinator import DucoConfigEntry, DucoCoordinator
from .entity import DucoEntity
@@ -158,13 +158,7 @@ async def async_setup_entry(
# The firmware removes deregistered RF/wired nodes automatically.
# BSRH box sensors that are physically unplugged from the PCB are
# not deregistered by the firmware and will never appear here as stale.
# The BOX node can transiently disappear from the API response, so keep
# node 1 to avoid removing the main controller device.
stale_node_ids = {
node_id
for node_id in known_nodes - coordinator.data.nodes.keys()
if node_id != BOX_NODE_ID
}
stale_node_ids = known_nodes - coordinator.data.nodes.keys()
if stale_node_ids:
device_reg = dr.async_get(hass)
mac = entry.unique_id
@@ -10,7 +10,7 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyecobee"],
"requirements": ["python-ecobee-api==0.4.1"],
"requirements": ["python-ecobee-api==0.4.0"],
"single_config_entry": true,
"zeroconf": [
{
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["sml"],
"requirements": ["pysml==0.1.8"]
"requirements": ["pysml==0.1.7"]
}
@@ -98,6 +98,10 @@ async def async_migrate_entry(
config_entry.minor_version,
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
new_options = {**config_entry.options}
@@ -26,7 +26,6 @@ from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_time_interval,
)
from homeassistant.util import dt as dt_util
from .const import (
CONF_DEVICE_NAME,
@@ -222,7 +221,8 @@ def update_listeners(hass: HomeAssistant, entry: EnergyIDConfigEntry) -> None:
):
try:
value = float(current_state.state)
timestamp = current_state.last_updated or dt_util.utcnow()
# pylint: disable-next=home-assistant-enforce-utcnow
timestamp = current_state.last_updated or dt.datetime.now(dt.UTC)
client.get_or_create_sensor(energyid_key).update(value, timestamp)
except ValueError, TypeError:
_LOGGER.debug(
@@ -8,25 +8,9 @@ from env_canada import ECAirQuality, ECMap, ECWeather
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_RADAR_LAYER,
CONF_RADAR_LEGEND,
CONF_RADAR_OPACITY,
CONF_RADAR_RADIUS,
CONF_RADAR_TIMESTAMP,
CONF_STATION,
DEFAULT_RADAR_LAYER,
DEFAULT_RADAR_LEGEND,
DEFAULT_RADAR_OPACITY,
DEFAULT_RADAR_RADIUS,
DEFAULT_RADAR_TIMESTAMP,
DOMAIN,
)
from .const import CONF_STATION
from .coordinator import ECConfigEntry, ECDataUpdateCoordinator, ECRuntimeData
from .services import async_setup_services
DEFAULT_RADAR_UPDATE_INTERVAL = timedelta(minutes=5)
DEFAULT_WEATHER_UPDATE_INTERVAL = timedelta(minutes=5)
@@ -35,14 +19,6 @@ PLATFORMS = [Platform.CAMERA, Platform.SENSOR, Platform.WEATHER]
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Environment Canada services."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) -> bool:
"""Set up EC as config entry."""
@@ -67,15 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
errors = errors + 1
_LOGGER.warning("Unable to retrieve Environment Canada weather")
options = config_entry.options
radar_data = ECMap(
coordinates=(lat, lon),
layer=options.get(CONF_RADAR_LAYER, DEFAULT_RADAR_LAYER),
legend=options.get(CONF_RADAR_LEGEND, DEFAULT_RADAR_LEGEND),
timestamp=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP),
layer_opacity=int(options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY)),
radius=int(options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS)),
)
radar_data = ECMap(coordinates=(lat, lon), layer="precip_type", legend=False)
radar_coordinator = ECDataUpdateCoordinator(
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
)
@@ -9,42 +9,17 @@ from env_canada import ECWeather, ec_exc
from env_canada.ec_weather import get_ec_sites_list
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
BooleanSelector,
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import (
CONF_RADAR_LAYER,
CONF_RADAR_LEGEND,
CONF_RADAR_OPACITY,
CONF_RADAR_RADIUS,
CONF_RADAR_TIMESTAMP,
CONF_STATION,
CONF_TITLE,
DEFAULT_RADAR_LAYER,
DEFAULT_RADAR_LEGEND,
DEFAULT_RADAR_OPACITY,
DEFAULT_RADAR_RADIUS,
DEFAULT_RADAR_TIMESTAMP,
DOMAIN,
RADAR_LAYERS,
)
from .const import CONF_STATION, CONF_TITLE, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -82,14 +57,6 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
_station_codes: list[dict[str, str]] | None = None
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> OptionsFlowHandler:
"""Return the options flow handler."""
return OptionsFlowHandler()
async def _get_station_codes(self) -> list[dict[str, str]]:
"""Get station codes, cached after first call."""
if self._station_codes is None:
@@ -160,55 +127,3 @@ class EnvironmentCanadaConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=data_schema, errors=errors
)
class OptionsFlowHandler(OptionsFlowWithReload):
"""Handle Environment Canada radar camera options."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the radar camera options."""
if user_input is not None:
return self.async_create_entry(data=user_input)
options = self.config_entry.options
data_schema = vol.Schema(
{
vol.Required(
CONF_RADAR_LAYER,
default=options.get(CONF_RADAR_LAYER, DEFAULT_RADAR_LAYER),
): SelectSelector(
SelectSelectorConfig(
options=RADAR_LAYERS,
translation_key="radar_layer",
)
),
vol.Required(
CONF_RADAR_LEGEND,
default=options.get(CONF_RADAR_LEGEND, DEFAULT_RADAR_LEGEND),
): BooleanSelector(),
vol.Required(
CONF_RADAR_TIMESTAMP,
default=options.get(CONF_RADAR_TIMESTAMP, DEFAULT_RADAR_TIMESTAMP),
): BooleanSelector(),
vol.Required(
CONF_RADAR_OPACITY,
default=options.get(CONF_RADAR_OPACITY, DEFAULT_RADAR_OPACITY),
): NumberSelector(
NumberSelectorConfig(
min=0, max=100, step=1, mode=NumberSelectorMode.SLIDER
)
),
vol.Required(
CONF_RADAR_RADIUS,
default=options.get(CONF_RADAR_RADIUS, DEFAULT_RADAR_RADIUS),
): NumberSelector(
NumberSelectorConfig(
min=10, max=2000, step=10, unit_of_measurement="km"
)
),
}
)
return self.async_show_form(step_id="init", data_schema=data_schema)
@@ -6,19 +6,3 @@ CONF_STATION = "station"
CONF_TITLE = "title"
DOMAIN = "environment_canada"
SERVICE_ENVIRONMENT_CANADA_FORECASTS = "get_forecasts"
CONF_RADAR_LAYER = "radar_layer"
CONF_RADAR_LEGEND = "radar_legend"
CONF_RADAR_TIMESTAMP = "radar_timestamp"
CONF_RADAR_OPACITY = "radar_opacity"
CONF_RADAR_RADIUS = "radar_radius"
RADAR_LAYERS = ["rain", "snow", "precip_type"]
# Defaults preserve the radar behaviour from before the options flow existed:
# the precipitation-type layer with the legend hidden.
DEFAULT_RADAR_LAYER = "precip_type"
DEFAULT_RADAR_LEGEND = False
DEFAULT_RADAR_TIMESTAMP = True
DEFAULT_RADAR_OPACITY = 65
DEFAULT_RADAR_RADIUS = 200
@@ -19,9 +19,6 @@
}
},
"services": {
"get_alerts": {
"service": "mdi:bell-alert"
},
"get_forecasts": {
"service": "mdi:weather-cloudy-clock"
},
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["env_canada"],
"requirements": ["env-canada==0.15.0"]
"requirements": ["env-canada==0.13.2"]
}
@@ -1,56 +0,0 @@
"""Define services for the Environment Canada integration."""
from typing import Any
from env_canada import ECWeather
import voluptuous as vol
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
SERVICE_GET_ALERTS = "get_alerts"
SERVICE_GET_ALERTS_SCHEMA = vol.Schema({vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string})
SNAKE_MAPPING = {
"alertColourLevel": "alert_colour_level",
"expiryTime": "expiry_time",
}
async def _async_get_alerts(call: ServiceCall) -> dict[str, Any]:
"""Return the active alerts."""
entry = service.async_get_config_entry(
call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID]
)
ec: ECWeather | None = entry.runtime_data.weather_coordinator.ec_data
if ec is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="not_connected",
)
data: dict[str, Any] = ec.alerts
return {
k: [
{SNAKE_MAPPING.get(ik, ik): iv for ik, iv in item.items()}
for item in v["value"]
]
for k, v in data.items()
}
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Environment Canada integration."""
hass.services.async_register(
DOMAIN,
SERVICE_GET_ALERTS,
_async_get_alerts,
schema=SERVICE_GET_ALERTS_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
@@ -1,11 +1,3 @@
get_alerts:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: environment_canada
get_forecasts:
target:
entity:
@@ -112,49 +112,7 @@
}
}
},
"exceptions": {
"not_connected": {
"message": "Environment Canada is not connected"
}
},
"options": {
"step": {
"init": {
"data": {
"radar_layer": "Radar type",
"radar_legend": "Show legend",
"radar_opacity": "Radar opacity",
"radar_radius": "Map radius",
"radar_timestamp": "Show timestamp"
},
"data_description": {
"radar_opacity": "Opacity of the radar layer overlay (0-100)",
"radar_radius": "Radius of the radar map in kilometres"
},
"title": "Radar camera options"
}
}
},
"selector": {
"radar_layer": {
"options": {
"precip_type": "Precipitation type",
"rain": "Rain",
"snow": "Snow"
}
}
},
"services": {
"get_alerts": {
"description": "Retrieves the alerts from the selected weather service.",
"fields": {
"config_entry_id": {
"description": "The Environment Canada service to retrieve alerts from.",
"name": "Environment Canada service"
}
},
"name": "Get alerts"
},
"get_forecasts": {
"description": "Retrieves the forecast from selected weather services.",
"name": "Get forecasts"
@@ -88,6 +88,10 @@ async def async_migrate_entry(
config_entry.minor_version,
)
if config_entry.version > 1 or config_entry.minor_version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1 and config_entry.minor_version == 1:
new_data = {**config_entry.data}
new_data[CONF_CONNECTION_TYPE] = HTTP
@@ -27,7 +27,6 @@ from epson_projector.const import (
)
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
@@ -63,7 +62,6 @@ class EpsonProjectorMediaPlayer(MediaPlayerEntity):
_attr_has_entity_name = True
_attr_name = None
_attr_device_class = MediaPlayerDeviceClass.PROJECTOR
_attr_supported_features = (
MediaPlayerEntityFeature.TURN_ON
@@ -166,8 +166,6 @@ class RuntimeEntryData:
)
loaded_platforms: set[Platform] = field(default_factory=set)
platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
# Set once the first connection has finished scanner setup or teardown.
first_connect_done: asyncio.Event = field(default_factory=asyncio.Event)
_storage_contents: StoreData | None = None
_pending_storage: Callable[[], StoreData] | None = None
assist_pipeline_update_callbacks: list[CALLBACK_TYPE] = field(default_factory=list)
+1 -22
View File
@@ -1,12 +1,11 @@
"""Manager for esphome devices."""
import asyncio
import base64
from functools import partial
import logging
import secrets
import struct
from typing import TYPE_CHECKING, Any, Final, NamedTuple
from typing import TYPE_CHECKING, Any, NamedTuple
from aioesphomeapi import (
APIClient,
@@ -107,9 +106,6 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
# Max time to wait at startup for a BLE proxy to register its scanner.
STARTUP_SCANNER_WAIT: Final = 3.0
LOG_LEVEL_TO_LOGGER = {
LogLevel.LOG_LEVEL_NONE: logging.DEBUG,
LogLevel.LOG_LEVEL_ERROR: logging.ERROR,
@@ -681,8 +677,6 @@ class ESPHomeManager:
hass, device_info.bluetooth_mac_address or device_info.mac_address
)
entry_data.first_connect_done.set()
if device_info.voice_assistant_feature_flags_compat(api_version) and (
Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms
):
@@ -994,21 +988,6 @@ class ESPHomeManager:
await reconnect_logic.start()
# Wait for a cached BLE proxy to register its scanner before finishing setup.
if (
device_info := entry_data.device_info
) is not None and device_info.bluetooth_proxy_feature_flags_compat(
entry_data.api_version
):
try:
async with asyncio.timeout(STARTUP_SCANNER_WAIT):
await entry_data.first_connect_done.wait()
except TimeoutError:
_LOGGER.debug(
"%s: Timed out waiting for Bluetooth scanner to register",
self.host,
)
@callback
def _async_setup_device_registry(
@@ -53,6 +53,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate config entry."""
if config_entry.version > 2:
# Downgraded from future
return False
if config_entry.version < 2:
# Move optional fields from data to options in config entry
-2
View File
@@ -8,8 +8,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FlussApiClientError, FlussConfigEntry
from .entity import FlussEntity
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
+1 -1
View File
@@ -15,7 +15,7 @@ from .const import DOMAIN
from .coordinator import FlussApiClientError, FlussConfigEntry
from .entity import FlussEntity
PARALLEL_UPDATES = 1
PARALLEL_UPDATES = 0
STATUS_OPEN = "Open"
STATUS_CLOSED = "Closed"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"domain": "fluss",
"name": "Fluss+",
"codeowners": ["@fluss", "@Marcello17"],
"codeowners": ["@fluss"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fluss",
"iot_class": "cloud_polling",
@@ -28,7 +28,7 @@ rules:
docs-installation-parameters: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260527.5"]
"requirements": ["home-assistant-frontend==20260527.4"]
}
@@ -48,6 +48,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: FGLairConfigEntry) -> b
async def async_migrate_entry(hass: HomeAssistant, entry: FGLairConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version > 1:
return False
if entry.version == 1:
new_data = {**entry.data}
@@ -65,6 +65,10 @@ async def async_migrate_entry(
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
if config_entry.minor_version < 2:
new = {**config_entry.data}
@@ -14,7 +14,6 @@ from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONF_PRODUCT_TYPE
from .coordinator import (
DeviceUnavailable,
GardenaBluetoothConfigEntry,
@@ -37,8 +36,8 @@ PRODUCTS_SCAN_TIMEOUT = 10
PRODUCT_TYPE_TIMEOUT = 30
async def async_get_product(hass: HomeAssistant, address: str) -> ManufacturerData:
"""Get manufacturer data for the given address via active scan."""
async def async_get_product_type(hass: HomeAssistant, address: str) -> ProductType:
"""Get a product type for the given address."""
data = ManufacturerData()
@@ -60,7 +59,7 @@ async def async_get_product(hass: HomeAssistant, address: str) -> ManufacturerDa
mode=bluetooth.BluetoothScanningMode.ACTIVE,
timeout=PRODUCT_TYPE_TIMEOUT,
)
return data
return data.product_type
async def async_get_products(hass: HomeAssistant) -> dict[str, ManufacturerData]:
@@ -93,21 +92,6 @@ async def async_get_products(hass: HomeAssistant) -> dict[str, ManufacturerData]
return products
async def async_migrate_product_type(
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
) -> GardenaBluetoothConfigEntry:
"""Discover product type for old entries and upgrade them to minor version 2."""
mfg = await async_get_product(hass, entry.data[CONF_ADDRESS])
if mfg.product_type is ProductType.UNKNOWN:
raise ConfigEntryNotReady("Unable to find product type")
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_PRODUCT_TYPE: mfg.product_type.name},
minor_version=2,
)
return entry
def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
"""Set up a cached client that keeps connection after last use."""
@@ -127,11 +111,11 @@ async def async_setup_entry(
) -> bool:
"""Set up Gardena Bluetooth from a config entry."""
if entry.minor_version < 2:
entry = await async_migrate_product_type(hass, entry)
address = entry.data[CONF_ADDRESS]
product_type = ProductType[entry.data[CONF_PRODUCT_TYPE]]
product_type = await async_get_product_type(hass, address)
if product_type is ProductType.UNKNOWN:
raise ConfigEntryNotReady("Unable to find product type")
client = Client(get_connection(hass, address), product_type)
@@ -6,7 +6,7 @@ from typing import Any
from gardena_bluetooth.client import Client
from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation
from gardena_bluetooth.exceptions import CharacteristicNotFound, CommunicationFailure
from gardena_bluetooth.parse import ManufacturerData, ProductType
from gardena_bluetooth.parse import ProductType
import voluptuous as vol
from homeassistant.components.bluetooth import BluetoothServiceInfo
@@ -14,8 +14,8 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS
from homeassistant.data_entry_flow import AbortFlow
from . import async_get_product, async_get_products, get_connection
from .const import CONF_PRODUCT_TYPE, DOMAIN
from . import async_get_product_type, async_get_products, get_connection
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -33,12 +33,11 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Gardena Bluetooth."""
VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize the config flow."""
self.devices: dict[str, str] = {}
self.address: str | None
self.devices: dict[str, ManufacturerData] = {}
async def async_read_data(self):
"""Try to connect to device and extract information."""
@@ -54,23 +53,19 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
finally:
await client.disconnect()
assert self.address in self.devices
return {
CONF_ADDRESS: self.address,
CONF_PRODUCT_TYPE: self.devices[self.address].product_type.name,
}
return {CONF_ADDRESS: self.address}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfo
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
_LOGGER.debug("Discovered device: %s", discovery_info)
mfg = await async_get_product(self.hass, discovery_info.address)
self.devices[discovery_info.address] = mfg
if mfg.product_type not in _SUPPORTED_PRODUCT_TYPES:
product_type = await async_get_product_type(self.hass, discovery_info.address)
if product_type not in _SUPPORTED_PRODUCT_TYPES:
return self.async_abort(reason="no_devices_found")
self.address = discovery_info.address
self.devices = {discovery_info.address: PRODUCT_NAMES[product_type]}
await self.async_set_unique_id(self.address)
self._abort_if_unique_id_configured()
return await self.async_step_confirm()
@@ -80,7 +75,7 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Confirm discovery."""
assert self.address
title = PRODUCT_NAMES[self.devices[self.address].product_type]
title = self.devices[self.address]
if user_input is not None:
data = await self.async_read_data()
@@ -107,24 +102,24 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_confirm()
current = self._async_current_ids(include_ignore=False)
self.devices = await async_get_products(self.hass)
devices = await async_get_products(self.hass)
# Keep selection sorted by address to ensure stable tests
devices = {
self.devices = {
address: PRODUCT_NAMES[data.product_type]
for address in sorted(self.devices)
for address in sorted(devices)
if address not in current
and (data := self.devices[address]).product_type in _SUPPORTED_PRODUCT_TYPES
and (data := devices[address]).product_type in _SUPPORTED_PRODUCT_TYPES
}
if not devices:
if not self.devices:
return self.async_abort(reason="no_devices_found")
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(devices),
vol.Required(CONF_ADDRESS): vol.In(self.devices),
},
),
)
@@ -1,4 +1,3 @@
"""Constants for the Gardena Bluetooth integration."""
DOMAIN = "gardena_bluetooth"
CONF_PRODUCT_TYPE = "product_type"
@@ -2,7 +2,7 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from gardena_bluetooth.const import (
AquaContourBattery,
@@ -279,7 +279,7 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity):
super()._handle_coordinator_update()
return
time = dt_util.utcnow() + timedelta(seconds=value)
time = datetime.now(UTC) + timedelta(seconds=value) # pylint: disable=home-assistant-enforce-utcnow
if not self._attr_native_value:
self._attr_native_value = time
super()._handle_coordinator_update()
@@ -10,7 +10,7 @@
},
"step": {
"confirm": {
"description": "Do you want to set up {name}?\n\nBefore you continue, make sure the device is in pairing mode."
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
},
"user": {
"data": {
@@ -59,6 +59,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version > 2:
# This means the user has downgraded from a future version
return False
if entry.version == 1:
# Migrate to advanced section
new_options = {**entry.options}
@@ -148,6 +148,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
"Migrating from version %s.%s", config_entry.version, config_entry.minor_version
)
if config_entry.version > 1:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
options = {**config_entry.options}
if config_entry.minor_version < 2:

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