Compare commits

..

25 Commits

Author SHA1 Message Date
Bram Kragten
15cb48badb Bump version to 2025.8.0b1 2025-07-31 19:01:26 +02:00
Erik Montnemery
22214e8d31 Fix kitchen_sink option flow (#149760) 2025-07-31 19:01:02 +02:00
Bram Kragten
fc04e0b2cc Update frontend to 20250731.0 (#149757) 2025-07-31 19:01:01 +02:00
Petro31
3fc6ebdb43 Fix unique_id in config validation for legacy weather platform (#149742) 2025-07-31 19:01:00 +02:00
Petro31
3ccb7deb3c Nitpick default translations for template integration (#149740) 2025-07-31 19:00:59 +02:00
Erik Montnemery
f5f63b914a Make _EventDeviceRegistryUpdatedData_Remove JSON serializable (#149734) 2025-07-31 19:00:58 +02:00
J. Nick Koston
bd0a3f5a5d Bump aioesphomeapi to 37.2.0 (#149732) 2025-07-31 19:00:58 +02:00
J. Nick Koston
ab9eebd092 Bump aioesphomeapi to 37.1.6 (#149715) 2025-07-31 19:00:57 +02:00
J. Nick Koston
68c43099d9 Fix ESPHome unnecessary probing on DHCP discovery (#149713) 2025-07-31 19:00:56 +02:00
Åke Strandberg
041c417164 Fix bug when interpreting miele action response (#149710) 2025-07-31 19:00:55 +02:00
Andrea Turri
537d09c697 Fix Miele induction hob empty state (#149706) 2025-07-31 19:00:54 +02:00
Roman Sivriver
21e3b8da92 Fix typo in backup log message (#149705) 2025-07-31 19:00:53 +02:00
Jan Bouwhuis
d390681360 Fix inconsistent use of the term 'target' and a typo in MQTT translation strings (#149703) 2025-07-31 19:00:53 +02:00
Åke Strandberg
918ec78348 Add missing translations for miele dishwasher (#149702) 2025-07-31 19:00:52 +02:00
starkillerOG
1deae3ee1a Bump reolink-aio to 0.14.5 (#149700) 2025-07-31 19:00:51 +02:00
Petro31
59eace67df Add translations for all fields in template integration (#149692)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2025-07-31 19:00:50 +02:00
Åke Strandberg
7eb7c66e3f Explicitly pass config_entry to miele coordinator (#149691) 2025-07-31 19:00:49 +02:00
Copilot
aa2941592d Fix ContextVar deprecation warning in homeassistant_hardware integration (#149687)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: joostlek <7083755+joostlek@users.noreply.github.com>
Co-authored-by: mib1185 <35783820+mib1185@users.noreply.github.com>
2025-07-31 19:00:48 +02:00
Manu
29daf136d2 Fix KeyError in friends coordinator (#149684) 2025-07-31 19:00:47 +02:00
puddly
3da3cf7f52 Bump ZHA to 0.0.64 (#149683)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
Co-authored-by: abmantis <amfcalt@gmail.com>
2025-07-31 19:00:47 +02:00
Michael Hansen
d8c93d54d5 Bump intents to 2025.7.30 (#149678) 2025-07-31 19:00:46 +02:00
Jan Bouwhuis
0799ee9fba Fix translation string reference for MQTT climate subentry option (#149673) 2025-07-31 19:00:45 +02:00
Bram Kragten
9d31403984 2025.8.0b0 (#149675) 2025-07-30 17:13:15 +02:00
Bram Kragten
02f87cba9b Merge branch 'dev' into dev-rc 2025-07-30 17:07:48 +02:00
Bram Kragten
5b54784378 Bump version to 2025.8.0b0 2025-07-30 16:56:55 +02:00
1361 changed files with 15430 additions and 114039 deletions

View File

@@ -27,7 +27,7 @@ jobs:
publish: ${{ steps.version.outputs.publish }}
steps:
- name: Checkout the repository
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
@@ -90,7 +90,7 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
@@ -175,7 +175,7 @@ jobs:
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations
uses: actions/download-artifact@v5.0.0
uses: actions/download-artifact@v4.3.0
with:
name: translations
@@ -190,7 +190,7 @@ jobs:
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.5.0
uses: docker/login-action@v3.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -242,7 +242,7 @@ jobs:
- green
steps:
- name: Checkout the repository
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set build additional args
run: |
@@ -256,7 +256,7 @@ jobs:
fi
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.5.0
uses: docker/login-action@v3.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -279,7 +279,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Initialize git
uses: home-assistant/actions/helpers/git-init@master
@@ -321,7 +321,7 @@ jobs:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps:
- name: Checkout the repository
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Install Cosign
uses: sigstore/cosign-installer@v3.9.2
@@ -330,14 +330,14 @@ jobs:
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@v3.5.0
uses: docker/login-action@v3.4.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: matrix.registry == 'ghcr.io/home-assistant'
uses: docker/login-action@v3.5.0
uses: docker/login-action@v3.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -454,7 +454,7 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.6.0
@@ -462,7 +462,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
uses: actions/download-artifact@v5.0.0
uses: actions/download-artifact@v4.3.0
with:
name: translations
@@ -499,10 +499,10 @@ jobs:
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Login to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}

View File

@@ -37,10 +37,10 @@ on:
type: boolean
env:
CACHE_VERSION: 5
CACHE_VERSION: 4
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.9"
HA_SHORT_VERSION: "2025.8"
DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13']"
# 10.3 is the oldest supported version
@@ -94,7 +94,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Generate partial Python venv restore key
id: generate_python_cache_key
run: |
@@ -246,7 +246,7 @@ jobs:
- info
steps:
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.6.0
@@ -255,7 +255,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache@v4.2.4
uses: actions/cache@v4.2.3
with:
path: venv
key: >-
@@ -271,7 +271,7 @@ jobs:
uv pip install "$(cat requirements_test.txt | grep pre-commit)"
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v4.2.4
uses: actions/cache@v4.2.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true
@@ -292,7 +292,7 @@ jobs:
- pre-commit
steps:
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.6.0
id: python
@@ -301,7 +301,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.4
uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -310,7 +310,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache/restore@v4.2.4
uses: actions/cache/restore@v4.2.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -332,7 +332,7 @@ jobs:
- pre-commit
steps:
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.6.0
id: python
@@ -341,7 +341,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.4
uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -350,7 +350,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache/restore@v4.2.4
uses: actions/cache/restore@v4.2.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -372,7 +372,7 @@ jobs:
- pre-commit
steps:
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.6.0
id: python
@@ -381,7 +381,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.4
uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -390,7 +390,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache/restore@v4.2.4
uses: actions/cache/restore@v4.2.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -462,7 +462,7 @@ jobs:
- script/hassfest/docker/Dockerfile
steps:
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Register hadolint problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/hadolint.json"
@@ -481,7 +481,7 @@ jobs:
python-version: ${{ fromJSON(needs.info.outputs.python_versions) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.6.0
@@ -497,7 +497,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache@v4.2.4
uses: actions/cache@v4.2.3
with:
path: venv
key: >-
@@ -505,7 +505,7 @@ jobs:
needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: actions/cache@v4.2.4
uses: actions/cache@v4.2.3
with:
path: ${{ env.UV_CACHE_DIR }}
key: >-
@@ -584,7 +584,7 @@ jobs:
sudo apt-get -y install \
libturbojpeg
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.6.0
@@ -593,7 +593,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.4
uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -617,7 +617,7 @@ jobs:
- base
steps:
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.6.0
@@ -626,7 +626,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.4
uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -651,7 +651,7 @@ jobs:
&& github.event_name == 'pull_request'
steps:
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Dependency review
uses: actions/dependency-review-action@v4.7.1
with:
@@ -674,7 +674,7 @@ jobs:
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.6.0
@@ -683,7 +683,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.4
uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -717,7 +717,7 @@ jobs:
- base
steps:
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.6.0
@@ -726,7 +726,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.4
uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -764,7 +764,7 @@ jobs:
- base
steps:
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.6.0
@@ -773,7 +773,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.4
uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -809,7 +809,7 @@ jobs:
- base
steps:
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.6.0
@@ -825,7 +825,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.4
uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -833,7 +833,7 @@ jobs:
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore mypy cache
uses: actions/cache@v4.2.4
uses: actions/cache@v4.2.3
with:
path: .mypy_cache
key: >-
@@ -886,7 +886,7 @@ jobs:
libturbojpeg \
libgammu-dev
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.6.0
@@ -895,7 +895,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.4
uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -947,7 +947,7 @@ jobs:
libgammu-dev \
libxml2-utils
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.6.0
@@ -956,7 +956,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.4
uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -970,7 +970,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
uses: actions/download-artifact@v5.0.0
uses: actions/download-artifact@v4.3.0
with:
name: pytest_buckets
- name: Compile English translations
@@ -1080,7 +1080,7 @@ jobs:
libmariadb-dev-compat \
libxml2-utils
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.6.0
@@ -1089,7 +1089,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.4
uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -1222,7 +1222,7 @@ jobs:
sudo apt-get -y install \
postgresql-server-dev-14
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.6.0
@@ -1231,7 +1231,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.4
uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -1334,9 +1334,9 @@ jobs:
timeout-minutes: 10
steps:
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Download all coverage artifacts
uses: actions/download-artifact@v5.0.0
uses: actions/download-artifact@v4.3.0
with:
pattern: coverage-*
- name: Upload coverage to Codecov
@@ -1381,7 +1381,7 @@ jobs:
libgammu-dev \
libxml2-utils
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
uses: actions/setup-python@v5.6.0
@@ -1390,7 +1390,7 @@ jobs:
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
uses: actions/cache/restore@v4.2.4
uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -1484,9 +1484,9 @@ jobs:
timeout-minutes: 10
steps:
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Download all coverage artifacts
uses: actions/download-artifact@v5.0.0
uses: actions/download-artifact@v4.3.0
with:
pattern: coverage-*
- name: Upload coverage to Codecov
@@ -1511,7 +1511,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Download all coverage artifacts
uses: actions/download-artifact@v5.0.0
uses: actions/download-artifact@v4.3.0
with:
pattern: test-results-*
- name: Upload test results to Codecov

View File

@@ -21,14 +21,14 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.29.9
uses: github/codeql-action/init@v3.29.5
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.29.9
uses: github/codeql-action/analyze@v3.29.5
with:
category: "/language:python"

View File

@@ -231,7 +231,7 @@ jobs:
- name: Detect duplicates using AI
id: ai_detection
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
uses: actions/ai-inference@v2.0.0
uses: actions/ai-inference@v1.2.3
with:
model: openai/gpt-4o
system-prompt: |

View File

@@ -57,7 +57,7 @@ jobs:
- name: Detect language using AI
id: ai_language_detection
if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/ai-inference@v2.0.0
uses: actions/ai-inference@v1.2.3
with:
model: openai/gpt-4o-mini
system-prompt: |

View File

@@ -9,7 +9,7 @@ jobs:
check-authorization:
runs-on: ubuntu-latest
# Only run if this is a Task issue type (from the issue form)
if: github.event.issue.type.name == 'Task'
if: github.event.issue.issue_type == 'Task'
steps:
- name: Check if user is authorized
uses: actions/github-script@v7

View File

@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.6.0

View File

@@ -32,7 +32,7 @@ jobs:
architectures: ${{ steps.info.outputs.architectures }}
steps:
- name: Checkout the repository
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
@@ -135,20 +135,20 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Download env_file
uses: actions/download-artifact@v5.0.0
uses: actions/download-artifact@v4.3.0
with:
name: env_file
- name: Download build_constraints
uses: actions/download-artifact@v5.0.0
uses: actions/download-artifact@v4.3.0
with:
name: build_constraints
- name: Download requirements_diff
uses: actions/download-artifact@v5.0.0
uses: actions/download-artifact@v4.3.0
with:
name: requirements_diff
@@ -159,7 +159,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@2025.07.0
uses: home-assistant/wheels@2025.03.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -184,25 +184,25 @@ jobs:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps:
- name: Checkout the repository
uses: actions/checkout@v5.0.0
uses: actions/checkout@v4.2.2
- name: Download env_file
uses: actions/download-artifact@v5.0.0
uses: actions/download-artifact@v4.3.0
with:
name: env_file
- name: Download build_constraints
uses: actions/download-artifact@v5.0.0
uses: actions/download-artifact@v4.3.0
with:
name: build_constraints
- name: Download requirements_diff
uses: actions/download-artifact@v5.0.0
uses: actions/download-artifact@v4.3.0
with:
name: requirements_diff
- name: Download requirements_all_wheels
uses: actions/download-artifact@v5.0.0
uses: actions/download-artifact@v4.3.0
with:
name: requirements_all_wheels
@@ -219,7 +219,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: home-assistant/wheels@2025.07.0
uses: home-assistant/wheels@2025.03.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2

View File

@@ -18,7 +18,7 @@ repos:
exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
rev: v5.0.0
hooks:
- id: check-executables-have-shebangs
stages: [manual]

View File

@@ -310,6 +310,7 @@ homeassistant.components.letpot.*
homeassistant.components.lidarr.*
homeassistant.components.lifx.*
homeassistant.components.light.*
homeassistant.components.linear_garage_door.*
homeassistant.components.linkplay.*
homeassistant.components.litejet.*
homeassistant.components.litterrobot.*
@@ -466,7 +467,6 @@ homeassistant.components.simplisafe.*
homeassistant.components.siren.*
homeassistant.components.skybell.*
homeassistant.components.slack.*
homeassistant.components.sleep_as_android.*
homeassistant.components.sleepiq.*
homeassistant.components.smhi.*
homeassistant.components.smlight.*

16
CODEOWNERS generated
View File

@@ -156,8 +156,8 @@ build.json @home-assistant/supervisor
/tests/components/assist_pipeline/ @balloob @synesthesiam
/homeassistant/components/assist_satellite/ @home-assistant/core @synesthesiam
/tests/components/assist_satellite/ @home-assistant/core @synesthesiam
/homeassistant/components/asuswrt/ @kennedyshead @ollo69 @Vaskivskyi
/tests/components/asuswrt/ @kennedyshead @ollo69 @Vaskivskyi
/homeassistant/components/asuswrt/ @kennedyshead @ollo69
/tests/components/asuswrt/ @kennedyshead @ollo69
/homeassistant/components/atag/ @MatsNL
/tests/components/atag/ @MatsNL
/homeassistant/components/aten_pe/ @mtdcr
@@ -438,8 +438,8 @@ build.json @home-assistant/supervisor
/tests/components/enigma2/ @autinerd
/homeassistant/components/enocean/ @bdurrer
/tests/components/enocean/ @bdurrer
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @joostlek @catsmanac
/tests/components/enphase_envoy/ @bdraco @cgarwood @joostlek @catsmanac
/homeassistant/components/entur_public_transport/ @hfurubotten
/homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie
@@ -862,6 +862,8 @@ build.json @home-assistant/supervisor
/tests/components/lifx/ @Djelibeybi
/homeassistant/components/light/ @home-assistant/core
/tests/components/light/ @home-assistant/core
/homeassistant/components/linear_garage_door/ @IceBotYT
/tests/components/linear_garage_door/ @IceBotYT
/homeassistant/components/linkplay/ @Velleman
/tests/components/linkplay/ @Velleman
/homeassistant/components/linux_battery/ @fabaff
@@ -1415,8 +1417,6 @@ build.json @home-assistant/supervisor
/tests/components/skybell/ @tkdrob
/homeassistant/components/slack/ @tkdrob @fletcherau
/tests/components/slack/ @tkdrob @fletcherau
/homeassistant/components/sleep_as_android/ @tr4nt0r
/tests/components/sleep_as_android/ @tr4nt0r
/homeassistant/components/sleepiq/ @mfugate1 @kbickar
/tests/components/sleepiq/ @mfugate1 @kbickar
/homeassistant/components/slide/ @ualex73
@@ -1599,8 +1599,6 @@ build.json @home-assistant/supervisor
/tests/components/todo/ @home-assistant/core
/homeassistant/components/todoist/ @boralyl
/tests/components/todoist/ @boralyl
/homeassistant/components/togrill/ @elupus
/tests/components/togrill/ @elupus
/homeassistant/components/tolo/ @MatthiasLohr
/tests/components/tolo/ @MatthiasLohr
/homeassistant/components/tomorrowio/ @raman325 @lymanepp
@@ -1615,6 +1613,8 @@ build.json @home-assistant/supervisor
/tests/components/tplink_omada/ @MarkGodwin
/homeassistant/components/traccar/ @ludeeus
/tests/components/traccar/ @ludeeus
/homeassistant/components/traccar_server/ @ludeeus
/tests/components/traccar_server/ @ludeeus
/homeassistant/components/trace/ @home-assistant/core
/tests/components/trace/ @home-assistant/core
/homeassistant/components/tractive/ @Danielhiversen @zhulik @bieniu

2
Dockerfile generated
View File

@@ -31,7 +31,7 @@ RUN \
&& go2rtc --version
# Install uv
RUN pip3 install uv==0.8.9
RUN pip3 install uv==0.7.1
WORKDIR /usr/src

View File

@@ -120,9 +120,6 @@ class AuthStore:
new_user = models.User(**kwargs)
while new_user.id in self._users:
new_user = models.User(**kwargs)
self._users[new_user.id] = new_user
if credentials is None:

View File

@@ -33,10 +33,7 @@ class AuthFlowContext(FlowContext, total=False):
redirect_uri: str
class AuthFlowResult(FlowResult[AuthFlowContext, tuple[str, str]], total=False):
"""Typed result dict for auth flow."""
result: Credentials # Only present if type is CREATE_ENTRY
AuthFlowResult = FlowResult[AuthFlowContext, tuple[str, str]]
@attr.s(slots=True)

View File

@@ -10,10 +10,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator
_PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.SENSOR,
]
_PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:

View File

@@ -1,106 +0,0 @@
"""AirOS Binary Sensor component for Home Assistant."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirOSConfigEntry, AirOSData, AirOSDataUpdateCoordinator
from .entity import AirOSEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describe an AirOS binary sensor."""
value_fn: Callable[[AirOSData], bool]
BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (
AirOSBinarySensorEntityDescription(
key="portfw",
translation_key="port_forwarding",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.portfw,
),
AirOSBinarySensorEntityDescription(
key="dhcp_client",
translation_key="dhcp_client",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.services.dhcpc,
),
AirOSBinarySensorEntityDescription(
key="dhcp_server",
translation_key="dhcp_server",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.services.dhcpd,
entity_registry_enabled_default=False,
),
AirOSBinarySensorEntityDescription(
key="dhcp6_server",
translation_key="dhcp6_server",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.services.dhcp6d_stateful,
entity_registry_enabled_default=False,
),
AirOSBinarySensorEntityDescription(
key="pppoe",
translation_key="pppoe",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.services.pppoe,
entity_registry_enabled_default=False,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirOSConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the AirOS binary sensors from a config entry."""
coordinator = config_entry.runtime_data
async_add_entities(
AirOSBinarySensor(coordinator, description) for description in BINARY_SENSORS
)
class AirOSBinarySensor(AirOSEntity, BinarySensorEntity):
"""Representation of a binary sensor."""
entity_description: AirOSBinarySensorEntityDescription
def __init__(
self,
coordinator: AirOSDataUpdateCoordinator,
description: AirOSBinarySensorEntityDescription,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.host.device_id}_{description.key}"
@property
def is_on(self) -> bool:
"""Return the state of the binary sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -6,11 +6,11 @@ import logging
from typing import Any
from airos.exceptions import (
AirOSConnectionAuthenticationError,
AirOSConnectionSetupError,
AirOSDataMissingError,
AirOSDeviceConnectionError,
AirOSKeyDataMissingError,
ConnectionAuthenticationError,
ConnectionSetupError,
DataMissingError,
DeviceConnectionError,
KeyDataMissingError,
)
import voluptuous as vol
@@ -59,13 +59,13 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
airos_data = await airos_device.status()
except (
AirOSConnectionSetupError,
AirOSDeviceConnectionError,
ConnectionSetupError,
DeviceConnectionError,
):
errors["base"] = "cannot_connect"
except (AirOSConnectionAuthenticationError, AirOSDataMissingError):
except (ConnectionAuthenticationError, DataMissingError):
errors["base"] = "invalid_auth"
except AirOSKeyDataMissingError:
except KeyDataMissingError:
errors["base"] = "key_data_missing"
except Exception:
_LOGGER.exception("Unexpected exception")

View File

@@ -6,10 +6,10 @@ import logging
from airos.airos8 import AirOS, AirOSData
from airos.exceptions import (
AirOSConnectionAuthenticationError,
AirOSConnectionSetupError,
AirOSDataMissingError,
AirOSDeviceConnectionError,
ConnectionAuthenticationError,
ConnectionSetupError,
DataMissingError,
DeviceConnectionError,
)
from homeassistant.config_entries import ConfigEntry
@@ -47,22 +47,18 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSData]):
try:
await self.airos_device.login()
return await self.airos_device.status()
except (AirOSConnectionAuthenticationError,) as err:
except (ConnectionAuthenticationError,) as err:
_LOGGER.exception("Error authenticating with airOS device")
raise ConfigEntryError(
translation_domain=DOMAIN, translation_key="invalid_auth"
) from err
except (
AirOSConnectionSetupError,
AirOSDeviceConnectionError,
TimeoutError,
) as err:
except (ConnectionSetupError, DeviceConnectionError, TimeoutError) as err:
_LOGGER.error("Error connecting to airOS device: %s", err)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
except (AirOSDataMissingError,) as err:
except (DataMissingError,) as err:
_LOGGER.error("Expected data not returned by airOS device: %s", err)
raise UpdateFailed(
translation_domain=DOMAIN,

View File

@@ -1,33 +0,0 @@
"""Diagnostics support for airOS."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from .coordinator import AirOSConfigEntry
IP_REDACT = ["addr", "ipaddr", "ip6addr", "lastip"] # IP related
HW_REDACT = ["apmac", "hwaddr", "mac"] # MAC address
TO_REDACT_HA = [CONF_HOST, CONF_PASSWORD]
TO_REDACT_AIROS = [
"hostname", # Prevent leaking device naming
"essid", # Network SSID
"lat", # GPS latitude to prevent exposing location data.
"lon", # GPS longitude to prevent exposing location data.
*HW_REDACT,
*IP_REDACT,
]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AirOSConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"entry_data": async_redact_data(entry.data, TO_REDACT_HA),
"data": async_redact_data(entry.runtime_data.data.to_dict(), TO_REDACT_AIROS),
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airos",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["airos==0.3.0"]
"requirements": ["airos==0.2.1"]
}

View File

@@ -41,7 +41,7 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: done
@@ -54,7 +54,9 @@ rules:
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-disabled-by-default:
status: todo
comment: prepared binary_sensors will provide this
entity-translations: done
exception-translations: done
icon-translations:

View File

@@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
import logging
from airos.data import DerivedWirelessMode, DerivedWirelessRole, NetRole
from airos.data import NetRole, WirelessMode
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -19,8 +19,6 @@ from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS,
UnitOfDataRate,
UnitOfFrequency,
UnitOfLength,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -31,11 +29,8 @@ from .entity import AirOSEntity
_LOGGER = logging.getLogger(__name__)
WIRELESS_MODE_OPTIONS = [mode.value.replace("-", "_").lower() for mode in WirelessMode]
NETROLE_OPTIONS = [mode.value for mode in NetRole]
WIRELESS_MODE_OPTIONS = [mode.value for mode in DerivedWirelessMode]
WIRELESS_ROLE_OPTIONS = [mode.value for mode in DerivedWirelessRole]
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
@@ -51,7 +46,6 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
translation_key="host_cpuload",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.host.cpuload,
entity_registry_enabled_default=False,
),
@@ -75,6 +69,13 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
translation_key="wireless_essid",
value_fn=lambda data: data.wireless.essid,
),
AirOSSensorEntityDescription(
key="wireless_mode",
translation_key="wireless_mode",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data.wireless.mode.value.replace("-", "_").lower(),
options=WIRELESS_MODE_OPTIONS,
),
AirOSSensorEntityDescription(
key="wireless_antenna_gain",
translation_key="wireless_antenna_gain",
@@ -89,8 +90,6 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.throughput.tx,
),
AirOSSensorEntityDescription(
@@ -99,8 +98,6 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.throughput.rx,
),
AirOSSensorEntityDescription(
@@ -109,8 +106,6 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.polling.dl_capacity,
),
AirOSSensorEntityDescription(
@@ -119,45 +114,8 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.polling.ul_capacity,
),
AirOSSensorEntityDescription(
key="host_uptime",
translation_key="host_uptime",
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=SensorDeviceClass.DURATION,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfTime.DAYS,
value_fn=lambda data: data.host.uptime,
entity_registry_enabled_default=False,
),
AirOSSensorEntityDescription(
key="wireless_distance",
translation_key="wireless_distance",
native_unit_of_measurement=UnitOfLength.METERS,
device_class=SensorDeviceClass.DISTANCE,
suggested_display_precision=1,
suggested_unit_of_measurement=UnitOfLength.KILOMETERS,
value_fn=lambda data: data.wireless.distance,
),
AirOSSensorEntityDescription(
key="wireless_mode",
translation_key="wireless_mode",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data.derived.mode.value,
options=WIRELESS_MODE_OPTIONS,
entity_registry_enabled_default=False,
),
AirOSSensorEntityDescription(
key="wireless_role",
translation_key="wireless_role",
device_class=SensorDeviceClass.ENUM,
value_fn=lambda data: data.derived.role.value,
options=WIRELESS_ROLE_OPTIONS,
entity_registry_enabled_default=False,
),
)

View File

@@ -26,23 +26,6 @@
}
},
"entity": {
"binary_sensor": {
"port_forwarding": {
"name": "Port forwarding"
},
"dhcp_client": {
"name": "DHCP client"
},
"dhcp_server": {
"name": "DHCP server"
},
"dhcp6_server": {
"name": "DHCPv6 server"
},
"pppoe": {
"name": "PPPoE link"
}
},
"sensor": {
"host_cpuload": {
"name": "CPU load"
@@ -60,6 +43,13 @@
"wireless_essid": {
"name": "Wireless SSID"
},
"wireless_mode": {
"name": "Wireless mode",
"state": {
"ap_ptp": "Access point",
"sta_ptp": "Station"
}
},
"wireless_antenna_gain": {
"name": "Antenna gain"
},
@@ -77,26 +67,6 @@
},
"wireless_remote_hostname": {
"name": "Remote hostname"
},
"host_uptime": {
"name": "Uptime"
},
"wireless_distance": {
"name": "Wireless distance"
},
"wireless_role": {
"name": "Wireless role",
"state": {
"access_point": "Access point",
"station": "Station"
}
},
"wireless_mode": {
"name": "Wireless mode",
"state": {
"point_to_point": "Point-to-point",
"point_to_multipoint": "Point-to-multipoint"
}
}
}
},

View File

@@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant
from .const import CONF_CLIP_NEGATIVE, CONF_RETURN_AVERAGE
from .coordinator import AirQCoordinator
PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.SENSOR]
AirQConfigEntry = ConfigEntry[AirQCoordinator]

View File

@@ -75,7 +75,6 @@ class AirQCoordinator(DataUpdateCoordinator):
return_average=self.return_average,
clip_negative_values=self.clip_negative,
)
data["brightness"] = await self.airq.get_current_brightness()
if warming_up_sensors := identify_warming_up_sensors(data):
_LOGGER.debug(
"Following sensors are still warming up: %s", warming_up_sensors

View File

@@ -1,85 +0,0 @@
"""Definition of air-Q number platform used to control the LED strips."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import logging
from aioairq.core import AirQ
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import AirQConfigEntry, AirQCoordinator
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class AirQBrightnessDescription(NumberEntityDescription):
"""Describes AirQ number entity responsible for brightness control."""
value: Callable[[dict], float]
set_value: Callable[[AirQ, float], Awaitable[None]]
AIRQ_LED_BRIGHTNESS = AirQBrightnessDescription(
key="airq_led_brightness",
translation_key="airq_led_brightness",
native_min_value=0.0,
native_max_value=100.0,
native_step=1.0,
native_unit_of_measurement=PERCENTAGE,
value=lambda data: data["brightness"],
set_value=lambda device, value: device.set_current_brightness(value),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirQConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up number entities: a single entity for the LEDs."""
coordinator = entry.runtime_data
entities = [AirQLEDBrightness(coordinator, AIRQ_LED_BRIGHTNESS)]
async_add_entities(entities)
class AirQLEDBrightness(CoordinatorEntity[AirQCoordinator], NumberEntity):
"""Representation of the LEDs from a single AirQ."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AirQCoordinator,
description: AirQBrightnessDescription,
) -> None:
"""Initialize a single sensor."""
super().__init__(coordinator)
self.entity_description: AirQBrightnessDescription = description
self._attr_device_info = coordinator.device_info
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
@property
def native_value(self) -> float:
"""Return the brightness of the LEDs in %."""
return self.entity_description.value(self.coordinator.data)
async def async_set_native_value(self, value: float) -> None:
"""Set the brightness of the LEDs to the value in %."""
_LOGGER.debug(
"Changing LED brighntess from %.0f%% to %.0f%%",
self.coordinator.data["brightness"],
value,
)
await self.entity_description.set_value(self.coordinator.airq, value)
await self.coordinator.async_request_refresh()

View File

@@ -35,11 +35,6 @@
}
},
"entity": {
"number": {
"airq_led_brightness": {
"name": "LED brightness"
}
},
"sensor": {
"acetaldehyde": {
"name": "Acetaldehyde"

View File

@@ -7,18 +7,21 @@ import logging
from airthings import Airthings
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_SECRET
from .coordinator import AirthingsConfigEntry, AirthingsDataUpdateCoordinator
from .coordinator import AirthingsDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
SCAN_INTERVAL = timedelta(minutes=6)
type AirthingsConfigEntry = ConfigEntry[AirthingsDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) -> bool:
"""Set up Airthings from a config entry."""
@@ -28,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirthingsConfigEntry) ->
async_get_clientsession(hass),
)
coordinator = AirthingsDataUpdateCoordinator(hass, airthings, entry)
coordinator = AirthingsDataUpdateCoordinator(hass, airthings)
await coordinator.async_config_entry_first_refresh()

View File

@@ -5,7 +5,6 @@ import logging
from airthings import Airthings, AirthingsDevice, AirthingsError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -14,23 +13,15 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(minutes=6)
type AirthingsConfigEntry = ConfigEntry[AirthingsDataUpdateCoordinator]
class AirthingsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AirthingsDevice]]):
"""Coordinator for Airthings data updates."""
def __init__(
self,
hass: HomeAssistant,
airthings: Airthings,
config_entry: AirthingsConfigEntry,
) -> None:
def __init__(self, hass: HomeAssistant, airthings: Airthings) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_method=self._update_method,
update_interval=SCAN_INTERVAL,

View File

@@ -9,6 +9,7 @@ DOMAIN: Final = "amberelectric"
CONF_SITE_NAME = "site_name"
CONF_SITE_ID = "site_id"
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
ATTR_CHANNEL_TYPE = "channel_type"
ATTRIBUTION = "Data provided by Amber Electric"

View File

@@ -4,7 +4,6 @@ from amberelectric.models.channel import ChannelType
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
from homeassistant.core import (
HomeAssistant,
ServiceCall,
@@ -17,6 +16,7 @@ from homeassistant.util.json import JsonValueType
from .const import (
ATTR_CHANNEL_TYPE,
ATTR_CONFIG_ENTRY_ID,
CONTROLLED_LOAD_CHANNEL,
DOMAIN,
FEED_IN_CHANNEL,

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from aioambient.util import get_public_device_id
from homeassistant.core import callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity, EntityDescription
@@ -37,7 +37,6 @@ class AmbientWeatherEntity(Entity):
identifiers={(DOMAIN, mac_address)},
manufacturer="Ambient Weather",
name=station_name.capitalize(),
connections={(CONNECTION_NETWORK_MAC, mac_address)},
)
self._attr_unique_id = f"{mac_address}_{description.key}"

View File

@@ -390,6 +390,7 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
async def async_devices_payload(hass: HomeAssistant) -> dict:
"""Return the devices payload."""
integrations_without_model_id: set[str] = set()
devices: list[dict[str, Any]] = []
dev_reg = dr.async_get(hass)
# Devices that need via device info set
@@ -399,6 +400,10 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
seen_integrations = set()
for device in dev_reg.devices.values():
# Ignore services
if device.entry_type:
continue
if not device.primary_config_entry:
continue
@@ -409,6 +414,13 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
seen_integrations.add(config_entry.domain)
if not device.model_id:
integrations_without_model_id.add(config_entry.domain)
continue
if not device.manufacturer:
continue
new_indexes[device.id] = len(devices)
devices.append(
{
@@ -418,12 +430,11 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
"model": device.model,
"sw_version": device.sw_version,
"hw_version": device.hw_version,
"has_suggested_area": device.suggested_area is not None,
"has_configuration_url": device.configuration_url is not None,
"via_device": None,
"entry_type": device.entry_type.value if device.entry_type else None,
}
)
if device.via_device_id:
via_devices[device.id] = device.via_device_id
@@ -443,11 +454,15 @@ async def async_devices_payload(hass: HomeAssistant) -> dict:
for device_info in devices:
if integration := integrations.get(device_info["integration"]):
device_info["is_custom_integration"] = not integration.is_built_in
# Include version for custom integrations
if not integration.is_built_in and integration.version:
device_info["custom_integration_version"] = str(integration.version)
return {
"version": "home-assistant:1",
"no_model_id": sorted(
[
domain
for domain in integrations_without_model_id
if domain in integrations and integrations[domain].is_built_in
]
),
"devices": devices,
}

View File

@@ -30,9 +30,10 @@ class AndroidIPCamDataUpdateCoordinator(DataUpdateCoordinator[None]):
cam: PyDroidIPCam,
) -> None:
"""Initialize the Android IP Webcam."""
self.hass = hass
self.cam = cam
super().__init__(
hass,
self.hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN} {config_entry.data[CONF_HOST]}",

View File

@@ -81,15 +81,11 @@ async def async_update_options(
async def async_migrate_integration(hass: HomeAssistant) -> None:
"""Migrate integration entry structure."""
# Make sure we get enabled config entries first
entries = sorted(
hass.config_entries.async_entries(DOMAIN),
key=lambda e: e.disabled_by is not None,
)
entries = hass.config_entries.async_entries(DOMAIN)
if not any(entry.version == 1 for entry in entries):
return
api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {}
api_keys_entries: dict[str, ConfigEntry] = {}
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
@@ -103,61 +99,30 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
)
if entry.data[CONF_API_KEY] not in api_keys_entries:
use_existing = True
all_disabled = all(
e.disabled_by is not None
for e in entries
if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY]
)
api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled)
api_keys_entries[entry.data[CONF_API_KEY]] = entry
parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]]
parent_entry = api_keys_entries[entry.data[CONF_API_KEY]]
hass.config_entries.async_add_subentry(parent_entry, subentry)
conversation_entity_id = entity_registry.async_get_entity_id(
conversation_entity = entity_registry.async_get_entity_id(
"conversation",
DOMAIN,
entry.entry_id,
)
device = device_registry.async_get_device(
identifiers={(DOMAIN, entry.entry_id)}
)
if conversation_entity_id is not None:
conversation_entity_entry = entity_registry.entities[conversation_entity_id]
entity_disabled_by = conversation_entity_entry.disabled_by
if (
entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY
and not all_disabled
):
# Device and entity registries don't update the disabled_by flag
# when moving a device or entity from one config entry to another,
# so we need to do it manually.
entity_disabled_by = (
er.RegistryEntryDisabler.DEVICE
if device
else er.RegistryEntryDisabler.USER
)
if conversation_entity is not None:
entity_registry.async_update_entity(
conversation_entity_id,
conversation_entity,
config_entry_id=parent_entry.entry_id,
config_subentry_id=subentry.subentry_id,
disabled_by=entity_disabled_by,
new_unique_id=subentry.subentry_id,
)
device = device_registry.async_get_device(
identifiers={(DOMAIN, entry.entry_id)}
)
if device is not None:
# Device and entity registries don't update the disabled_by flag when
# moving a device or entity from one config entry to another, so we
# need to do it manually.
device_disabled_by = device.disabled_by
if (
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
and not all_disabled
):
device_disabled_by = dr.DeviceEntryDisabler.USER
device_registry.async_update_device(
device.id,
disabled_by=device_disabled_by,
new_identifiers={(DOMAIN, subentry.subentry_id)},
add_config_subentry_id=subentry.subentry_id,
add_config_entry_id=parent_entry.entry_id,
@@ -182,7 +147,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
title=DEFAULT_CONVERSATION_NAME,
options={},
version=2,
minor_version=3,
minor_version=2,
)
@@ -208,38 +173,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
hass.config_entries.async_update_entry(entry, minor_version=2)
if entry.version == 2 and entry.minor_version == 2:
# Fix migration where the disabled_by flag was not set correctly.
# We can currently only correct this for enabled config entries,
# because migration does not run for disabled config entries. This
# is asserted in tests, and if that behavior is changed, we should
# correct also disabled config entries.
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
entity_entries = er.async_entries_for_config_entry(
entity_registry, entry.entry_id
)
if entry.disabled_by is None:
# If the config entry is not disabled, we need to set the disabled_by
# flag on devices to USER, and on entities to DEVICE, if they are set
# to CONFIG_ENTRY.
for device in devices:
if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY:
continue
device_registry.async_update_device(
device.id,
disabled_by=dr.DeviceEntryDisabler.USER,
)
for entity in entity_entries:
if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY:
continue
entity_registry.async_update_entity(
entity.entity_id,
disabled_by=er.RegistryEntryDisabler.DEVICE,
)
hass.config_entries.async_update_entry(entry, minor_version=3)
LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
)

View File

@@ -75,7 +75,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Anthropic."""
VERSION = 2
MINOR_VERSION = 3
MINOR_VERSION = 2
async def async_step_user(
self, user_input: dict[str, Any] | None = None

View File

@@ -20,8 +20,10 @@ RECOMMENDED_THINKING_BUDGET = 0
MIN_THINKING_BUDGET = 1024
THINKING_MODELS = [
"claude-3-7-sonnet",
"claude-sonnet-4-0",
"claude-3-7-sonnet-20250219",
"claude-3-7-sonnet-latest",
"claude-opus-4-20250514",
"claude-opus-4-0",
"claude-opus-4-1",
"claude-sonnet-4-20250514",
"claude-sonnet-4-0",
]

View File

@@ -2,10 +2,11 @@
from collections.abc import AsyncGenerator, Callable, Iterable
import json
from typing import Any
from typing import Any, cast
import anthropic
from anthropic import AsyncStream
from anthropic._types import NOT_GIVEN
from anthropic.types import (
InputJSONDelta,
MessageDeltaUsage,
@@ -16,6 +17,7 @@ from anthropic.types import (
RawContentBlockStopEvent,
RawMessageDeltaEvent,
RawMessageStartEvent,
RawMessageStopEvent,
RedactedThinkingBlock,
RedactedThinkingBlockParam,
SignatureDelta,
@@ -33,7 +35,6 @@ from anthropic.types import (
ToolUseBlockParam,
Usage,
)
from anthropic.types.message_create_params import MessageCreateParamsStreaming
from voluptuous_openapi import convert
from homeassistant.components import conversation
@@ -128,28 +129,6 @@ def _convert_content(
)
)
if isinstance(content.native, ThinkingBlock):
messages[-1]["content"].append( # type: ignore[union-attr]
ThinkingBlockParam(
type="thinking",
thinking=content.thinking_content or "",
signature=content.native.signature,
)
)
elif isinstance(content.native, RedactedThinkingBlock):
redacted_thinking_block = RedactedThinkingBlockParam(
type="redacted_thinking",
data=content.native.data,
)
if isinstance(messages[-1]["content"], str):
messages[-1]["content"] = [
TextBlockParam(type="text", text=messages[-1]["content"]),
redacted_thinking_block,
]
else:
messages[-1]["content"].append( # type: ignore[attr-defined]
redacted_thinking_block
)
if content.content:
messages[-1]["content"].append( # type: ignore[union-attr]
TextBlockParam(type="text", text=content.content)
@@ -173,9 +152,10 @@ def _convert_content(
return messages
async def _transform_stream(
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
chat_log: conversation.ChatLog,
stream: AsyncStream[MessageStreamEvent],
result: AsyncStream[MessageStreamEvent],
messages: list[MessageParam],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform the response stream into HA format.
@@ -206,25 +186,31 @@ async def _transform_stream(
Each message could contain multiple blocks of the same type.
"""
if stream is None:
if result is None:
raise TypeError("Expected a stream of messages")
current_tool_block: ToolUseBlockParam | None = None
current_message: MessageParam | None = None
current_block: (
TextBlockParam
| ToolUseBlockParam
| ThinkingBlockParam
| RedactedThinkingBlockParam
| None
) = None
current_tool_args: str
input_usage: Usage | None = None
has_content = False
has_native = False
async for response in stream:
async for response in result:
LOGGER.debug("Received response: %s", response)
if isinstance(response, RawMessageStartEvent):
if response.message.role != "assistant":
raise ValueError("Unexpected message role")
current_message = MessageParam(role=response.message.role, content=[])
input_usage = response.message.usage
elif isinstance(response, RawContentBlockStartEvent):
if isinstance(response.content_block, ToolUseBlock):
current_tool_block = ToolUseBlockParam(
current_block = ToolUseBlockParam(
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
@@ -232,64 +218,75 @@ async def _transform_stream(
)
current_tool_args = ""
elif isinstance(response.content_block, TextBlock):
if has_content:
yield {"role": "assistant"}
has_native = False
has_content = True
current_block = TextBlockParam(
type="text", text=response.content_block.text
)
yield {"role": "assistant"}
if response.content_block.text:
yield {"content": response.content_block.text}
elif isinstance(response.content_block, ThinkingBlock):
if has_native:
yield {"role": "assistant"}
has_native = False
has_content = False
current_block = ThinkingBlockParam(
type="thinking",
thinking=response.content_block.thinking,
signature=response.content_block.signature,
)
elif isinstance(response.content_block, RedactedThinkingBlock):
current_block = RedactedThinkingBlockParam(
type="redacted_thinking", data=response.content_block.data
)
LOGGER.debug(
"Some of Claudes internal reasoning has been automatically "
"encrypted for safety reasons. This doesnt affect the quality of "
"responses"
)
if has_native:
yield {"role": "assistant"}
has_native = False
has_content = False
yield {"native": response.content_block}
has_native = True
elif isinstance(response, RawContentBlockDeltaEvent):
if current_block is None:
raise ValueError("Unexpected delta without a block")
if isinstance(response.delta, InputJSONDelta):
current_tool_args += response.delta.partial_json
elif isinstance(response.delta, TextDelta):
text_block = cast(TextBlockParam, current_block)
text_block["text"] += response.delta.text
yield {"content": response.delta.text}
elif isinstance(response.delta, ThinkingDelta):
yield {"thinking_content": response.delta.thinking}
thinking_block = cast(ThinkingBlockParam, current_block)
thinking_block["thinking"] += response.delta.thinking
elif isinstance(response.delta, SignatureDelta):
yield {
"native": ThinkingBlock(
type="thinking",
thinking="",
signature=response.delta.signature,
)
}
has_native = True
thinking_block = cast(ThinkingBlockParam, current_block)
thinking_block["signature"] += response.delta.signature
elif isinstance(response, RawContentBlockStopEvent):
if current_tool_block is not None:
if current_block is None:
raise ValueError("Unexpected stop event without a current block")
if current_block["type"] == "tool_use":
# tool block
tool_args = json.loads(current_tool_args) if current_tool_args else {}
current_tool_block["input"] = tool_args
current_block["input"] = tool_args
yield {
"tool_calls": [
llm.ToolInput(
id=current_tool_block["id"],
tool_name=current_tool_block["name"],
id=current_block["id"],
tool_name=current_block["name"],
tool_args=tool_args,
)
]
}
current_tool_block = None
elif current_block["type"] == "thinking":
# thinking block
LOGGER.debug("Thinking: %s", current_block["thinking"])
if current_message is None:
raise ValueError("Unexpected stop event without a current message")
current_message["content"].append(current_block) # type: ignore[union-attr]
current_block = None
elif isinstance(response, RawMessageDeltaEvent):
if (usage := response.usage) is not None:
chat_log.async_trace(_create_token_stats(input_usage, usage))
if response.delta.stop_reason == "refusal":
raise HomeAssistantError("Potential policy violation detected")
elif isinstance(response, RawMessageStopEvent):
if current_message is not None:
messages.append(current_message)
current_message = None
def _create_token_stats(
@@ -354,48 +351,45 @@ class AnthropicBaseLLMEntity(Entity):
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
model_args = MessageCreateParamsStreaming(
model=model,
messages=messages,
max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
system=system.content,
stream=True,
)
if tools:
model_args["tools"] = tools
if (
model.startswith(tuple(THINKING_MODELS))
and thinking_budget >= MIN_THINKING_BUDGET
):
model_args["thinking"] = ThinkingConfigEnabledParam(
type="enabled", budget_tokens=thinking_budget
)
else:
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
model_args["temperature"] = options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
)
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
model_args = {
"model": model,
"messages": messages,
"tools": tools or NOT_GIVEN,
"max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
"system": system.content,
"stream": True,
}
if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET:
model_args["thinking"] = ThinkingConfigEnabledParam(
type="enabled", budget_tokens=thinking_budget
)
else:
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
model_args["temperature"] = options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
)
try:
stream = await client.messages.create(**model_args)
messages.extend(
_convert_content(
[
content
async for content in chat_log.async_add_delta_content_stream(
self.entity_id,
_transform_stream(chat_log, stream),
)
]
)
)
except anthropic.AnthropicError as err:
raise HomeAssistantError(
f"Sorry, I had a problem talking to Anthropic: {err}"
) from err
messages.extend(
_convert_content(
[
content
async for content in chat_log.async_add_delta_content_stream(
self.entity_id,
_transform_stream(chat_log, stream, messages),
)
if not isinstance(content, conversation.AssistantContent)
]
)
)
if not chat_log.unresponded_tool_results:
break

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["anthropic==0.62.0"]
"requirements": ["anthropic==0.52.0"]
}

View File

@@ -6,6 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/apcupsd",
"iot_class": "local_polling",
"loggers": ["apcaccess"],
"quality_scale": "bronze",
"requirements": ["aioapcaccess==0.4.2"]
}

View File

@@ -1,93 +0,0 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules:
status: done
comment: |
Consider deriving a base entity.
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 does 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:
status: exempt
comment: |
The integration does not provide any actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
The integration does not provide any additional options.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
The integration does not require authentication.
test-coverage:
status: todo
comment: |
Patch `aioapcaccess.request_status` where we use it.
# Gold
devices: done
diagnostics: done
discovery-update-info:
status: exempt
comment: |
This integration cannot be discovered.
discovery:
status: exempt
comment: |
This integration cannot be discovered.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: |
The integration connects to a single service per configuration entry.
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues: done
stale-devices:
status: exempt
comment: |
This integration connect to a single service per configuration entry.
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: |
The integration does not connect via HTTP.
strict-typing: done

View File

@@ -14,22 +14,7 @@
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of the APC UPS Daemon",
"port": "The port the APC UPS Daemon is listening on"
},
"description": "Enter the host and port on which the apcupsd NIS is being served."
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "[%key:component::apcupsd::config::step::user::data_description::host%]",
"port": "[%key:component::apcupsd::config::step::user::data_description::port%]"
},
"description": "[%key:component::apcupsd::config::step::user::description%]"
}
}
},

View File

@@ -11,7 +11,7 @@ import time
from typing import Any, Literal, final
from hassil import Intents, recognize
from hassil.expression import Expression, Group, ListReference
from hassil.expression import Expression, ListReference, Sequence
from hassil.intents import WildcardSlotList
from homeassistant.components import conversation, media_source, stt, tts
@@ -413,7 +413,7 @@ class AssistSatelliteEntity(entity.Entity):
for intent in intents.intents.values():
for intent_data in intent.data:
for sentence in intent_data.sentences:
_collect_list_references(sentence.expression, wildcard_names)
_collect_list_references(sentence, wildcard_names)
for wildcard_name in wildcard_names:
intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name)
@@ -727,9 +727,9 @@ class AssistSatelliteEntity(entity.Entity):
def _collect_list_references(expression: Expression, list_names: set[str]) -> None:
"""Collect list reference names recursively."""
if isinstance(expression, Group):
grp: Group = expression
for item in grp.items:
if isinstance(expression, Sequence):
seq: Sequence = expression
for item in seq.items:
_collect_list_references(item, list_names)
elif isinstance(expression, ListReference):
# {list}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.1.0"]
"requirements": ["hassil==2.2.3"]
}

View File

@@ -5,16 +5,15 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from collections import namedtuple
from collections.abc import Awaitable, Callable, Coroutine
from datetime import datetime
import functools
import logging
from typing import Any, cast
from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy
from aiohttp import ClientSession
from asusrouter import AsusRouter, AsusRouterError
from asusrouter.modules.client import AsusClient
from asusrouter.modules.data import AsusData
from asusrouter.modules.homeassistant import convert_to_ha_data, convert_to_ha_sensors
from pyasuswrt import AsusWrtError, AsusWrtHttp
from pyasuswrt.exceptions import AsusWrtNotAvailableInfoError
from homeassistant.const import (
CONF_HOST,
@@ -42,13 +41,14 @@ from .const import (
PROTOCOL_HTTPS,
PROTOCOL_TELNET,
SENSORS_BYTES,
SENSORS_CPU,
SENSORS_LOAD_AVG,
SENSORS_MEMORY,
SENSORS_RATES,
SENSORS_TEMPERATURES,
SENSORS_TEMPERATURES_LEGACY,
SENSORS_UPTIME,
)
from .helpers import clean_dict, translate_to_legacy
SENSORS_TYPE_BYTES = "sensors_bytes"
SENSORS_TYPE_COUNT = "sensors_count"
@@ -310,16 +310,16 @@ class AsusWrtHttpBridge(AsusWrtBridge):
def __init__(self, conf: dict[str, Any], session: ClientSession) -> None:
"""Initialize Bridge that use HTTP library."""
super().__init__(conf[CONF_HOST])
self._api = self._get_api(conf, session)
self._api: AsusWrtHttp = self._get_api(conf, session)
@staticmethod
def _get_api(conf: dict[str, Any], session: ClientSession) -> AsusRouter:
"""Get the AsusRouter API."""
return AsusRouter(
hostname=conf[CONF_HOST],
username=conf[CONF_USERNAME],
password=conf.get(CONF_PASSWORD, ""),
use_ssl=conf[CONF_PROTOCOL] == PROTOCOL_HTTPS,
def _get_api(conf: dict[str, Any], session: ClientSession) -> AsusWrtHttp:
"""Get the AsusWrtHttp API."""
return AsusWrtHttp(
conf[CONF_HOST],
conf[CONF_USERNAME],
conf.get(CONF_PASSWORD, ""),
use_https=conf[CONF_PROTOCOL] == PROTOCOL_HTTPS,
port=conf.get(CONF_PORT),
session=session,
)
@@ -327,90 +327,46 @@ class AsusWrtHttpBridge(AsusWrtBridge):
@property
def is_connected(self) -> bool:
"""Get connected status."""
return self._api.connected
return cast(bool, self._api.is_connected)
async def async_connect(self) -> None:
"""Connect to the device."""
await self._api.async_connect()
# Collect the identity
_identity = await self._api.async_get_identity()
# get main router properties
if mac := _identity.mac:
if mac := self._api.mac:
self._label_mac = format_mac(mac)
self._firmware = str(_identity.firmware)
self._model = _identity.model
self._firmware = self._api.firmware
self._model = self._api.model
async def async_disconnect(self) -> None:
"""Disconnect to the device."""
await self._api.async_disconnect()
async def _get_data(
self,
datatype: AsusData,
force: bool = False,
) -> dict[str, Any]:
"""Get data from the device.
This is a generic method which automatically converts to
the Home Assistant-compatible format.
"""
try:
raw = await self._api.async_get_data(datatype, force=force)
return translate_to_legacy(clean_dict(convert_to_ha_data(raw)))
except AsusRouterError as ex:
raise UpdateFailed(ex) from ex
async def _get_sensors(self, datatype: AsusData) -> list[str]:
"""Get the available sensors.
This is a generic method which automatically converts to
the Home Assistant-compatible format.
"""
sensors = []
try:
data = await self._api.async_get_data(datatype)
# Get the list of sensors from the raw data
# and translate in to the legacy format
sensors = translate_to_legacy(convert_to_ha_sensors(data, datatype))
_LOGGER.debug("Available `%s` sensors: %s", datatype.value, sensors)
except AsusRouterError as ex:
_LOGGER.warning(
"Cannot get available `%s` sensors with exception: %s",
datatype.value,
ex,
)
return sensors
async def async_get_connected_devices(self) -> dict[str, WrtDevice]:
"""Get list of connected devices."""
api_devices: dict[str, AsusClient] = await self._api.async_get_data(
AsusData.CLIENTS, force=True
)
api_devices = await self._api.async_get_connected_devices()
return {
format_mac(mac): WrtDevice(
dev.connection.ip_address, dev.description.name, dev.connection.node
)
format_mac(mac): WrtDevice(dev.ip, dev.name, dev.node)
for mac, dev in api_devices.items()
if dev.connection is not None
and dev.description is not None
and dev.connection.ip_address is not None
}
async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]:
"""Return a dictionary of available sensors for this bridge."""
sensors_cpu = await self._get_available_cpu_sensors()
sensors_temperatures = await self._get_available_temperature_sensors()
sensors_loadavg = await self._get_loadavg_sensors_availability()
return {
SENSORS_TYPE_BYTES: {
KEY_SENSORS: SENSORS_BYTES,
KEY_METHOD: self._get_bytes,
},
SENSORS_TYPE_CPU: {
KEY_SENSORS: await self._get_sensors(AsusData.CPU),
KEY_SENSORS: sensors_cpu,
KEY_METHOD: self._get_cpu_usage,
},
SENSORS_TYPE_LOAD_AVG: {
KEY_SENSORS: await self._get_sensors(AsusData.SYSINFO),
KEY_SENSORS: sensors_loadavg,
KEY_METHOD: self._get_load_avg,
},
SENSORS_TYPE_MEMORY: {
@@ -426,44 +382,95 @@ class AsusWrtHttpBridge(AsusWrtBridge):
KEY_METHOD: self._get_uptime,
},
SENSORS_TYPE_TEMPERATURES: {
KEY_SENSORS: await self._get_sensors(AsusData.TEMPERATURE),
KEY_SENSORS: sensors_temperatures,
KEY_METHOD: self._get_temperatures,
},
}
async def _get_available_cpu_sensors(self) -> list[str]:
"""Check which cpu information is available on the router."""
try:
available_cpu = await self._api.async_get_cpu_usage()
available_sensors = [t for t in SENSORS_CPU if t in available_cpu]
except AsusWrtError as exc:
_LOGGER.warning(
(
"Failed checking cpu sensor availability for ASUS router"
" %s. Exception: %s"
),
self.host,
exc,
)
return []
return available_sensors
async def _get_available_temperature_sensors(self) -> list[str]:
"""Check which temperature information is available on the router."""
try:
available_temps = await self._api.async_get_temperatures()
available_sensors = [
t for t in SENSORS_TEMPERATURES if t in available_temps
]
except AsusWrtError as exc:
_LOGGER.warning(
(
"Failed checking temperature sensor availability for ASUS router"
" %s. Exception: %s"
),
self.host,
exc,
)
return []
return available_sensors
async def _get_loadavg_sensors_availability(self) -> list[str]:
"""Check if load avg is available on the router."""
try:
await self._api.async_get_loadavg()
except AsusWrtNotAvailableInfoError:
return []
except AsusWrtError:
pass
return SENSORS_LOAD_AVG
@handle_errors_and_zip(AsusWrtError, SENSORS_BYTES)
async def _get_bytes(self) -> Any:
"""Fetch byte information from the router."""
return await self._get_data(AsusData.NETWORK)
return await self._api.async_get_traffic_bytes()
@handle_errors_and_zip(AsusWrtError, SENSORS_RATES)
async def _get_rates(self) -> Any:
"""Fetch rates information from the router."""
data = await self._get_data(AsusData.NETWORK)
# Convert from bits/s to Bytes/s for compatibility with legacy sensors
return {
key: (
value / 8
if key in SENSORS_RATES and isinstance(value, (int, float))
else value
)
for key, value in data.items()
}
return await self._api.async_get_traffic_rates()
@handle_errors_and_zip(AsusWrtError, SENSORS_LOAD_AVG)
async def _get_load_avg(self) -> Any:
"""Fetch cpu load avg information from the router."""
return await self._get_data(AsusData.SYSINFO)
return await self._api.async_get_loadavg()
@handle_errors_and_zip(AsusWrtError, None)
async def _get_temperatures(self) -> Any:
"""Fetch temperatures information from the router."""
return await self._get_data(AsusData.TEMPERATURE)
return await self._api.async_get_temperatures()
@handle_errors_and_zip(AsusWrtError, None)
async def _get_cpu_usage(self) -> Any:
"""Fetch cpu information from the router."""
return await self._get_data(AsusData.CPU)
return await self._api.async_get_cpu_usage()
@handle_errors_and_zip(AsusWrtError, None)
async def _get_memory_usage(self) -> Any:
"""Fetch memory information from the router."""
return await self._get_data(AsusData.RAM)
return await self._api.async_get_memory_usage()
async def _get_uptime(self) -> dict[str, Any]:
"""Fetch uptime from the router."""
return await self._get_data(AsusData.BOOTTIME)
try:
uptimes = await self._api.async_get_uptime()
except AsusWrtError as exc:
raise UpdateFailed(exc) from exc
last_boot = datetime.fromisoformat(uptimes["last_boot"])
uptime = uptimes["uptime"]
return dict(zip(SENSORS_UPTIME, [last_boot, uptime], strict=False))

View File

@@ -7,7 +7,7 @@ import os
import socket
from typing import Any, cast
from asusrouter import AsusRouterError
from pyasuswrt import AsusWrtError
import voluptuous as vol
from homeassistant.components.device_tracker import (
@@ -189,7 +189,7 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN):
try:
await api.async_connect()
except (AsusRouterError, OSError):
except (AsusWrtError, OSError):
_LOGGER.error(
"Error connecting to the AsusWrt router at %s using protocol %s",
host,

View File

@@ -1,56 +0,0 @@
"""Helpers for AsusWRT integration."""
from __future__ import annotations
from typing import Any, TypeVar
T = TypeVar("T", dict[str, Any], list[Any], None)
TRANSLATION_MAP = {
"wan_rx": "sensor_rx_bytes",
"wan_tx": "sensor_tx_bytes",
"total_usage": "cpu_total_usage",
"usage": "mem_usage_perc",
"free": "mem_free",
"used": "mem_used",
"wan_rx_speed": "sensor_rx_rates",
"wan_tx_speed": "sensor_tx_rates",
"2ghz": "2.4GHz",
"5ghz": "5.0GHz",
"5ghz2": "5.0GHz_2",
"6ghz": "6.0GHz",
"cpu": "CPU",
"datetime": "sensor_last_boot",
"uptime": "sensor_uptime",
**{f"{num}_usage": f"cpu{num}_usage" for num in range(1, 9)},
**{f"load_avg_{load}": f"sensor_load_avg{load}" for load in ("1", "5", "15")},
}
def clean_dict(raw: dict[str, Any]) -> dict[str, Any]:
"""Cleans dictionary from None values.
The `state` key is always preserved regardless of its value.
"""
return {k: v for k, v in raw.items() if v is not None or k.endswith("state")}
def translate_to_legacy(raw: T) -> T:
"""Translate raw data to legacy format for dicts and lists."""
if raw is None:
return None
if isinstance(raw, dict):
return {TRANSLATION_MAP.get(k, k): v for k, v in raw.items()}
if isinstance(raw, list):
return [
TRANSLATION_MAP[item]
if isinstance(item, str) and item in TRANSLATION_MAP
else item
for item in raw
]
return raw

View File

@@ -1,11 +1,11 @@
{
"domain": "asuswrt",
"name": "ASUSWRT",
"codeowners": ["@kennedyshead", "@ollo69", "@Vaskivskyi"],
"codeowners": ["@kennedyshead", "@ollo69"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/asuswrt",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioasuswrt", "asusrouter", "asyncssh"],
"requirements": ["aioasuswrt==1.4.0", "asusrouter==1.19.0"]
"loggers": ["aioasuswrt", "asyncssh"],
"requirements": ["aioasuswrt==1.4.0", "pyasuswrt==0.1.21"]
}

View File

@@ -5,9 +5,9 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from datetime import datetime, timedelta
import logging
from typing import TYPE_CHECKING, Any
from typing import Any
from asusrouter import AsusRouterError
from pyasuswrt import AsusWrtError
from homeassistant.components.device_tracker import (
CONF_CONSIDER_HOME,
@@ -40,9 +40,6 @@ from .const import (
SENSORS_CONNECTED_DEVICE,
)
if TYPE_CHECKING:
from . import AsusWrtConfigEntry
CONF_REQ_RELOAD = [CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP]
SCAN_INTERVAL = timedelta(seconds=30)
@@ -55,13 +52,10 @@ _LOGGER = logging.getLogger(__name__)
class AsusWrtSensorDataHandler:
"""Data handler for AsusWrt sensor."""
def __init__(
self, hass: HomeAssistant, api: AsusWrtBridge, entry: AsusWrtConfigEntry
) -> None:
def __init__(self, hass: HomeAssistant, api: AsusWrtBridge) -> None:
"""Initialize a AsusWrt sensor data handler."""
self._hass = hass
self._api = api
self._entry = entry
self._connected_devices = 0
async def _get_connected_devices(self) -> dict[str, int]:
@@ -97,7 +91,6 @@ class AsusWrtSensorDataHandler:
update_method=method,
# Polling interval. Will only be polled if there are subscribers.
update_interval=SCAN_INTERVAL if should_poll else None,
config_entry=self._entry,
)
await coordinator.async_refresh()
@@ -229,7 +222,7 @@ class AsusWrtRouter:
"""Set up a AsusWrt router."""
try:
await self._api.async_connect()
except (AsusRouterError, OSError) as exc:
except (AsusWrtError, OSError) as exc:
raise ConfigEntryNotReady from exc
if not self._api.is_connected:
raise ConfigEntryNotReady
@@ -284,7 +277,7 @@ class AsusWrtRouter:
_LOGGER.debug("Checking devices for ASUS router %s", self.host)
try:
wrt_devices = await self._api.async_get_connected_devices()
except (OSError, AsusRouterError) as exc:
except (OSError, AsusWrtError) as exc:
if not self._connect_error:
self._connect_error = True
_LOGGER.error(
@@ -328,9 +321,7 @@ class AsusWrtRouter:
if self._sensors_data_handler:
return
self._sensors_data_handler = AsusWrtSensorDataHandler(
self.hass, self._api, self._entry
)
self._sensors_data_handler = AsusWrtSensorDataHandler(self.hass, self._api)
self._sensors_data_handler.update_device_count(self._connected_devices)
sensors_types = await self._api.async_get_available_sensors()

View File

@@ -28,5 +28,5 @@
"documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==8.11.1", "yalexs-ble==3.1.2"]
"requirements": ["yalexs==8.10.0", "yalexs-ble==3.1.0"]
}

View File

@@ -268,7 +268,7 @@ class LoginFlowBaseView(HomeAssistantView):
result.pop("data")
result.pop("context")
result_obj = result.pop("result")
result_obj: Credentials = result.pop("result")
# Result can be None if credential was never linked to a user before.
user = await hass.auth.async_get_user_by_credentials(result_obj)
@@ -281,8 +281,7 @@ class LoginFlowBaseView(HomeAssistantView):
)
process_success_login(request)
# We overwrite the Credentials object with the string code to retrieve it.
result["result"] = self._store_result(client_id, result_obj) # type: ignore[typeddict-item]
result["result"] = self._store_result(client_id, result_obj)
return self.json(result)

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from datetime import timedelta
import logging
API_ABS_HUMID = "abs_humid"
API_CO2 = "carbon_dioxide"
API_DEW_POINT = "dew_point"
API_DUST = "dust"

View File

@@ -18,7 +18,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_CONNECTIONS,
ATTR_SW_VERSION,
CONCENTRATION_GRAMS_PER_CUBIC_METER,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
@@ -34,7 +33,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
API_ABS_HUMID,
API_CO2,
API_DEW_POINT,
API_DUST,
@@ -122,14 +120,6 @@ SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
AwairSensorEntityDescription(
key=API_ABS_HUMID,
device_class=SensorDeviceClass.ABSOLUTE_HUMIDITY,
native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER,
unique_id_tag="absolute_humidity",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
)
SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = (

View File

@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==65"],
"requirements": ["axis==64"],
"ssdp": [
{
"manufacturer": "AXIS"

View File

@@ -127,6 +127,7 @@ class BackupConfigData:
schedule=BackupSchedule(
days=days,
recurrence=ScheduleRecurrence(data["schedule"]["recurrence"]),
state=ScheduleState(data["schedule"].get("state", ScheduleState.NEVER)),
time=time,
),
)
@@ -452,6 +453,7 @@ class StoredBackupSchedule(TypedDict):
days: list[Day]
recurrence: ScheduleRecurrence
state: ScheduleState
time: str | None
@@ -460,6 +462,7 @@ class ScheduleParametersDict(TypedDict, total=False):
days: list[Day]
recurrence: ScheduleRecurrence
state: ScheduleState
time: dt.time | None
@@ -483,12 +486,32 @@ class ScheduleRecurrence(StrEnum):
CUSTOM_DAYS = "custom_days"
class ScheduleState(StrEnum):
"""Represent the schedule recurrence.
This is deprecated and can be remove in HA Core 2025.8.
"""
NEVER = "never"
DAILY = "daily"
MONDAY = "mon"
TUESDAY = "tue"
WEDNESDAY = "wed"
THURSDAY = "thu"
FRIDAY = "fri"
SATURDAY = "sat"
SUNDAY = "sun"
@dataclass(kw_only=True)
class BackupSchedule:
"""Represent the backup schedule."""
days: list[Day] = field(default_factory=list)
recurrence: ScheduleRecurrence = ScheduleRecurrence.NEVER
# Although no longer used, state is kept for backwards compatibility.
# It can be removed in HA Core 2025.8.
state: ScheduleState = ScheduleState.NEVER
time: dt.time | None = None
cron_event: CronSim | None = field(init=False, default=None)
next_automatic_backup: datetime | None = field(init=False, default=None)
@@ -587,6 +610,7 @@ class BackupSchedule:
return StoredBackupSchedule(
days=self.days,
recurrence=self.recurrence,
state=self.state,
time=self.time.isoformat() if self.time else None,
)

View File

@@ -331,6 +331,9 @@ async def handle_config_info(
"""Send the stored backup config."""
manager = hass.data[DATA_MANAGER]
config = manager.config.data.to_dict()
# Remove state from schedule, it's not needed in the frontend
# mypy doesn't like deleting from TypedDict, ignore it
del config["schedule"]["state"] # type: ignore[misc]
connection.send_result(
msg["id"],
{

View File

@@ -25,6 +25,7 @@ SERVICE_TRIGGER = "trigger_camera"
SERVICE_SAVE_VIDEO = "save_video"
SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips"
SERVICE_SEND_PIN = "send_pin"
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
PLATFORMS = [
Platform.ALARM_CONTROL_PANEL,

View File

@@ -5,12 +5,12 @@ from __future__ import annotations
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_PIN
from homeassistant.const import CONF_PIN
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN, SERVICE_SEND_PIN
from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_SEND_PIN
from .coordinator import BlinkConfigEntry
SERVICE_SEND_PIN_SCHEMA = vol.Schema(

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/blue_current",
"iot_class": "cloud_push",
"loggers": ["bluecurrent_api"],
"requirements": ["bluecurrent-api==1.3.1"]
"requirements": ["bluecurrent-api==1.2.4"]
}

View File

@@ -388,6 +388,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE
scanner = HaScanner(mode, adapter, address)
scanner.async_setup()
try:
await scanner.async_start()
except (RuntimeError, ScannerStartError) as err:
raise ConfigEntryNotReady(
f"{adapter_human_name(adapter, address)}: {err}"
) from err
adapters = await manager.async_get_bluetooth_adapters()
details = adapters[adapter]
if entry.title == address:
@@ -395,16 +401,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, title=adapter_title(adapter, details)
)
slots: int = details.get(ADAPTER_CONNECTION_SLOTS) or DEFAULT_CONNECTION_SLOTS
# Register the scanner before starting so
# any raw advertisement data can be processed
entry.async_on_unload(async_register_scanner(hass, scanner, connection_slots=slots))
await async_update_device(hass, entry, adapter, details)
try:
await scanner.async_start()
except (RuntimeError, ScannerStartError) as err:
raise ConfigEntryNotReady(
f"{adapter_human_name(adapter, address)}: {err}"
) from err
entry.async_on_unload(entry.add_update_listener(async_update_listener))
entry.async_on_unload(scanner.async_stop)
return True

View File

@@ -235,9 +235,10 @@ class HomeAssistantBluetoothManager(BluetoothManager):
def _async_save_scanner_history(self, scanner: BaseHaScanner) -> None:
"""Save the scanner history."""
self.storage.async_set_advertisement_history(
scanner.source, scanner.serialize_discovered_devices()
)
if isinstance(scanner, BaseHaRemoteScanner):
self.storage.async_set_advertisement_history(
scanner.source, scanner.serialize_discovered_devices()
)
def _async_unregister_scanner(
self, scanner: BaseHaScanner, unregister: CALLBACK_TYPE
@@ -284,8 +285,9 @@ class HomeAssistantBluetoothManager(BluetoothManager):
connection_slots: int | None = None,
) -> CALLBACK_TYPE:
"""Register a scanner."""
if history := self.storage.async_get_advertisement_history(scanner.source):
scanner.restore_discovered_devices(history)
if isinstance(scanner, BaseHaRemoteScanner):
if history := self.storage.async_get_advertisement_history(scanner.source):
scanner.restore_discovered_devices(history)
unregister = super().async_register_scanner(scanner, connection_slots)
return partial(self._async_unregister_scanner, scanner, unregister)

View File

@@ -16,11 +16,11 @@
"quality_scale": "internal",
"requirements": [
"bleak==1.0.1",
"bleak-retry-connector==4.0.1",
"bleak-retry-connector==4.0.0",
"bluetooth-adapters==2.0.0",
"bluetooth-auto-recovery==1.5.2",
"bluetooth-data-tools==1.28.2",
"dbus-fast==2.44.3",
"habluetooth==5.0.1"
"dbus-fast==2.44.2",
"habluetooth==4.0.1"
]
}

View File

@@ -39,13 +39,7 @@ def async_setup(hass: HomeAssistant) -> None:
def serialize_service_info(
service_info: BluetoothServiceInfoBleak, time_diff: float
) -> dict[str, Any]:
"""Serialize a BluetoothServiceInfoBleak object.
The raw field is included for:
1. Debugging - to see the actual advertisement packet
2. Data freshness - manufacturer_data and service_data are aggregated
across multiple advertisements, raw shows the latest packet only
"""
"""Serialize a BluetoothServiceInfoBleak object."""
return {
"name": service_info.name,
"address": service_info.address,
@@ -63,7 +57,6 @@ def serialize_service_info(
"connectable": service_info.connectable,
"time": service_info.time + time_diff,
"tx_power": service_info.tx_power,
"raw": service_info.raw.hex() if service_info.raw else None,
}

View File

@@ -6,3 +6,4 @@ CONF_INSTALLER_CODE = "installer_code"
CONF_USER_CODE = "user_code"
ATTR_DATETIME = "datetime"
SERVICE_SET_DATE_TIME = "set_date_time"
ATTR_CONFIG_ENTRY_ID = "config_entry_id"

View File

@@ -9,13 +9,12 @@ from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util
from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME
from .const import ATTR_CONFIG_ENTRY_ID, ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME
from .types import BoschAlarmConfigEntry

View File

@@ -95,7 +95,7 @@
"name": "Battery missing"
},
"panel_fault_ac_fail": {
"name": "AC failure"
"name": "AC Failure"
},
"panel_fault_parameter_crc_fail_in_pif": {
"name": "CRC failure in panel configuration"

View File

@@ -69,7 +69,12 @@ class SHCEntity(SHCBaseEntity):
manufacturer=device.manufacturer,
model=device.device_model,
name=device.name,
via_device=(DOMAIN, device.root_device_id),
via_device=(
DOMAIN,
device.parent_device_id
if device.parent_device_id is not None
else parent_id,
),
)
super().__init__(device=device, parent_id=parent_id, entry_id=entry_id)

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/bosch_shc",
"iot_class": "local_push",
"loggers": ["boschshcpy"],
"requirements": ["boschshcpy==0.2.107"],
"requirements": ["boschshcpy==0.2.91"],
"zeroconf": [
{
"type": "_http._tcp.local.",

View File

@@ -53,7 +53,8 @@ async def async_setup_entry(
assert unique_id is not None
async_add_entities(
BraviaTVButton(coordinator, unique_id, description) for description in BUTTONS
BraviaTVButton(coordinator, unique_id, config_entry.title, description)
for description in BUTTONS
)
@@ -66,10 +67,11 @@ class BraviaTVButton(BraviaTVEntity, ButtonEntity):
self,
coordinator: BraviaTVCoordinator,
unique_id: str,
model: str,
description: BraviaTVButtonDescription,
) -> None:
"""Initialize the button."""
super().__init__(coordinator, unique_id)
super().__init__(coordinator, unique_id, model)
self._attr_unique_id = f"{unique_id}_{description.key}"
self.entity_description = description

View File

@@ -79,16 +79,14 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
system_info = await self.client.get_system_info()
cid = system_info[ATTR_CID].lower()
title = system_info[ATTR_MODEL]
self.device_config[CONF_MAC] = system_info[ATTR_MAC]
await self.async_set_unique_id(cid)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"{system_info['name']} {system_info[ATTR_MODEL]}",
data=self.device_config,
)
return self.async_create_entry(title=title, data=self.device_config)
async def async_reauth_device(self) -> ConfigFlowResult:
"""Reauthorize Bravia TV device from config."""

View File

@@ -81,7 +81,6 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]):
self.use_psk = config_entry.data.get(CONF_USE_PSK, False)
self.client_id = config_entry.data.get(CONF_CLIENT_ID, LEGACY_CLIENT_ID)
self.nickname = config_entry.data.get(CONF_NICKNAME, NICKNAME_PREFIX)
self.system_info: dict[str, str] = {}
self.source: str | None = None
self.source_list: list[str] = []
self.source_map: dict[str, dict] = {}
@@ -151,9 +150,6 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]):
self.is_on = power_status == "active"
self.skipped_updates = 0
if not self.system_info:
self.system_info = await self.client.get_system_info()
if self.is_on is False:
return

View File

@@ -12,16 +12,23 @@ class BraviaTVEntity(CoordinatorEntity[BraviaTVCoordinator]):
_attr_has_entity_name = True
def __init__(self, coordinator: BraviaTVCoordinator, unique_id: str) -> None:
def __init__(
self,
coordinator: BraviaTVCoordinator,
unique_id: str,
model: str,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
connections={(CONNECTION_NETWORK_MAC, coordinator.system_info["macAddr"])},
manufacturer=ATTR_MANUFACTURER,
model_id=coordinator.system_info["model"],
hw_version=coordinator.system_info["generation"],
serial_number=coordinator.system_info["serial"],
model=model,
name=f"{ATTR_MANUFACTURER} {model}",
)
if coordinator.client.mac is not None:
self._attr_device_info["connections"] = {
(CONNECTION_NETWORK_MAC, coordinator.client.mac)
}

View File

@@ -34,7 +34,9 @@ async def async_setup_entry(
unique_id = config_entry.unique_id
assert unique_id is not None
async_add_entities([BraviaTVMediaPlayer(coordinator, unique_id)])
async_add_entities(
[BraviaTVMediaPlayer(coordinator, unique_id, config_entry.title)]
)
class BraviaTVMediaPlayer(BraviaTVEntity, MediaPlayerEntity):

View File

@@ -24,7 +24,7 @@ async def async_setup_entry(
unique_id = config_entry.unique_id
assert unique_id is not None
async_add_entities([BraviaTVRemote(coordinator, unique_id)])
async_add_entities([BraviaTVRemote(coordinator, unique_id, config_entry.title)])
class BraviaTVRemote(BraviaTVEntity, RemoteEntity):

View File

@@ -64,7 +64,6 @@ class BroadlinkUpdateManager(ABC, Generic[_ApiT]):
device.hass,
_LOGGER,
name=f"{device.name} ({device.api.model} at {device.api.host[0]})",
config_entry=device.config,
update_method=self.async_update,
update_interval=self.SCAN_INTERVAL,
)

View File

@@ -2,16 +2,7 @@
import dataclasses
from bsblan import (
BSBLAN,
BSBLANAuthError,
BSBLANConfig,
BSBLANConnectionError,
BSBLANError,
Device,
Info,
StaticState,
)
from bsblan import BSBLAN, BSBLANConfig, Device, Info, StaticState
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -22,14 +13,9 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
ConfigEntryNotReady,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_PASSKEY, DOMAIN
from .const import CONF_PASSKEY
from .coordinator import BSBLanUpdateCoordinator
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
@@ -68,27 +54,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
coordinator = BSBLanUpdateCoordinator(hass, entry, bsblan)
await coordinator.async_config_entry_first_refresh()
try:
# Fetch all required data sequentially
device = await bsblan.device()
info = await bsblan.info()
static = await bsblan.static_values()
except BSBLANConnectionError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="setup_connection_error",
translation_placeholders={"host": entry.data[CONF_HOST]},
) from err
except BSBLANAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="setup_auth_error",
) from err
except BSBLANError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="setup_general_error",
) from err
# Fetch all required data concurrently
device = await bsblan.device()
info = await bsblan.info()
static = await bsblan.static_values()
entry.runtime_data = BSBLanData(
client=bsblan,

View File

@@ -2,10 +2,9 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from bsblan import BSBLAN, BSBLANAuthError, BSBLANConfig, BSBLANError
from bsblan import BSBLAN, BSBLANConfig, BSBLANError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -46,7 +45,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
self.username = user_input.get(CONF_USERNAME)
self.password = user_input.get(CONF_PASSWORD)
return await self._validate_and_create(user_input)
return await self._validate_and_create()
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
@@ -129,29 +128,14 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
self.username = user_input.get(CONF_USERNAME)
self.password = user_input.get(CONF_PASSWORD)
return await self._validate_and_create(user_input, is_discovery=True)
return await self._validate_and_create(is_discovery=True)
async def _validate_and_create(
self, user_input: dict[str, Any], is_discovery: bool = False
self, is_discovery: bool = False
) -> ConfigFlowResult:
"""Validate device connection and create entry."""
try:
await self._get_bsblan_info()
except BSBLANAuthError:
if is_discovery:
return self.async_show_form(
step_id="discovery_confirm",
data_schema=vol.Schema(
{
vol.Optional(CONF_PASSKEY): str,
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
}
),
errors={"base": "invalid_auth"},
description_placeholders={"host": str(self.host)},
)
return self._show_setup_form({"base": "invalid_auth"}, user_input)
await self._get_bsblan_info(is_discovery=is_discovery)
except BSBLANError:
if is_discovery:
return self.async_show_form(
@@ -170,137 +154,18 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
return self._async_create_entry()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauth flow."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth confirmation flow."""
existing_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
assert existing_entry
if user_input is None:
# Preserve existing values as defaults
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=existing_entry.data.get(
CONF_PASSKEY, vol.UNDEFINED
),
): str,
vol.Optional(
CONF_USERNAME,
default=existing_entry.data.get(
CONF_USERNAME, vol.UNDEFINED
),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
)
# Combine existing data with the user's new input for validation.
# This correctly handles adding, changing, and clearing credentials.
config_data = existing_entry.data.copy()
config_data.update(user_input)
self.host = config_data[CONF_HOST]
self.port = config_data[CONF_PORT]
self.passkey = config_data.get(CONF_PASSKEY)
self.username = config_data.get(CONF_USERNAME)
self.password = config_data.get(CONF_PASSWORD)
try:
await self._get_bsblan_info(raise_on_progress=False, is_reauth=True)
except BSBLANAuthError:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
): str,
vol.Optional(
CONF_USERNAME,
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
errors={"base": "invalid_auth"},
)
except BSBLANError:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Optional(
CONF_PASSKEY,
default=user_input.get(CONF_PASSKEY, vol.UNDEFINED),
): str,
vol.Optional(
CONF_USERNAME,
default=user_input.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=vol.UNDEFINED,
): str,
}
),
errors={"base": "cannot_connect"},
)
# Update only the fields that were provided by the user
return self.async_update_reload_and_abort(
existing_entry, data_updates=user_input, reason="reauth_successful"
)
@callback
def _show_setup_form(
self, errors: dict | None = None, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult:
"""Show the setup form to the user."""
# Preserve user input if provided, otherwise use defaults
defaults = user_input or {}
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_HOST, default=defaults.get(CONF_HOST, vol.UNDEFINED)
): str,
vol.Optional(
CONF_PORT, default=defaults.get(CONF_PORT, DEFAULT_PORT)
): int,
vol.Optional(
CONF_PASSKEY, default=defaults.get(CONF_PASSKEY, vol.UNDEFINED)
): str,
vol.Optional(
CONF_USERNAME,
default=defaults.get(CONF_USERNAME, vol.UNDEFINED),
): str,
vol.Optional(
CONF_PASSWORD,
default=defaults.get(CONF_PASSWORD, vol.UNDEFINED),
): str,
vol.Required(CONF_HOST): str,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
vol.Optional(CONF_PASSKEY): str,
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
}
),
errors=errors or {},
@@ -321,9 +186,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
)
async def _get_bsblan_info(
self,
raise_on_progress: bool = True,
is_reauth: bool = False,
self, raise_on_progress: bool = True, is_discovery: bool = False
) -> None:
"""Get device information from a BSBLAN device."""
config = BSBLANConfig(
@@ -346,13 +209,11 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
format_mac(self.mac), raise_on_progress=raise_on_progress
)
# Skip unique_id configuration check during reauth to prevent "already_configured" abort
if not is_reauth:
# Always allow updating host/port for both user and discovery flows
# This ensures connectivity is maintained when devices change IP addresses
self._abort_if_unique_id_configured(
updates={
CONF_HOST: self.host,
CONF_PORT: self.port,
}
)
# Always allow updating host/port for both user and discovery flows
# This ensures connectivity is maintained when devices change IP addresses
self._abort_if_unique_id_configured(
updates={
CONF_HOST: self.host,
CONF_PORT: self.port,
}
)

View File

@@ -4,19 +4,11 @@ from dataclasses import dataclass
from datetime import timedelta
from random import randint
from bsblan import (
BSBLAN,
BSBLANAuthError,
BSBLANConnectionError,
HotWaterState,
Sensor,
State,
)
from bsblan import BSBLAN, BSBLANConnectionError, HotWaterState, Sensor, State
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
@@ -70,10 +62,6 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]):
state = await self.client.state()
sensor = await self.client.sensor()
dhw = await self.client.hot_water_state()
except BSBLANAuthError as err:
raise ConfigEntryAuthFailed(
"Authentication failed for BSB-Lan device"
) from err
except BSBLANConnectionError as err:
host = self.config_entry.data[CONF_HOST] if self.config_entry else "unknown"
raise UpdateFailed(

View File

@@ -33,30 +33,14 @@
"username": "[%key:component::bsblan::config::step::user::data_description::username%]",
"password": "[%key:component::bsblan::config::step::user::data_description::password%]"
}
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The BSB-Lan integration needs to re-authenticate with {name}",
"data": {
"passkey": "[%key:component::bsblan::config::step::user::data::passkey%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"passkey": "[%key:component::bsblan::config::step::user::data_description::passkey%]",
"username": "[%key:component::bsblan::config::step::user::data_description::username%]",
"password": "[%key:component::bsblan::config::step::user::data_description::password%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
}
},
"exceptions": {
@@ -71,15 +55,6 @@
},
"set_operation_mode_error": {
"message": "An error occurred while setting the operation mode"
},
"setup_connection_error": {
"message": "Failed to retrieve static device data from BSB-Lan device at {host}"
},
"setup_auth_error": {
"message": "Authentication failed while retrieving static device data"
},
"setup_general_error": {
"message": "An unknown error occurred while retrieving static device data"
}
},
"entity": {

View File

@@ -25,7 +25,7 @@
"services": {
"press": {
"name": "Press",
"description": "Presses a button entity."
"description": "Press the button entity."
}
}
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/caldav",
"iot_class": "cloud_polling",
"loggers": ["caldav", "vobject"],
"requirements": ["caldav==1.6.0", "icalendar==6.3.1"]
"requirements": ["caldav==1.6.0", "icalendar==6.1.0"]
}

View File

@@ -255,7 +255,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
)
entity_description: ClimateEntityDescription
_attr_current_humidity: float | None = None
_attr_current_humidity: int | None = None
_attr_current_temperature: float | None = None
_attr_fan_mode: str | None
_attr_fan_modes: list[str] | None

View File

@@ -100,10 +100,16 @@ set_hvac_mode:
fields:
hvac_mode:
selector:
state:
hide_states:
- unavailable
- unknown
select:
options:
- "off"
- "auto"
- "cool"
- "dry"
- "fan_only"
- "heat_cool"
- "heat"
translation_key: hvac_mode
set_swing_mode:
target:
entity:

View File

@@ -6,16 +6,12 @@ import asyncio
from collections.abc import Callable
from contextlib import suppress
from datetime import datetime, timedelta
from http import HTTPStatus
import logging
from typing import TYPE_CHECKING, Any
import aiohttp
from hass_nabucasa import AlexaApiError, Cloud
from hass_nabucasa.alexa_api import (
AlexaAccessTokenDetails,
AlexaApiNeedsRelinkError,
AlexaApiNoTokenError,
)
from hass_nabucasa import Cloud, cloud_api
from yarl import URL
from homeassistant.components import persistent_notification
@@ -150,7 +146,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
self._cloud_user = cloud_user
self._prefs = prefs
self._cloud = cloud
self._token: str | None = None
self._token = None
self._token_valid: datetime | None = None
self._cur_entity_prefs = async_get_assistant_settings(hass, CLOUD_ALEXA)
self._alexa_sync_unsub: Callable[[], None] | None = None
@@ -322,31 +318,32 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
async def async_get_access_token(self) -> str | None:
"""Get an access token."""
details: AlexaAccessTokenDetails | None
if self._token_valid is not None and self._token_valid > utcnow():
return self._token
try:
details = await self._cloud.alexa_api.access_token()
except AlexaApiNeedsRelinkError as exception:
if self.should_report_state:
persistent_notification.async_create(
self.hass,
(
"There was an error reporting state to Alexa"
f" ({exception.reason}). Please re-link your Alexa skill via"
" the Alexa app to continue using it."
),
"Alexa state reporting disabled",
"cloud_alexa_report",
)
raise alexa_errors.RequireRelink from exception
except (AlexaApiNoTokenError, AlexaApiError) as exception:
raise alexa_errors.NoTokenAvailable from exception
resp = await cloud_api.async_alexa_access_token(self._cloud)
body = await resp.json()
self._token = details["access_token"]
self._endpoint = details["event_endpoint"]
self._token_valid = utcnow() + timedelta(seconds=details["expires_in"])
if resp.status == HTTPStatus.BAD_REQUEST:
if body["reason"] in ("RefreshTokenNotFound", "UnknownRegion"):
if self.should_report_state:
persistent_notification.async_create(
self.hass,
(
"There was an error reporting state to Alexa"
f" ({body['reason']}). Please re-link your Alexa skill via"
" the Alexa app to continue using it."
),
"Alexa state reporting disabled",
"cloud_alexa_report",
)
raise alexa_errors.RequireRelink
raise alexa_errors.NoTokenAvailable
self._token = body["access_token"]
self._endpoint = body["event_endpoint"]
self._token_valid = utcnow() + timedelta(seconds=body["expires_in"])
return self._token
async def _async_prefs_updated(self, prefs: CloudPreferences) -> None:

View File

@@ -7,7 +7,7 @@ from http import HTTPStatus
import logging
from typing import TYPE_CHECKING, Any
from hass_nabucasa import Cloud
from hass_nabucasa import Cloud, cloud_api
from hass_nabucasa.google_report_state import ErrorResponse
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
@@ -377,7 +377,7 @@ class CloudGoogleConfig(AbstractConfig):
return HTTPStatus.OK
async with self._sync_entities_lock:
resp = await self._cloud.google_report_state.request_sync()
resp = await cloud_api.async_google_actions_request_sync(self._cloud)
return resp.status
async def async_connect_agent_user(self, agent_user_id: str) -> None:

View File

@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==1.0.0"],
"requirements": ["hass-nabucasa==0.110.0"],
"single_config_entry": true
}

View File

@@ -4,13 +4,11 @@ from __future__ import annotations
import asyncio
import logging
from typing import Any
from hass_nabucasa import (
Cloud,
MigratePaypalAgreementInfo,
PaymentsApiError,
SubscriptionInfo,
)
from aiohttp.client_exceptions import ClientError
from hass_nabucasa import Cloud, cloud_api
from hass_nabucasa.payments_api import PaymentsApiError, SubscriptionInfo
from .client import CloudClient
from .const import REQUEST_TIMEOUT
@@ -31,17 +29,17 @@ async def async_subscription_info(cloud: Cloud[CloudClient]) -> SubscriptionInfo
async def async_migrate_paypal_agreement(
cloud: Cloud[CloudClient],
) -> MigratePaypalAgreementInfo | None:
) -> dict[str, Any] | None:
"""Migrate a paypal agreement from legacy."""
try:
async with asyncio.timeout(REQUEST_TIMEOUT):
return await cloud.payments.migrate_paypal_agreement()
return await cloud_api.async_migrate_paypal_agreement(cloud)
except TimeoutError:
_LOGGER.error(
"A timeout of %s was reached while trying to start agreement migration",
REQUEST_TIMEOUT,
)
except PaymentsApiError as exception:
except ClientError as exception:
_LOGGER.error("Failed to start agreement migration - %s", exception)
return None

View File

@@ -7,18 +7,22 @@ import logging
from coinbase.rest import RESTClient
from coinbase.rest.rest_base import HTTPError
from coinbase.wallet.client import Client as LegacyClient
from coinbase.wallet.error import AuthenticationError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import entity_registry as er
from homeassistant.util import Throttle
from .const import (
ACCOUNT_IS_VAULT,
API_ACCOUNT_AMOUNT,
API_ACCOUNT_AVALIABLE,
API_ACCOUNT_BALANCE,
API_ACCOUNT_CURRENCY,
API_ACCOUNT_CURRENCY_CODE,
API_ACCOUNT_HOLD,
API_ACCOUNT_ID,
API_ACCOUNT_NAME,
@@ -27,9 +31,12 @@ from .const import (
API_DATA,
API_RATES_CURRENCY,
API_RESOURCE_TYPE,
API_TYPE_VAULT,
API_V3_ACCOUNT_ID,
API_V3_TYPE_VAULT,
CONF_CURRENCIES,
CONF_EXCHANGE_BASE,
CONF_EXCHANGE_RATES,
)
_LOGGER = logging.getLogger(__name__)
@@ -44,6 +51,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoinbaseConfigEntry) ->
"""Set up Coinbase from a config entry."""
instance = await hass.async_add_executor_job(create_and_update_instance, entry)
entry.async_on_unload(entry.add_update_listener(update_listener))
entry.runtime_data = instance
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -58,28 +68,68 @@ async def async_unload_entry(hass: HomeAssistant, entry: CoinbaseConfigEntry) ->
def create_and_update_instance(entry: CoinbaseConfigEntry) -> CoinbaseData:
"""Create and update a Coinbase Data instance."""
# Check if user is using deprecated v2 API credentials
if "organizations" not in entry.data[CONF_API_KEY]:
# Trigger reauthentication to ask user for v3 credentials
raise ConfigEntryAuthFailed(
"Your Coinbase API key appears to be for the deprecated v2 API. "
"Please reconfigure with a new API key created for the v3 API. "
"Visit https://www.coinbase.com/developer-platform to create new credentials."
client = LegacyClient(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN])
version = "v2"
else:
client = RESTClient(
api_key=entry.data[CONF_API_KEY], api_secret=entry.data[CONF_API_TOKEN]
)
client = RESTClient(
api_key=entry.data[CONF_API_KEY], api_secret=entry.data[CONF_API_TOKEN]
)
version = "v3"
base_rate = entry.options.get(CONF_EXCHANGE_BASE, "USD")
instance = CoinbaseData(client, base_rate)
instance = CoinbaseData(client, base_rate, version)
instance.update()
return instance
def get_accounts(client):
async def update_listener(
hass: HomeAssistant, config_entry: CoinbaseConfigEntry
) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(config_entry.entry_id)
registry = er.async_get(hass)
entities = er.async_entries_for_config_entry(registry, config_entry.entry_id)
# Remove orphaned entities
for entity in entities:
currency = entity.unique_id.split("-")[-1]
if (
"xe" in entity.unique_id
and currency not in config_entry.options.get(CONF_EXCHANGE_RATES, [])
) or (
"wallet" in entity.unique_id
and currency not in config_entry.options.get(CONF_CURRENCIES, [])
):
registry.async_remove(entity.entity_id)
def get_accounts(client, version):
"""Handle paginated accounts."""
response = client.get_accounts()
if version == "v2":
accounts = response[API_DATA]
next_starting_after = response.pagination.next_starting_after
while next_starting_after:
response = client.get_accounts(starting_after=next_starting_after)
accounts += response[API_DATA]
next_starting_after = response.pagination.next_starting_after
return [
{
API_ACCOUNT_ID: account[API_ACCOUNT_ID],
API_ACCOUNT_NAME: account[API_ACCOUNT_NAME],
API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY][
API_ACCOUNT_CURRENCY_CODE
],
API_ACCOUNT_AMOUNT: account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT],
ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_TYPE_VAULT,
}
for account in accounts
]
accounts = response[API_ACCOUNTS]
while response["has_next"]:
response = client.get_accounts(cursor=response["cursor"])
@@ -103,28 +153,37 @@ def get_accounts(client):
class CoinbaseData:
"""Get the latest data and update the states."""
def __init__(self, client, exchange_base):
def __init__(self, client, exchange_base, version):
"""Init the coinbase data object."""
self.client = client
self.accounts = None
self.exchange_base = exchange_base
self.exchange_rates = None
self.user_id = (
"v3_" + client.get_portfolios()["portfolios"][0][API_V3_ACCOUNT_ID]
)
if version == "v2":
self.user_id = self.client.get_current_user()[API_ACCOUNT_ID]
else:
self.user_id = (
"v3_" + client.get_portfolios()["portfolios"][0][API_V3_ACCOUNT_ID]
)
self.api_version = version
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Get the latest data from coinbase."""
try:
self.accounts = get_accounts(self.client)
self.exchange_rates = self.client.get(
"/v2/exchange-rates",
params={API_RATES_CURRENCY: self.exchange_base},
)[API_DATA]
except HTTPError as coinbase_error:
self.accounts = get_accounts(self.client, self.api_version)
if self.api_version == "v2":
self.exchange_rates = self.client.get_exchange_rates(
currency=self.exchange_base
)
else:
self.exchange_rates = self.client.get(
"/v2/exchange-rates",
params={API_RATES_CURRENCY: self.exchange_base},
)[API_DATA]
except (AuthenticationError, HTTPError) as coinbase_error:
_LOGGER.error(
"Authentication error connecting to coinbase: %s", coinbase_error
)

View File

@@ -2,20 +2,17 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from coinbase.rest import RESTClient
from coinbase.rest.rest_base import HTTPError
from coinbase.wallet.client import Client as LegacyClient
from coinbase.wallet.error import AuthenticationError
import voluptuous as vol
from homeassistant.config_entries import (
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
)
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
@@ -48,6 +45,9 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
def get_user_from_client(api_key, api_token):
"""Get the user name from Coinbase API credentials."""
if "organizations" not in api_key:
client = LegacyClient(api_key, api_token)
return client.get_current_user()["name"]
client = RESTClient(api_key=api_key, api_secret=api_token)
return client.get_portfolios()["portfolios"][0]["name"]
@@ -59,7 +59,7 @@ async def validate_api(hass: HomeAssistant, data):
user = await hass.async_add_executor_job(
get_user_from_client, data[CONF_API_KEY], data[CONF_API_TOKEN]
)
except HTTPError as error:
except (AuthenticationError, HTTPError) as error:
if "api key" in str(error) or " 401 Client Error" in str(error):
_LOGGER.debug("Coinbase rejected API credentials due to an invalid API key")
raise InvalidKey from error
@@ -74,8 +74,8 @@ async def validate_api(hass: HomeAssistant, data):
raise InvalidAuth from error
except ConnectionError as error:
raise CannotConnect from error
return {"title": user}
api_version = "v3" if "organizations" in data[CONF_API_KEY] else "v2"
return {"title": user, "api_version": api_version}
async def validate_options(
@@ -85,17 +85,20 @@ async def validate_options(
client = config_entry.runtime_data.client
accounts = await hass.async_add_executor_job(get_accounts, client)
accounts = await hass.async_add_executor_job(
get_accounts, client, config_entry.data.get("api_version", "v2")
)
accounts_currencies = [
account[API_ACCOUNT_CURRENCY]
for account in accounts
if not account[ACCOUNT_IS_VAULT]
]
resp = await hass.async_add_executor_job(client.get, "/v2/exchange-rates")
available_rates = resp[API_DATA]
if config_entry.data.get("api_version", "v2") == "v2":
available_rates = await hass.async_add_executor_job(client.get_exchange_rates)
else:
resp = await hass.async_add_executor_job(client.get, "/v2/exchange-rates")
available_rates = resp[API_DATA]
if CONF_CURRENCIES in options:
for currency in options[CONF_CURRENCIES]:
if currency not in accounts_currencies:
@@ -114,8 +117,6 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
reauth_entry: CoinbaseConfigEntry
async def async_step_user(
self, user_input: dict[str, str] | None = None
) -> ConfigFlowResult:
@@ -142,63 +143,12 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
user_input[CONF_API_VERSION] = info["api_version"]
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication flow."""
self.reauth_entry = self._get_reauth_entry()
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, str] | None = None
) -> ConfigFlowResult:
"""Handle reauthentication confirmation."""
errors: dict[str, str] = {}
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_USER_DATA_SCHEMA,
description_placeholders={
"account_name": self.reauth_entry.title,
},
errors=errors,
)
try:
await validate_api(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidKey:
errors["base"] = "invalid_auth_key"
except InvalidSecret:
errors["base"] = "invalid_auth_secret"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
self.reauth_entry,
data_updates=user_input,
reason="reauth_successful",
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_USER_DATA_SCHEMA,
description_placeholders={
"account_name": self.reauth_entry.title,
},
errors=errors,
)
@staticmethod
@callback
def async_get_options_flow(
@@ -208,7 +158,7 @@ class CoinbaseConfigFlow(ConfigFlow, domain=DOMAIN):
return OptionsFlowHandler()
class OptionsFlowHandler(OptionsFlowWithReload):
class OptionsFlowHandler(OptionsFlow):
"""Handle a option flow for Coinbase."""
async def async_step_init(

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/coinbase",
"iot_class": "cloud_polling",
"loggers": ["coinbase"],
"requirements": ["coinbase-advanced-py==1.2.2"]
"requirements": ["coinbase==2.1.0", "coinbase-advanced-py==1.2.2"]
}

View File

@@ -6,7 +6,6 @@ import logging
from homeassistant.components.sensor import SensorEntity, SensorStateClass
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -28,6 +27,7 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
ATTR_NATIVE_BALANCE = "Balance in native currency"
ATTR_API_VERSION = "API Version"
CURRENCY_ICONS = {
"BTC": "mdi:currency-btc",
@@ -69,26 +69,11 @@ async def async_setup_entry(
CONF_EXCHANGE_PRECISION, CONF_EXCHANGE_PRECISION_DEFAULT
)
# Remove orphaned entities
registry = er.async_get(hass)
existing_entities = er.async_entries_for_config_entry(
registry, config_entry.entry_id
)
for entity in existing_entities:
currency = entity.unique_id.split("-")[-1]
if (
"xe" in entity.unique_id
and currency not in config_entry.options.get(CONF_EXCHANGE_RATES, [])
) or (
"wallet" in entity.unique_id
and currency not in config_entry.options.get(CONF_CURRENCIES, [])
):
registry.async_remove(entity.entity_id)
for currency in desired_currencies:
_LOGGER.debug(
"Attempting to set up %s account sensor",
"Attempting to set up %s account sensor with %s API",
currency,
instance.api_version,
)
if currency not in provided_currencies:
_LOGGER.warning(
@@ -104,8 +89,9 @@ async def async_setup_entry(
if CONF_EXCHANGE_RATES in config_entry.options:
for rate in config_entry.options[CONF_EXCHANGE_RATES]:
_LOGGER.debug(
"Attempting to set up %s exchange rate sensor",
"Attempting to set up %s account sensor with %s API",
rate,
instance.api_version,
)
entities.append(
ExchangeRateSensor(
@@ -160,13 +146,15 @@ class AccountSensor(SensorEntity):
"""Return the state attributes of the sensor."""
return {
ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._coinbase_data.exchange_base}",
ATTR_API_VERSION: self._coinbase_data.api_version,
}
def update(self) -> None:
"""Get the latest state of the sensor."""
_LOGGER.debug(
"Updating %s account sensor",
"Updating %s account sensor with %s API",
self._currency,
self._coinbase_data.api_version,
)
self._coinbase_data.update()
for account in self._coinbase_data.accounts:
@@ -222,8 +210,9 @@ class ExchangeRateSensor(SensorEntity):
def update(self) -> None:
"""Get the latest state of the sensor."""
_LOGGER.debug(
"Updating %s rate sensor",
"Updating %s rate sensor with %s API",
self._currency,
self._coinbase_data.api_version,
)
self._coinbase_data.update()
self._attr_native_value = round(

View File

@@ -8,14 +8,6 @@
"api_key": "[%key:common::config_flow::data::api_key%]",
"api_token": "API secret"
}
},
"reauth_confirm": {
"title": "Update Coinbase API credentials",
"description": "Your current Coinbase API key appears to be for the deprecated v2 API. Please reconfigure with a new API key created for the v3 API. Visit https://www.coinbase.com/developer-platform to create new credentials for {account_name}.",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"api_token": "API secret"
}
}
},
"error": {
@@ -26,8 +18,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "Successfully updated credentials"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"options": {

View File

@@ -146,9 +146,8 @@ def _prepare_config_flow_result_json(
return prepare_result_json(result)
data = result.copy()
entry: config_entries.ConfigEntry = data["result"] # type: ignore[typeddict-item]
# We overwrite the ConfigEntry object with its json representation.
data["result"] = entry.as_json_fragment # type: ignore[typeddict-unknown-key]
entry: config_entries.ConfigEntry = data["result"]
data["result"] = entry.as_json_fragment
data.pop("data")
data.pop("context")
return data

View File

@@ -40,7 +40,6 @@ from .chat_log import (
ConverseError,
SystemContent,
ToolResultContent,
ToolResultContentDeltaDict,
UserContent,
async_get_chat_log,
)
@@ -80,7 +79,6 @@ __all__ = [
"ConverseError",
"SystemContent",
"ToolResultContent",
"ToolResultContentDeltaDict",
"UserContent",
"async_conversation_trace_append",
"async_converse",
@@ -119,7 +117,7 @@ CONFIG_SCHEMA = vol.Schema(
{cv.string: vol.All(cv.ensure_list, [cv.string])}
)
}
),
)
},
extra=vol.ALLOW_EXTRA,
)
@@ -270,9 +268,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass)
hass.data[DATA_COMPONENT] = entity_component
agent_config = config.get(DOMAIN, {})
await async_setup_default_agent(
hass, entity_component, config_intents=agent_config.get("intents", {})
hass, entity_component, config.get(DOMAIN, {}).get("intents", {})
)
async def handle_process(service: ServiceCall) -> ServiceResponse:

View File

@@ -9,7 +9,7 @@ from contextvars import ContextVar
from dataclasses import asdict, dataclass, field, replace
import logging
from pathlib import Path
from typing import Any, Literal, TypedDict, cast
from typing import Any, Literal, TypedDict
import voluptuous as vol
@@ -161,9 +161,7 @@ class AssistantContent:
role: Literal["assistant"] = field(init=False, default="assistant")
agent_id: str
content: str | None = None
thinking_content: str | None = None
tool_calls: list[llm.ToolInput] | None = None
native: Any = None
@dataclass(frozen=True)
@@ -185,18 +183,7 @@ class AssistantContentDeltaDict(TypedDict, total=False):
role: Literal["assistant"]
content: str | None
thinking_content: str | None
tool_calls: list[llm.ToolInput] | None
native: Any
class ToolResultContentDeltaDict(TypedDict, total=False):
"""Tool result content."""
role: Literal["tool_result"]
tool_call_id: str
tool_name: str
tool_result: JsonObjectType
@dataclass
@@ -244,25 +231,17 @@ class ChatLog:
@callback
def async_add_assistant_content_without_tools(
self, content: AssistantContent | ToolResultContent
self, content: AssistantContent
) -> None:
"""Add assistant content to the log.
Allows assistant content without tool calls or with external tool calls only,
as well as tool results for the external tools.
"""
"""Add assistant content to the log."""
LOGGER.debug("Adding assistant content: %s", content)
if (
isinstance(content, AssistantContent)
and content.tool_calls is not None
and any(not tool_call.external for tool_call in content.tool_calls)
):
raise ValueError("Non-external tool calls not allowed")
if content.tool_calls is not None:
raise ValueError("Tool calls not allowed")
self.content.append(content)
async def async_add_assistant_content(
self,
content: AssistantContent | ToolResultContent,
content: AssistantContent,
/,
tool_call_tasks: dict[str, asyncio.Task] | None = None,
) -> AsyncGenerator[ToolResultContent]:
@@ -275,11 +254,7 @@ class ChatLog:
LOGGER.debug("Adding assistant content: %s", content)
self.content.append(content)
if (
not isinstance(content, AssistantContent)
or content.tool_calls is None
or all(tool_call.external for tool_call in content.tool_calls)
):
if content.tool_calls is None:
return
if self.llm_api is None:
@@ -288,16 +263,13 @@ class ChatLog:
if tool_call_tasks is None:
tool_call_tasks = {}
for tool_input in content.tool_calls:
if tool_input.id not in tool_call_tasks and not tool_input.external:
if tool_input.id not in tool_call_tasks:
tool_call_tasks[tool_input.id] = self.hass.async_create_task(
self.llm_api.async_call_tool(tool_input),
name=f"llm_tool_{tool_input.id}",
)
for tool_input in content.tool_calls:
if tool_input.external:
continue
LOGGER.debug(
"Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args
)
@@ -320,9 +292,7 @@ class ChatLog:
yield response_content
async def async_add_delta_content_stream(
self,
agent_id: str,
stream: AsyncIterable[AssistantContentDeltaDict | ToolResultContentDeltaDict],
self, agent_id: str, stream: AsyncIterable[AssistantContentDeltaDict]
) -> AsyncGenerator[AssistantContent | ToolResultContent]:
"""Stream content into the chat log.
@@ -336,8 +306,6 @@ class ChatLog:
The keys content and tool_calls will be concatenated if they appear multiple times.
"""
current_content = ""
current_thinking_content = ""
current_native: Any = None
current_tool_calls: list[llm.ToolInput] = []
tool_call_tasks: dict[str, asyncio.Task] = {}
@@ -346,54 +314,34 @@ class ChatLog:
# Indicates update to current message
if "role" not in delta:
# ToolResultContentDeltaDict will always have a role
assistant_delta = cast(AssistantContentDeltaDict, delta)
if delta_content := assistant_delta.get("content"):
if delta_content := delta.get("content"):
current_content += delta_content
if delta_thinking_content := assistant_delta.get("thinking_content"):
current_thinking_content += delta_thinking_content
if delta_native := assistant_delta.get("native"):
if current_native is not None:
raise RuntimeError(
"Native content already set, cannot overwrite"
)
current_native = delta_native
if delta_tool_calls := assistant_delta.get("tool_calls"):
if delta_tool_calls := delta.get("tool_calls"):
if self.llm_api is None:
raise ValueError("No LLM API configured")
current_tool_calls += delta_tool_calls
# Start processing the tool calls as soon as we know about them
for tool_call in delta_tool_calls:
if not tool_call.external:
if self.llm_api is None:
raise ValueError("No LLM API configured")
tool_call_tasks[tool_call.id] = self.hass.async_create_task(
self.llm_api.async_call_tool(tool_call),
name=f"llm_tool_{tool_call.id}",
)
tool_call_tasks[tool_call.id] = self.hass.async_create_task(
self.llm_api.async_call_tool(tool_call),
name=f"llm_tool_{tool_call.id}",
)
if self.delta_listener:
if filtered_delta := {
k: v for k, v in assistant_delta.items() if k != "native"
}:
# We do not want to send the native content to the listener
# as it is not JSON serializable
self.delta_listener(self, filtered_delta)
self.delta_listener(self, delta) # type: ignore[arg-type]
continue
# Starting a new message
if delta["role"] != "assistant":
raise ValueError(f"Only assistant role expected. Got {delta['role']}")
# Yield the previous message if it has content
if (
current_content
or current_thinking_content
or current_tool_calls
or current_native
):
content: AssistantContent | ToolResultContent = AssistantContent(
if current_content or current_tool_calls:
content = AssistantContent(
agent_id=agent_id,
content=current_content or None,
thinking_content=current_thinking_content or None,
tool_calls=current_tool_calls or None,
native=current_native,
)
yield content
async for tool_result in self.async_add_assistant_content(
@@ -402,51 +350,18 @@ class ChatLog:
yield tool_result
if self.delta_listener:
self.delta_listener(self, asdict(tool_result))
current_content = ""
current_thinking_content = ""
current_native = None
current_tool_calls = []
if delta["role"] == "assistant":
current_content = delta.get("content") or ""
current_thinking_content = delta.get("thinking_content") or ""
current_tool_calls = delta.get("tool_calls") or []
current_native = delta.get("native")
current_content = delta.get("content") or ""
current_tool_calls = delta.get("tool_calls") or []
if self.delta_listener:
if filtered_delta := {
k: v for k, v in delta.items() if k != "native"
}:
self.delta_listener(self, filtered_delta)
elif delta["role"] == "tool_result":
content = ToolResultContent(
agent_id=agent_id,
tool_call_id=delta["tool_call_id"],
tool_name=delta["tool_name"],
tool_result=delta["tool_result"],
)
yield content
if self.delta_listener:
self.delta_listener(self, asdict(content))
self.async_add_assistant_content_without_tools(content)
else:
raise ValueError(
"Only assistant and tool_result roles expected."
f" Got {delta['role']}"
)
if self.delta_listener:
self.delta_listener(self, delta) # type: ignore[arg-type]
if (
current_content
or current_thinking_content
or current_tool_calls
or current_native
):
if current_content or current_tool_calls:
content = AssistantContent(
agent_id=agent_id,
content=current_content or None,
thinking_content=current_thinking_content or None,
tool_calls=current_tool_calls or None,
native=current_native,
)
yield content
async for tool_result in self.async_add_assistant_content(

View File

@@ -14,19 +14,14 @@ import re
import time
from typing import IO, Any, cast
from hassil.expression import Expression, Group, ListReference, TextChunk
from hassil.fuzzy import FuzzyNgramMatcher, SlotCombinationInfo
from hassil.expression import Expression, ListReference, Sequence, TextChunk
from hassil.intents import (
Intent,
IntentData,
Intents,
SlotList,
TextSlotList,
TextSlotValue,
WildcardSlotList,
)
from hassil.models import MatchEntity
from hassil.ngram import Sqlite3NgramModel
from hassil.recognize import (
MISSING_ENTITY,
RecognizeResult,
@@ -36,15 +31,7 @@ from hassil.recognize import (
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
from hassil.trie import Trie
from hassil.util import merge_dict
from home_assistant_intents import (
ErrorKey,
FuzzyConfig,
FuzzyLanguageResponses,
get_fuzzy_config,
get_fuzzy_language,
get_intents,
get_languages,
)
from home_assistant_intents import ErrorKey, get_intents, get_languages
import yaml
from homeassistant import core
@@ -89,7 +76,6 @@ TRIGGER_CALLBACK_TYPE = Callable[
]
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"
METADATA_FUZZY_MATCH = "hass_fuzzy_match"
ERROR_SENTINEL = object()
@@ -108,8 +94,6 @@ class LanguageIntents:
intent_responses: dict[str, Any]
error_responses: dict[str, Any]
language_variant: str | None
fuzzy_matcher: FuzzyNgramMatcher | None = None
fuzzy_responses: FuzzyLanguageResponses | None = None
@dataclass(slots=True)
@@ -135,13 +119,10 @@ class IntentMatchingStage(Enum):
EXPOSED_ENTITIES_ONLY = auto()
"""Match against exposed entities only."""
FUZZY = auto()
"""Use fuzzy matching to guess intent."""
UNEXPOSED_ENTITIES = auto()
"""Match against unexposed entities in Home Assistant."""
UNKNOWN_NAMES = auto()
FUZZY = auto()
"""Capture names that are not known to Home Assistant."""
@@ -260,10 +241,6 @@ class DefaultAgent(ConversationEntity):
# LRU cache to avoid unnecessary intent matching
self._intent_cache = IntentCache(capacity=128)
# Shared configuration for fuzzy matching
self.fuzzy_matching = True
self._fuzzy_config: FuzzyConfig | None = None
@property
def supported_languages(self) -> list[str]:
"""Return a list of supported languages."""
@@ -322,7 +299,7 @@ class DefaultAgent(ConversationEntity):
_LOGGER.warning("No intents were loaded for language: %s", language)
return None
slot_lists = await self._make_slot_lists()
slot_lists = self._make_slot_lists()
intent_context = self._make_intent_context(user_input)
if self._exposed_names_trie is not None:
@@ -579,36 +556,6 @@ class DefaultAgent(ConversationEntity):
# Don't try matching against all entities or doing a fuzzy match
return None
# Use fuzzy matching
skip_fuzzy_match = False
if cache_value is not None:
if (cache_value.result is not None) and (
cache_value.stage == IntentMatchingStage.FUZZY
):
_LOGGER.debug("Got cached result for fuzzy match")
return cache_value.result
# Continue with matching, but we know we won't succeed for fuzzy
# match.
skip_fuzzy_match = True
if (not skip_fuzzy_match) and self.fuzzy_matching:
start_time = time.monotonic()
fuzzy_result = self._recognize_fuzzy(lang_intents, user_input)
# Update cache
self._intent_cache.put(
cache_key,
IntentCacheValue(result=fuzzy_result, stage=IntentMatchingStage.FUZZY),
)
_LOGGER.debug(
"Did fuzzy match in %s second(s)", time.monotonic() - start_time
)
if fuzzy_result is not None:
return fuzzy_result
# Try again with all entities (including unexposed)
skip_unexposed_entities_match = False
if cache_value is not None:
@@ -654,160 +601,102 @@ class DefaultAgent(ConversationEntity):
# This should fail the intent handling phase (async_match_targets).
return strict_result
# Check unknown names
skip_unknown_names = False
# Try again with missing entities enabled
skip_fuzzy_match = False
if cache_value is not None:
if (cache_value.result is not None) and (
cache_value.stage == IntentMatchingStage.UNKNOWN_NAMES
cache_value.stage == IntentMatchingStage.FUZZY
):
_LOGGER.debug("Got cached result for unknown names")
_LOGGER.debug("Got cached result for fuzzy match")
return cache_value.result
skip_unknown_names = True
# We know we won't succeed for fuzzy matching.
skip_fuzzy_match = True
maybe_result: RecognizeResult | None = None
if not skip_unknown_names:
if not skip_fuzzy_match:
start_time = time.monotonic()
maybe_result = self._recognize_unknown_names(
lang_intents, user_input, slot_lists, intent_context
)
best_num_matched_entities = 0
best_num_unmatched_entities = 0
best_num_unmatched_ranges = 0
for result in recognize_all(
user_input.text,
lang_intents.intents,
slot_lists=slot_lists,
intent_context=intent_context,
allow_unmatched_entities=True,
):
if result.text_chunks_matched < 1:
# Skip results that don't match any literal text
continue
# Don't count missing entities that couldn't be filled from context
num_matched_entities = 0
for matched_entity in result.entities_list:
if matched_entity.name not in result.unmatched_entities:
num_matched_entities += 1
num_unmatched_entities = 0
num_unmatched_ranges = 0
for unmatched_entity in result.unmatched_entities_list:
if isinstance(unmatched_entity, UnmatchedTextEntity):
if unmatched_entity.text != MISSING_ENTITY:
num_unmatched_entities += 1
elif isinstance(unmatched_entity, UnmatchedRangeEntity):
num_unmatched_ranges += 1
num_unmatched_entities += 1
else:
num_unmatched_entities += 1
if (
(maybe_result is None) # first result
or (
# More literal text matched
result.text_chunks_matched > maybe_result.text_chunks_matched
)
or (
# More entities matched
num_matched_entities > best_num_matched_entities
)
or (
# Fewer unmatched entities
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities < best_num_unmatched_entities)
)
or (
# Prefer unmatched ranges
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges > best_num_unmatched_ranges)
)
or (
# Prefer match failures with entities
(result.text_chunks_matched == maybe_result.text_chunks_matched)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges == best_num_unmatched_ranges)
and (
("name" in result.entities)
or ("name" in result.unmatched_entities)
)
)
):
maybe_result = result
best_num_matched_entities = num_matched_entities
best_num_unmatched_entities = num_unmatched_entities
best_num_unmatched_ranges = num_unmatched_ranges
# Update cache
self._intent_cache.put(
cache_key,
IntentCacheValue(
result=maybe_result, stage=IntentMatchingStage.UNKNOWN_NAMES
),
IntentCacheValue(result=maybe_result, stage=IntentMatchingStage.FUZZY),
)
_LOGGER.debug(
"Did unknown names match in %s second(s)", time.monotonic() - start_time
"Did fuzzy match in %s second(s)", time.monotonic() - start_time
)
return maybe_result
def _recognize_fuzzy(
self, lang_intents: LanguageIntents, user_input: ConversationInput
) -> RecognizeResult | None:
"""Return fuzzy recognition from hassil."""
if lang_intents.fuzzy_matcher is None:
return None
fuzzy_result = lang_intents.fuzzy_matcher.match(user_input.text)
if fuzzy_result is None:
return None
response = "default"
if lang_intents.fuzzy_responses:
domain = "" # no domain
if "name" in fuzzy_result.slots:
domain = fuzzy_result.name_domain
elif "domain" in fuzzy_result.slots:
domain = fuzzy_result.slots["domain"].value
slot_combo = tuple(sorted(fuzzy_result.slots))
if (
intent_responses := lang_intents.fuzzy_responses.get(
fuzzy_result.intent_name
)
) and (combo_responses := intent_responses.get(slot_combo)):
response = combo_responses.get(domain, response)
entities = [
MatchEntity(name=slot_name, value=slot_value.value, text=slot_value.text)
for slot_name, slot_value in fuzzy_result.slots.items()
]
return RecognizeResult(
intent=Intent(name=fuzzy_result.intent_name),
intent_data=IntentData(sentence_texts=[]),
intent_metadata={METADATA_FUZZY_MATCH: True},
entities={entity.name: entity for entity in entities},
entities_list=entities,
response=response,
)
def _recognize_unknown_names(
self,
lang_intents: LanguageIntents,
user_input: ConversationInput,
slot_lists: dict[str, SlotList],
intent_context: dict[str, Any] | None,
) -> RecognizeResult | None:
"""Return result with unknown names for an error message."""
maybe_result: RecognizeResult | None = None
best_num_matched_entities = 0
best_num_unmatched_entities = 0
best_num_unmatched_ranges = 0
for result in recognize_all(
user_input.text,
lang_intents.intents,
slot_lists=slot_lists,
intent_context=intent_context,
allow_unmatched_entities=True,
):
if result.text_chunks_matched < 1:
# Skip results that don't match any literal text
continue
# Don't count missing entities that couldn't be filled from context
num_matched_entities = 0
for matched_entity in result.entities_list:
if matched_entity.name not in result.unmatched_entities:
num_matched_entities += 1
num_unmatched_entities = 0
num_unmatched_ranges = 0
for unmatched_entity in result.unmatched_entities_list:
if isinstance(unmatched_entity, UnmatchedTextEntity):
if unmatched_entity.text != MISSING_ENTITY:
num_unmatched_entities += 1
elif isinstance(unmatched_entity, UnmatchedRangeEntity):
num_unmatched_ranges += 1
num_unmatched_entities += 1
else:
num_unmatched_entities += 1
if (
(maybe_result is None) # first result
or (
# More literal text matched
result.text_chunks_matched > maybe_result.text_chunks_matched
)
or (
# More entities matched
num_matched_entities > best_num_matched_entities
)
or (
# Fewer unmatched entities
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities < best_num_unmatched_entities)
)
or (
# Prefer unmatched ranges
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges > best_num_unmatched_ranges)
)
or (
# Prefer match failures with entities
(result.text_chunks_matched == maybe_result.text_chunks_matched)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges == best_num_unmatched_ranges)
and (
("name" in result.entities)
or ("name" in result.unmatched_entities)
)
)
):
maybe_result = result
best_num_matched_entities = num_matched_entities
best_num_unmatched_entities = num_unmatched_entities
best_num_unmatched_ranges = num_unmatched_ranges
return maybe_result
def _get_unexposed_entity_names(self, text: str) -> TextSlotList:
"""Get filtered slot list with unexposed entity names in Home Assistant."""
if self._unexposed_names_trie is None:
@@ -962,7 +851,7 @@ class DefaultAgent(ConversationEntity):
if lang_intents is None:
return
await self._make_slot_lists()
self._make_slot_lists()
async def async_get_or_load_intents(self, language: str) -> LanguageIntents | None:
"""Load all intents of a language with lock."""
@@ -1113,85 +1002,12 @@ class DefaultAgent(ConversationEntity):
intent_responses = responses_dict.get("intents", {})
error_responses = responses_dict.get("errors", {})
if not self.fuzzy_matching:
_LOGGER.debug("Fuzzy matching is disabled")
return LanguageIntents(
intents,
intents_dict,
intent_responses,
error_responses,
language_variant,
)
# Load fuzzy
fuzzy_info = get_fuzzy_language(language_variant, json_load=json_load)
if fuzzy_info is None:
_LOGGER.debug(
"Fuzzy matching not available for language: %s", language_variant
)
return LanguageIntents(
intents,
intents_dict,
intent_responses,
error_responses,
language_variant,
)
if self._fuzzy_config is None:
# Load shared config
self._fuzzy_config = get_fuzzy_config(json_load=json_load)
_LOGGER.debug("Loaded shared fuzzy matching config")
assert self._fuzzy_config is not None
fuzzy_matcher: FuzzyNgramMatcher | None = None
fuzzy_responses: FuzzyLanguageResponses | None = None
start_time = time.monotonic()
fuzzy_responses = fuzzy_info.responses
fuzzy_matcher = FuzzyNgramMatcher(
intents=intents,
intent_models={
intent_name: Sqlite3NgramModel(
order=fuzzy_model.order,
words={
word: str(word_id)
for word, word_id in fuzzy_model.words.items()
},
database_path=fuzzy_model.database_path,
)
for intent_name, fuzzy_model in fuzzy_info.ngram_models.items()
},
intent_slot_list_names=self._fuzzy_config.slot_list_names,
slot_combinations={
intent_name: {
combo_key: [
SlotCombinationInfo(
name_domains=(set(name_domains) if name_domains else None)
)
]
for combo_key, name_domains in intent_combos.items()
}
for intent_name, intent_combos in self._fuzzy_config.slot_combinations.items()
},
domain_keywords=fuzzy_info.domain_keywords,
stop_words=fuzzy_info.stop_words,
)
_LOGGER.debug(
"Loaded fuzzy matcher in %s second(s): language=%s, intents=%s",
time.monotonic() - start_time,
language_variant,
sorted(fuzzy_matcher.intent_models.keys()),
)
return LanguageIntents(
intents,
intents_dict,
intent_responses,
error_responses,
language_variant,
fuzzy_matcher=fuzzy_matcher,
fuzzy_responses=fuzzy_responses,
)
@core.callback
@@ -1211,7 +1027,8 @@ class DefaultAgent(ConversationEntity):
# Slot lists have changed, so we must clear the cache
self._intent_cache.clear()
async def _make_slot_lists(self) -> dict[str, SlotList]:
@core.callback
def _make_slot_lists(self) -> dict[str, SlotList]:
"""Create slot lists with areas and entity names/aliases."""
if self._slot_lists is not None:
return self._slot_lists
@@ -1272,10 +1089,6 @@ class DefaultAgent(ConversationEntity):
"floor": TextSlotList.from_tuples(floor_names, allow_template=False),
}
# Reload fuzzy matchers with new slot lists
if self.fuzzy_matching:
await self.hass.async_add_executor_job(self._load_fuzzy_matchers)
self._listen_clear_slot_list()
_LOGGER.debug(
@@ -1285,25 +1098,6 @@ class DefaultAgent(ConversationEntity):
return self._slot_lists
def _load_fuzzy_matchers(self) -> None:
"""Reload fuzzy matchers for all loaded languages."""
for lang_intents in self._lang_intents.values():
if (not isinstance(lang_intents, LanguageIntents)) or (
lang_intents.fuzzy_matcher is None
):
continue
lang_matcher = lang_intents.fuzzy_matcher
lang_intents.fuzzy_matcher = FuzzyNgramMatcher(
intents=lang_matcher.intents,
intent_models=lang_matcher.intent_models,
intent_slot_list_names=lang_matcher.intent_slot_list_names,
slot_combinations=lang_matcher.slot_combinations,
domain_keywords=lang_matcher.domain_keywords,
stop_words=lang_matcher.stop_words,
slot_lists=self._slot_lists,
)
def _make_intent_context(
self, user_input: ConversationInput
) -> dict[str, Any] | None:
@@ -1389,7 +1183,7 @@ class DefaultAgent(ConversationEntity):
for trigger_intent in trigger_intents.intents.values():
for intent_data in trigger_intent.data:
for sentence in intent_data.sentences:
_collect_list_references(sentence.expression, wildcard_names)
_collect_list_references(sentence, wildcard_names)
for wildcard_name in wildcard_names:
trigger_intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name)
@@ -1726,9 +1520,11 @@ def _get_match_error_response(
def _collect_list_references(expression: Expression, list_names: set[str]) -> None:
"""Collect list reference names recursively."""
if isinstance(expression, Group):
for item in expression.items:
if isinstance(expression, Sequence):
seq: Sequence = expression
for item in seq.items:
_collect_list_references(item, list_names)
elif isinstance(expression, ListReference):
# {list}
list_names.add(expression.slot_name)
list_ref: ListReference = expression
list_names.add(list_ref.slot_name)

View File

@@ -26,11 +26,7 @@ from .agent_manager import (
get_agent_manager,
)
from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY
from .default_agent import (
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
METADATA_FUZZY_MATCH,
)
from .default_agent import METADATA_CUSTOM_FILE, METADATA_CUSTOM_SENTENCE
from .entity import ConversationEntity
from .models import ConversationInput
@@ -244,8 +240,6 @@ async def websocket_hass_agent_debug(
"sentence_template": "",
# When match is incomplete, this will contain the best slot guesses
"unmatched_slots": _get_unmatched_slots(intent_result),
# True if match was not exact
"fuzzy_match": False,
}
if successful_match:
@@ -257,19 +251,16 @@ async def websocket_hass_agent_debug(
if intent_result.intent_sentence is not None:
result_dict["sentence_template"] = intent_result.intent_sentence.text
if intent_result.intent_metadata:
# Inspect metadata to determine if this matched a custom sentence
if intent_result.intent_metadata.get(METADATA_CUSTOM_SENTENCE):
result_dict["source"] = "custom"
result_dict["file"] = intent_result.intent_metadata.get(
METADATA_CUSTOM_FILE
)
else:
result_dict["source"] = "builtin"
result_dict["fuzzy_match"] = intent_result.intent_metadata.get(
METADATA_FUZZY_MATCH, False
# Inspect metadata to determine if this matched a custom sentence
if intent_result.intent_metadata and intent_result.intent_metadata.get(
METADATA_CUSTOM_SENTENCE
):
result_dict["source"] = "custom"
result_dict["file"] = intent_result.intent_metadata.get(
METADATA_CUSTOM_FILE
)
else:
result_dict["source"] = "builtin"
result_dicts.append(result_dict)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["hassil==3.1.0", "home-assistant-intents==2025.7.30"]
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.7.30"]
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["cookidoo_api"],
"quality_scale": "silver",
"requirements": ["cookidoo-api==0.14.0"]
"requirements": ["cookidoo-api==0.12.2"]
}

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from datetime import datetime
from typing import Any
from homeassistant.components import media_source
from homeassistant.components.media_player import (
BrowseMedia,
MediaClass,
@@ -397,15 +396,6 @@ class DemoBrowsePlayer(AbstractDemoPlayer):
_attr_supported_features = BROWSE_PLAYER_SUPPORT
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Implement the websocket media browsing helper."""
return await media_source.async_browse_media(self.hass, media_content_id)
class DemoGroupPlayer(AbstractDemoPlayer):
"""A Demo media player that supports grouping."""

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