Compare commits

..

2 Commits

Author SHA1 Message Date
Robert Resch c9656acb27 test 2026-05-18 20:58:26 +00:00
Erik a68ad41469 Don't spin up docker container when validating go2rtc version 2026-05-18 22:04:37 +02:00
5505 changed files with 46041 additions and 202104 deletions
@@ -18,13 +18,6 @@ description: Reviews GitHub pull requests and provides feedback comments. This i
4. Ensure any existing review comments have been addressed.
5. Generate constructive review comments in the CONSOLE. DO NOT POST TO GITHUB YOURSELF.
## Verification:
- After the review, run parallel subagents for each finding to double check it.
- Spawn up to a maximum of 10 parallel subagents at a time.
- Gather the results from the subagents and summarize them in the final review comments.
## IMPORTANT:
- Just review. DO NOT make any changes
- Be constructive and specific in your comments
-9
View File
@@ -1,9 +0,0 @@
{
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {
"version": "1.1.0",
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
"integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
}
}
}
+2 -2
View File
@@ -14,12 +14,12 @@ Dockerfile.dev linguist-language=Dockerfile
# Generated files
CODEOWNERS linguist-generated=true
Dockerfile linguist-generated=true
homeassistant/generated/*.py linguist-generated=true
pylint/plugins/pylint_home_assistant/generated/*.py linguist-generated=true
machine/* linguist-generated=true
mypy.ini linguist-generated=true
requirements.txt linguist-generated=true
requirements_all.txt linguist-generated=true
requirements_test_all.txt linguist-generated=true
requirements_test_pre_commit.txt linguist-generated=true
script/hassfest/docker/Dockerfile linguist-generated=true
.github/workflows/*.lock.yml linguist-generated=true
+2 -13
View File
@@ -16,16 +16,9 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review
## Pull Requests
- When opening a pull request, use the repository's PR template (`.github/PULL_REQUEST_TEMPLATE.md`). NEVER REMOVE ANYTHING from the template.
- Do not remove checkboxes that are not checked — leave all unchecked checkboxes in place so reviewers can see which options were not selected.
## Development Commands
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
- .vscode/tasks.json contains useful commands used for development.
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
.vscode/tasks.json contains useful commands used for development.
## Python Syntax Notes
@@ -35,14 +28,10 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
## Testing
- Use `uv run pytest` to run tests
- After modifying `strings.json` for an integration, regenerate the English translation file before running tests: `.venv/bin/python3 -m script.translations develop --integration <integration_name>`. Tests load translations from the generated `translations/en.json`, not directly from `strings.json`.
- When writing or modifying tests, ensure all test function parameters have type annotations.
- Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
- Prefer `@pytest.mark.usefixtures` over arguments, if the argument is not going to be used.
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body.
## Good practices
-3
View File
@@ -11,6 +11,3 @@ updates:
- github_actions
cooldown:
default-days: 7
ignore:
# Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump.
- dependency-name: "github/gh-aw-actions/**"
+1 -29
View File
@@ -6,7 +6,6 @@
"pep621",
"pip_requirements",
"pre-commit",
"dockerfile",
"custom.regex",
"homeassistant-manifest"
],
@@ -22,10 +21,6 @@
]
},
"dockerfile": {
"managerFilePatterns": ["/^Dockerfile$/"]
},
"homeassistant-manifest": {
"managerFilePatterns": [
"/^homeassistant/components/[^/]+/manifest\\.json$/"
@@ -40,14 +35,6 @@
"matchStrings": ["required-version = \">=(?<currentValue>[\\d.]+)\""],
"depNameTemplate": "ruff",
"datasourceTemplate": "pypi"
},
{
"customType": "regex",
"description": "Update go2rtc RECOMMENDED_VERSION in const.py alongside the Dockerfile pin",
"managerFilePatterns": ["/^homeassistant/components/go2rtc/const\\.py$/"],
"matchStrings": ["RECOMMENDED_VERSION = \"(?<currentValue>[\\d.]+)\""],
"depNameTemplate": "ghcr.io/alexxit/go2rtc",
"datasourceTemplate": "docker"
}
],
@@ -128,7 +115,6 @@
"standard-aifc",
"standard-telnetlib",
"ulid-transform",
"unidiff",
"url-normalize",
"xmltodict"
],
@@ -142,8 +128,7 @@
"home-assistant-bluetooth",
"home-assistant-frontend",
"home-assistant-intents",
"infrared-protocols",
"rf-protocols"
"infrared-protocols"
],
"enabled": true,
"minimumReleaseAge": null,
@@ -198,13 +183,6 @@
"enabled": true,
"labels": ["dependency"]
},
{
"description": "Docker allowlist (ghcr.io exposes no release timestamps so the global cooldown needs to be bypassed)",
"matchPackageNames": ["ghcr.io/alexxit/go2rtc"],
"enabled": true,
"minimumReleaseAge": null,
"labels": ["dependency"]
},
{
"description": "Group ruff pre-commit hook with its PyPI twin into one PR",
"matchPackageNames": ["astral-sh/ruff-pre-commit", "ruff"],
@@ -234,12 +212,6 @@
"matchPackageNames": ["pylint", "astroid"],
"groupName": "pylint",
"groupSlug": "pylint"
},
{
"description": "Group go2rtc Dockerfile pin with const.py RECOMMENDED_VERSION into one PR",
"matchPackageNames": ["ghcr.io/alexxit/go2rtc"],
"groupName": "go2rtc",
"groupSlug": "go2rtc"
}
]
}
+398 -398
View File
@@ -14,7 +14,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.05.0"
BASE_IMAGE_VERSION: "2026.04.0"
ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {}
@@ -53,10 +53,10 @@ jobs:
with:
type: ${{ env.BUILD_TYPE }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
with:
ignore-dev: true
# - name: Verify version
# uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
# with:
# ignore-dev: true
- name: Fail if translations files are checked in
run: |
@@ -66,442 +66,442 @@ jobs:
exit 1
fi
- name: Download Translations
run: python3 -m script.translations download
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
# - name: Download Translations
# run: python3 -m script.translations download
# env:
# LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
- name: Archive translations
shell: bash
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
# - name: Archive translations
# shell: bash
# run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: translations
path: translations.tar.gz
if-no-files-found: error
# - name: Upload translations
# uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# with:
# name: translations
# path: translations.tar.gz
# if-no-files-found: error
build_base:
name: Build ${{ matrix.arch }} base core image
if: github.repository_owner == 'home-assistant'
needs: init
runs-on: ${{ matrix.os }}
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
id-token: write # For cosign signing
strategy:
fail-fast: false
matrix:
arch: ${{ fromJson(needs.init.outputs.architectures) }}
include:
- arch: amd64
os: ubuntu-24.04
- arch: aarch64
os: ubuntu-24.04-arm
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# build_base:
# name: Build ${{ matrix.arch }} base core image
# if: github.repository_owner == 'home-assistant'
# needs: init
# runs-on: ${{ matrix.os }}
# permissions:
# contents: read # To check out the repository
# packages: write # To push to GHCR
# id-token: write # For cosign signing
# strategy:
# fail-fast: false
# matrix:
# arch: ${{ fromJson(needs.init.outputs.architectures) }}
# include:
# - arch: amd64
# os: ubuntu-24.04
# - arch: aarch64
# os: ubuntu-24.04-arm
# steps:
# - name: Checkout the repository
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# with:
# persist-credentials: false
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
branch: dev
workflow: nightly.yaml
workflow_conclusion: success
name: wheels
# - name: Download nightly wheels of frontend
# if: needs.init.outputs.channel == 'dev'
# uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
# with:
# github_token: ${{secrets.GITHUB_TOKEN}}
# repo: home-assistant/frontend
# branch: dev
# workflow: nightly.yaml
# workflow_conclusion: success
# name: wheels
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package
branch: main
workflow: nightly.yaml
workflow_conclusion: success
name: package
# - name: Download nightly wheels of intents
# if: needs.init.outputs.channel == 'dev'
# uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
# with:
# github_token: ${{secrets.GITHUB_TOKEN}}
# repo: OHF-Voice/intents-package
# branch: main
# workflow: nightly.yaml
# workflow_conclusion: success
# name: package
- name: Set up Python
if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
# - name: Set up Python
# if: needs.init.outputs.channel == 'dev'
# uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
# with:
# python-version-file: ".python-version"
- name: Adjust nightly version
if: needs.init.outputs.channel == 'dev'
shell: bash
env:
UV_PRERELEASE: allow
VERSION: ${{ needs.init.outputs.version }}
run: |
python3 -m pip install "$(grep '^uv' < requirements.txt)"
uv pip install packaging tomli
uv pip install .
python3 script/version_bump.py nightly --set-nightly-version "${VERSION}"
# - name: Adjust nightly version
# if: needs.init.outputs.channel == 'dev'
# shell: bash
# env:
# UV_PRERELEASE: allow
# VERSION: ${{ needs.init.outputs.version }}
# run: |
# python3 -m pip install "$(grep '^uv' < requirements.txt)"
# uv pip install packaging tomli
# uv pip install .
# python3 script/version_bump.py nightly --set-nightly-version "${VERSION}"
if [[ "$(ls home_assistant_frontend*.whl)" =~ ^home_assistant_frontend-(.*)-py3-none-any.whl$ ]]; then
echo "Found frontend wheel, setting version to: ${BASH_REMATCH[1]}"
frontend_version="${BASH_REMATCH[1]}" yq \
--inplace e -o json \
'.requirements = ["home-assistant-frontend=="+env(frontend_version)]' \
homeassistant/components/frontend/manifest.json
# if [[ "$(ls home_assistant_frontend*.whl)" =~ ^home_assistant_frontend-(.*)-py3-none-any.whl$ ]]; then
# echo "Found frontend wheel, setting version to: ${BASH_REMATCH[1]}"
# frontend_version="${BASH_REMATCH[1]}" yq \
# --inplace e -o json \
# '.requirements = ["home-assistant-frontend=="+env(frontend_version)]' \
# homeassistant/components/frontend/manifest.json
sed -i "s|home-assistant-frontend==.*|home-assistant-frontend==${BASH_REMATCH[1]}|" \
homeassistant/package_constraints.txt
# sed -i "s|home-assistant-frontend==.*|home-assistant-frontend==${BASH_REMATCH[1]}|" \
# homeassistant/package_constraints.txt
sed -i "s|home-assistant-frontend==.*||" requirements_all.txt
fi
# sed -i "s|home-assistant-frontend==.*||" requirements_all.txt
# fi
if [[ "$(ls home_assistant_intents*.whl)" =~ ^home_assistant_intents-(.*)-py3-none-any.whl$ ]]; then
echo "Found intents wheel, setting version to: ${BASH_REMATCH[1]}"
yq \
--inplace e -o json \
'del(.requirements[] | select(contains("home-assistant-intents")))' \
homeassistant/components/conversation/manifest.json
# if [[ "$(ls home_assistant_intents*.whl)" =~ ^home_assistant_intents-(.*)-py3-none-any.whl$ ]]; then
# echo "Found intents wheel, setting version to: ${BASH_REMATCH[1]}"
# yq \
# --inplace e -o json \
# 'del(.requirements[] | select(contains("home-assistant-intents")))' \
# homeassistant/components/conversation/manifest.json
intents_version="${BASH_REMATCH[1]}" yq \
--inplace e -o json \
'.requirements += ["home-assistant-intents=="+env(intents_version)]' \
homeassistant/components/conversation/manifest.json
# intents_version="${BASH_REMATCH[1]}" yq \
# --inplace e -o json \
# '.requirements += ["home-assistant-intents=="+env(intents_version)]' \
# homeassistant/components/conversation/manifest.json
sed -i "s|home-assistant-intents==.*|home-assistant-intents==${BASH_REMATCH[1]}|" \
homeassistant/package_constraints.txt
# sed -i "s|home-assistant-intents==.*|home-assistant-intents==${BASH_REMATCH[1]}|" \
# homeassistant/package_constraints.txt
sed -i "s|home-assistant-intents==.*||" requirements_all.txt requirements.txt
fi
# sed -i "s|home-assistant-intents==.*||" requirements_all.txt requirements.txt
# fi
- name: Download translations
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: translations
# - name: Download translations
# uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# with:
# name: translations
- name: Extract translations
run: |
tar xvf translations.tar.gz
rm translations.tar.gz
# - name: Extract translations
# run: |
# tar xvf translations.tar.gz
# rm translations.tar.gz
- name: Write meta info file
shell: bash
run: |
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
# - name: Write meta info file
# shell: bash
# run: |
# echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
- name: Build base image
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
with:
arch: ${{ matrix.arch }}
build-args: |
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
cache-gha: false
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
cosign-base-identity: "https://github.com/home-assistant/docker/.*"
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
image: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant
image-tags: ${{ needs.init.outputs.version }}
push: true
version: ${{ needs.init.outputs.version }}
# - name: Build base image
# uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
# with:
# arch: ${{ matrix.arch }}
# build-args: |
# BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
# cache-gha: false
# container-registry-password: ${{ secrets.GITHUB_TOKEN }}
# cosign-base-identity: "https://github.com/home-assistant/docker/.*"
# cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ needs.init.outputs.base_image_version }}
# image: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant
# image-tags: ${{ needs.init.outputs.version }}
# push: true
# version: ${{ needs.init.outputs.version }}
build_machine:
name: Build ${{ matrix.machine }} machine core image
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
runs-on: ${{ matrix.runs-on }}
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
id-token: write # For cosign signing
strategy:
matrix:
machine:
- generic-x86-64
- khadas-vim3
- odroid-c2
- odroid-c4
- odroid-m1
- odroid-n2
- qemuarm-64
- qemux86-64
- raspberrypi3-64
- raspberrypi4-64
- raspberrypi5-64
- yellow
- green
include:
# Default: aarch64 on native ARM runner
- arch: aarch64
runs-on: ubuntu-24.04-arm
# Overrides for amd64 machines
- machine: generic-x86-64
arch: amd64
runs-on: ubuntu-24.04
- machine: qemux86-64
arch: amd64
runs-on: ubuntu-24.04
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# build_machine:
# name: Build ${{ matrix.machine }} machine core image
# if: github.repository_owner == 'home-assistant'
# needs: ["init", "build_base"]
# runs-on: ${{ matrix.runs-on }}
# permissions:
# contents: read # To check out the repository
# packages: write # To push to GHCR
# id-token: write # For cosign signing
# strategy:
# matrix:
# machine:
# - generic-x86-64
# - khadas-vim3
# - odroid-c2
# - odroid-c4
# - odroid-m1
# - odroid-n2
# - qemuarm-64
# - qemux86-64
# - raspberrypi3-64
# - raspberrypi4-64
# - raspberrypi5-64
# - yellow
# - green
# include:
# # Default: aarch64 on native ARM runner
# - arch: aarch64
# runs-on: ubuntu-24.04-arm
# # Overrides for amd64 machines
# - machine: generic-x86-64
# arch: amd64
# runs-on: ubuntu-24.04
# - machine: qemux86-64
# arch: amd64
# runs-on: ubuntu-24.04
# steps:
# - name: Checkout the repository
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# with:
# persist-credentials: false
- name: Compute extra tags
id: tags
shell: bash
env:
VERSION: ${{ needs.init.outputs.version }}
run: |
if [[ "${VERSION}" =~ d ]]; then
echo "extra_tags=dev" >> "$GITHUB_OUTPUT"
elif [[ "${VERSION}" =~ b ]]; then
echo "extra_tags=beta" >> "$GITHUB_OUTPUT"
else
echo "extra_tags=stable" >> "$GITHUB_OUTPUT"
fi
# - name: Compute extra tags
# id: tags
# shell: bash
# env:
# VERSION: ${{ needs.init.outputs.version }}
# run: |
# if [[ "${VERSION}" =~ d ]]; then
# echo "extra_tags=dev" >> "$GITHUB_OUTPUT"
# elif [[ "${VERSION}" =~ b ]]; then
# echo "extra_tags=beta" >> "$GITHUB_OUTPUT"
# else
# echo "extra_tags=stable" >> "$GITHUB_OUTPUT"
# fi
- name: Build machine image
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
with:
arch: ${{ matrix.arch }}
build-args: |
BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
cache-gha: false
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
context: machine/
cosign-base-identity: "https://github.com/home-assistant/core/.*"
cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
file: machine/${{ matrix.machine }}
image: ghcr.io/home-assistant/${{ matrix.machine }}-homeassistant
image-tags: |
${{ needs.init.outputs.version }}
${{ steps.tags.outputs.extra_tags }}
push: true
version: ${{ needs.init.outputs.version }}
# - name: Build machine image
# uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
# with:
# arch: ${{ matrix.arch }}
# build-args: |
# BUILD_FROM=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
# cache-gha: false
# container-registry-password: ${{ secrets.GITHUB_TOKEN }}
# context: machine/
# cosign-base-identity: "https://github.com/home-assistant/core/.*"
# cosign-base-verify: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
# file: machine/${{ matrix.machine }}
# image: ghcr.io/home-assistant/${{ matrix.machine }}-homeassistant
# image-tags: |
# ${{ needs.init.outputs.version }}
# ${{ steps.tags.outputs.extra_tags }}
# push: true
# version: ${{ needs.init.outputs.version }}
publish_ha:
name: Publish version files
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_machine"]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# publish_ha:
# name: Publish version files
# environment: ${{ needs.init.outputs.channel }}
# if: github.repository_owner == 'home-assistant'
# needs: ["init", "build_machine"]
# runs-on: ubuntu-latest
# permissions:
# contents: read
# steps:
# - name: Checkout the repository
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# with:
# persist-credentials: false
- name: Initialize git
uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses]
with:
name: ${{ secrets.GIT_NAME }}
email: ${{ secrets.GIT_EMAIL }}
token: ${{ secrets.GIT_TOKEN }}
# - name: Initialize git
# uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses]
# with:
# name: ${{ secrets.GIT_NAME }}
# email: ${{ secrets.GIT_EMAIL }}
# token: ${{ secrets.GIT_TOKEN }}
- name: Update version file
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
with:
key: "homeassistant[]"
key-description: "Home Assistant Core"
version: ${{ needs.init.outputs.version }}
channel: ${{ needs.init.outputs.channel }}
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
# - name: Update version file
# uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
# with:
# key: "homeassistant[]"
# key-description: "Home Assistant Core"
# version: ${{ needs.init.outputs.version }}
# channel: ${{ needs.init.outputs.channel }}
# exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
- name: Update version file (stable -> beta)
if: needs.init.outputs.channel == 'stable'
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
with:
key: "homeassistant[]"
key-description: "Home Assistant Core"
version: ${{ needs.init.outputs.version }}
channel: beta
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
# - name: Update version file (stable -> beta)
# if: needs.init.outputs.channel == 'stable'
# uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
# with:
# key: "homeassistant[]"
# key-description: "Home Assistant Core"
# version: ${{ needs.init.outputs.version }}
# channel: beta
# exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
publish_container:
name: Publish to ${{ matrix.registry }}
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
id-token: write # For cosign signing
strategy:
fail-fast: false
matrix:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps:
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: "v2.5.3"
# publish_container:
# name: Publish meta container for ${{ matrix.registry }}
# environment: ${{ needs.init.outputs.channel }}
# if: github.repository_owner == 'home-assistant'
# needs: ["init", "build_base"]
# runs-on: ubuntu-latest
# permissions:
# contents: read # To check out the repository
# packages: write # To push to GHCR
# id-token: write # For cosign signing
# strategy:
# fail-fast: false
# matrix:
# registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
# steps:
# - name: Install Cosign
# uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
# with:
# cosign-release: "v2.5.3"
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# - name: Login to DockerHub
# if: matrix.registry == 'docker.io/homeassistant'
# uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
# with:
# username: ${{ secrets.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# - name: Login to GitHub Container Registry
# uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
# with:
# registry: ghcr.io
# username: ${{ github.repository_owner }}
# password: ${{ secrets.GITHUB_TOKEN }}
- name: Verify architecture image signatures
shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
VERSION: ${{ needs.init.outputs.version }}
run: |
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
for arch in $ARCHS; do
echo "Verifying ${arch} image signature..."
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp https://github.com/home-assistant/core/.* \
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
done
echo "✓ All images verified successfully"
# - name: Verify architecture image signatures
# shell: bash
# env:
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
# VERSION: ${{ needs.init.outputs.version }}
# run: |
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
# for arch in $ARCHS; do
# echo "Verifying ${arch} image signature..."
# cosign verify \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
# --certificate-identity-regexp https://github.com/home-assistant/core/.* \
# "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
# done
# echo "✓ All images verified successfully"
# Generate all Docker tags based on version string
# Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
# Examples:
# 2025.12.1 (stable) -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
# 2025.12.0b3 (beta) -> tags: 2025.12.0b3, beta, rc
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ matrix.registry }}/home-assistant
sep-tags: ","
tags: |
type=raw,value=${{ needs.init.outputs.version }},priority=9999
type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
# # Generate all Docker tags based on version string
# # Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
# # Examples:
# # 2025.12.1 (stable) -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
# # 2025.12.0b3 (beta) -> tags: 2025.12.0b3, beta, rc
# # 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
# - name: Generate Docker metadata
# id: meta
# uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
# with:
# images: ${{ matrix.registry }}/home-assistant
# sep-tags: ","
# tags: |
# type=raw,value=${{ needs.init.outputs.version }},priority=9999
# type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
# type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
# type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
# type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
# type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
# type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
VERSION: ${{ needs.init.outputs.version }}
run: |
# Use imagetools to copy image blobs directly between registries
# This preserves provenance/attestations and seems to be much faster than pull/push
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
for arch in $ARCHS; do
echo "Copying ${arch} image to DockerHub..."
for attempt in 1 2 3; do
if docker buildx imagetools create \
--tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
break
fi
echo "Attempt ${attempt} failed, retrying in 10 seconds..."
sleep 10
if [ "${attempt}" -eq 3 ]; then
echo "Failed after 3 attempts"
exit 1
fi
done
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
done
# - name: Copy architecture images to DockerHub
# if: matrix.registry == 'docker.io/homeassistant'
# shell: bash
# env:
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
# VERSION: ${{ needs.init.outputs.version }}
# run: |
# # Use imagetools to copy image blobs directly between registries
# # This preserves provenance/attestations and seems to be much faster than pull/push
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
# for arch in $ARCHS; do
# echo "Copying ${arch} image to DockerHub..."
# for attempt in 1 2 3; do
# if docker buildx imagetools create \
# --tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
# "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
# break
# fi
# echo "Attempt ${attempt} failed, retrying in 10 seconds..."
# sleep 10
# if [ "${attempt}" -eq 3 ]; then
# echo "Failed after 3 attempts"
# exit 1
# fi
# done
# cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
# done
- name: Create and push multi-arch manifests
shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
REGISTRY: ${{ matrix.registry }}
VERSION: ${{ needs.init.outputs.version }}
META_TAGS: ${{ steps.meta.outputs.tags }}
run: |
# Build list of architecture images dynamically
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
ARCH_IMAGES=()
for arch in $ARCHS; do
ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
done
# - name: Create and push multi-arch manifests
# shell: bash
# env:
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
# REGISTRY: ${{ matrix.registry }}
# VERSION: ${{ needs.init.outputs.version }}
# META_TAGS: ${{ steps.meta.outputs.tags }}
# run: |
# # Build list of architecture images dynamically
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
# ARCH_IMAGES=()
# for arch in $ARCHS; do
# ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
# done
# Build list of all tags for single manifest creation
# Note: Using sep-tags=',' in metadata-action for easier parsing
TAG_ARGS=()
IFS=',' read -ra TAGS <<< "${META_TAGS}"
for tag in "${TAGS[@]}"; do
TAG_ARGS+=("--tag" "${tag}")
done
# # Build list of all tags for single manifest creation
# # Note: Using sep-tags=',' in metadata-action for easier parsing
# TAG_ARGS=()
# IFS=',' read -ra TAGS <<< "${META_TAGS}"
# for tag in "${TAGS[@]}"; do
# TAG_ARGS+=("--tag" "${tag}")
# done
# Create manifest with ALL tags in a single operation (much faster!)
echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"
# # Create manifest with ALL tags in a single operation (much faster!)
# echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
# docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"
# Sign each tag separately (signing requires individual tag names)
echo "Signing all tags..."
for tag in "${TAGS[@]}"; do
echo "Signing ${tag}"
cosign sign --yes "${tag}"
done
# # Sign each tag separately (signing requires individual tag names)
# echo "Signing all tags..."
# for tag in "${TAGS[@]}"; do
# echo "Signing ${tag}"
# cosign sign --yes "${tag}"
# done
echo "All manifests created and signed successfully"
# echo "All manifests created and signed successfully"
build_python:
name: Build PyPi package
environment: ${{ needs.init.outputs.channel }}
needs: ["init", "build_base"]
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
id-token: write # For PyPI trusted publishing
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# build_python:
# name: Build PyPi package
# environment: ${{ needs.init.outputs.channel }}
# needs: ["init", "build_base"]
# runs-on: ubuntu-latest
# permissions:
# contents: read # To check out the repository
# id-token: write # For PyPI trusted publishing
# if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
# steps:
# - name: Checkout the repository
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# with:
# persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
# - name: Set up Python
# uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
# with:
# python-version-file: ".python-version"
- name: Download translations
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: translations
# - name: Download translations
# uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# with:
# name: translations
- name: Extract translations
run: |
tar xvf translations.tar.gz
rm translations.tar.gz
# - name: Extract translations
# run: |
# tar xvf translations.tar.gz
# rm translations.tar.gz
- name: Build package
shell: bash
run: |
# Remove dist, build, and homeassistant.egg-info
# when build locally for testing!
pip install build
python -m build
# - name: Build package
# shell: bash
# run: |
# # Remove dist, build, and homeassistant.egg-info
# # when build locally for testing!
# pip install build
# python -m build
- name: Upload package to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
skip-existing: true
# - name: Upload package to PyPI
# uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
# with:
# skip-existing: true
hassfest-image:
name: Build and test hassfest image
@@ -1,74 +0,0 @@
name: Check requirements (deterministic)
# Stage 1 of the Check requirements pipeline.
#
# Runs the deterministic Python checks and uploads the structured
# results as an artifact. Stage 2 (the agentic workflow defined in
# `check-requirements.md`) consumes the artifact on completion.
# yamllint disable-line rule:truthy
on:
# Auto-trigger on PRs that touch tracked requirement files is disabled
# for now while we iterate — testing the workflow_run handoff to the
# agentic stage is hard with an auto-trigger. Re-enable once the chain
# has been validated end-to-end.
# pull_request:
# types: [opened, synchronize, reopened]
# paths:
# - "**/requirements*.txt"
# - "homeassistant/package_constraints.txt"
workflow_dispatch:
inputs:
pull_request_number:
description: "Pull request number to (re-)check"
required: true
type: number
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ inputs.pull_request_number || github.event.pull_request.number }}
cancel-in-progress: true
jobs:
deterministic:
name: Run deterministic requirement checks
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: read # To fetch the PR diff via gh CLI
timeout-minutes: 10
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: ".python-version"
check-latest: true
- name: Install script dependencies
run: pip install -r script/check_requirements/requirements.txt
- name: Collect PR diff
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }}
run: |
mkdir -p deterministic
gh pr diff "${PR_NUMBER}" > deterministic/pr.diff
- name: Run deterministic checks
env:
PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }}
run: |
python -m script.check_requirements \
--pr-number "${PR_NUMBER}" \
--diff deterministic/pr.diff \
--output deterministic/results.json
- name: Upload deterministic-results artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: check-requirements-deterministic
path: deterministic/results.json
if-no-files-found: error
retention-days: 7
File diff suppressed because it is too large Load Diff
-378
View File
@@ -1,378 +0,0 @@
---
on:
workflow_run:
workflows: ["Check requirements (deterministic)"]
types: [completed]
permissions:
contents: read
actions: read
issues: read
pull-requests: read
network:
allowed:
- python
tools:
web-fetch: {}
github:
toolsets: [default, actions]
min-integrity: unapproved
safe-outputs:
add-comment:
max: 1
target: "${{ needs.extract_pr_number.outputs.pr_number }}"
needs:
- extract_pr_number
jobs:
extract_pr_number:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
outputs:
pr_number: ${{ steps.extract.outputs.pr_number }}
steps:
- name: Download deterministic-results artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: check-requirements-deterministic
path: /tmp/deterministic
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract PR number from artifact
id: extract
run: |
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
steps:
- name: Download deterministic-results artifact
if: github.event.workflow_run.conclusion == 'success'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: check-requirements-deterministic
path: /tmp/gh-aw/deterministic
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
post-steps:
- name: Verify agent produced an add_comment safe-output
if: always() && github.event.workflow_run.conclusion == 'success'
run: |
OUTPUT=/tmp/gh-aw/agent_output.json
if [ ! -f "${OUTPUT}" ]; then
echo "::error::Agent output file ${OUTPUT} is missing; the agent did not run to completion."
exit 1
fi
if ! grep -q '"add_comment"' "${OUTPUT}"; then
echo "::error::Agent did not emit an add_comment safe-output; no review comment was posted to the PR."
echo "Agent output:"
cat "${OUTPUT}"
exit 1
fi
description: >
Resolves the deterministic-stage artifact's NEEDS_AGENT checks for changed
Python package requirements on PRs targeting the core repo, then posts the
final review comment. Triggered by completion of the deterministic workflow.
Reads the uploaded artifact from disk, replaces placeholders for any check
whose status is `needs_agent`, and posts the merged comment using the PR
number recorded inside the artifact itself. Each check kind has a dedicated
instruction section below; if the artifact contains a check kind that does
not have a section here, the agent fails hard rather than guess.
---
# Check requirements (AW)
You are a code review assistant for the Home Assistant project. The
deterministic stage has already evaluated every check it can on its own
and produced an artifact containing the PR number, per-package check
results, and a pre-rendered comment with placeholders. **Your only job is
to read that artifact, resolve any `needs_agent` checks, and post the
final comment.**
## Step 1 — Read the deterministic-stage artifact
The deterministic stage uploaded its results to the runner at
`/tmp/gh-aw/deterministic/results.json`.
The JSON has this shape:
- `pr_number` — the PR being checked. The `add_comment` safe-output is
already targeted at this PR (a pre-job extracts `pr_number` from the
artifact and the workflow wires it into the safe-output config via
`needs.extract_pr_number.outputs.pr_number`), so **you do not need to
set `item_number` yourself** — just emit `add_comment` with the
rendered body.
- `needs_agent``true` iff any package's check needs resolution.
- `packages[]` — one entry per changed package. Each entry has:
- `name`, `old_version` (`null` for a newly added package; otherwise the
previous pin), `new_version`, `repo_url`, `publisher_kind`.
- `checks` — a dict keyed by **check kind** (string). Each value has a
`status` (`pass`, `warn`, `fail`, or `needs_agent`) and `details`.
- `rendered_comment` — the final PR comment body, already rendered. For
every check whose status is `needs_agent` it contains two placeholders
you must replace:
- `{{CHECK_CELL:<pkg-name>:<check-kind>}}` — one cell of the summary
table. Replace with exactly one of `✅`, `⚠️`, `❌`.
- `{{CHECK_DETAIL:<pkg-name>:<check-kind>}}` — the body of one bullet
in the package's `<details>` block. Replace with
`<icon> <one-line explanation>` (the bullet's leading
`- **<label>**:` is already rendered — replace only the placeholder).
You **must not** modify any other content in `rendered_comment`. Do not
re-evaluate checks that already have a deterministic status. Do not add
or remove packages.
## Step 2 — Resolve each `needs_agent` check
For each `package` in `packages`:
For each `(check_kind, result)` in `package.checks` where
`result.status == "needs_agent"`:
1. Look up `## Check kind: <check_kind>` in the **Check instructions**
section below.
2. **If no matching section exists**: emit a single `add_comment` whose
body is:
```
<!-- requirements-check -->
## Check requirements
❌ Internal error: the deterministic artifact contains a check kind
(`<check_kind>` on package `<pkg-name>`) that this workflow has no
instructions for. Update `.github/workflows/check-requirements.md`
to add a matching `## Check kind: <check_kind>` section, or remove
the kind from the deterministic stage.
```
Then stop. **Do not improvise** a verdict for an unknown check kind.
3. Otherwise, follow the instructions in that section. They tell you
which icon (✅/⚠️/❌) and one-line explanation to produce.
## Step 3 — Post the comment
1. Replace every `{{CHECK_CELL:…}}` and `{{CHECK_DETAIL:…}}` placeholder
in `rendered_comment` with the resolved value.
2. Emit the resulting markdown using `add_comment` — set `body` to the
merged `rendered_comment` verbatim (the leading
`<!-- requirements-check -->` marker must be preserved). The PR
target is already set by the workflow; do not pass `item_number`.
If the artifact's top-level `needs_agent` is `false` (no checks need
you), emit `rendered_comment` unchanged.
## Check instructions
### Check kind: `repo_public`
Verify that the package's source repository is publicly reachable.
1. Read `package.repo_url`.
2. Use the `web-fetch` tool to GET that URL.
3. Decide the verdict:
- HTTP 200, returns a public repository page → ✅
`<repo_url> is publicly accessible.`
- HTTP 4xx/5xx, or the response redirects to a login / sign-in page →
❌ `Source repository at <repo_url> is not publicly accessible.
Home Assistant requires all dependencies to have publicly available
source code.`
- Any other inconclusive result → ⚠️ with a one-line description.
If `repo_public` resolves to ❌ for a package, **also** mark that
package's `release_pipeline` and `async_blocking` cells/details as ``
(em dash) and explain `Skipped because the source repository is not
publicly accessible.` — neither check can be performed without a public
repo.
### Check kind: `pr_link`
Verify the PR description contains the right link for the change.
1. Fetch the PR body via the GitHub MCP tool, using the `pr_number`
field from the artifact.
2. Extract all URLs from the body.
3. For a **new package** (`package.old_version` is `null`):
- The PR body must contain a URL that points at `package.repo_url`
(any sub-path of the same `owner/repo` on the same host is
acceptable). A PyPI link is **not** sufficient.
- ✅ if such a URL is present.
- ❌ otherwise:
`PR description must link to the source repository at <repo_url>.
A PyPI page link is not sufficient.`
4. For a **version bump** (`package.old_version` is not `null`):
- The PR body must contain a URL on the same host as
`package.repo_url` that references **both** `package.old_version`
and `package.new_version` (e.g. a GitHub compare URL
`compare/vX...vY`, a release / changelog URL containing both
versions, etc.).
- ✅ if such a URL is present and the versions match the actual bump.
- ❌ otherwise:
`PR description should link to a changelog or compare URL on
<repo_url> that mentions both <old_version> and <new_version>.`
### Check kind: `release_pipeline`
Inspect the upstream project's release / publish CI pipeline.
For each package needing inspection, determine the source repository
host from `package.repo_url`, then apply the corresponding checklist.
#### GitHub repositories (`github.com`)
1. List workflows: `GET /repos/{owner}/{repo}/actions/workflows`.
2. Identify any workflow whose name or filename suggests publishing to
PyPI (`release`, `publish`, `pypi`, or `deploy`).
3. Fetch the workflow file and check:
- **Trigger sanity**: triggered by `push` to tags,
`release: published`, or `workflow_run` on a release job —
**not** solely `workflow_dispatch` with no environment-protection
guard.
- **OIDC / Trusted Publisher**: look for `id-token: write` and one of
`pypa/gh-action-pypi-publish`, `actions/attest-build-provenance`,
or `TWINE_PASSWORD` from a static `secrets.PYPI_TOKEN`.
- **No manual upload bypass**: no ungated `twine upload` or
`pip upload`.
4. Verdict:
- ✅ if OIDC + sane triggers + no bypass.
- ⚠️ if static token but version bump, or details unclear.
- ❌ if static token on a new package, or only-manual triggers with
no environment protection.
#### GitLab repositories (`gitlab.com` or self-hosted GitLab)
1. Resolve the project ID via
`GET https://gitlab.com/api/v4/projects/{url-encoded-namespace-and-name}`.
2. Fetch `.gitlab-ci.yml` via
`GET https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`.
3. Apply the same conceptual checks: tag-only / protected-branch
triggers, GitLab OIDC `id_tokens` or CI/CD protected `PYPI_TOKEN`, no
ungated `twine upload`. Same verdict rules as GitHub.
#### Other code hosting providers (Bitbucket, Codeberg, Gitea, Sourcehut, …)
1. Use `web-fetch` to retrieve any visible CI configuration
(`.circleci/config.yml`, `Jenkinsfile`, `azure-pipelines.yml`,
`bitbucket-pipelines.yml`, `.builds/*.yml`).
2. Apply the conceptual checks: automated triggers, CI-injected
credentials, no manual `twine upload`.
3. If no CI config can be retrieved: ⚠️ `Release pipeline could not be
inspected; hosting provider is not GitHub or GitLab.`
### Check kind: `async_blocking`
Verify whether the dependency performs blocking I/O inside async code
paths. Home Assistant runs on a single asyncio event loop, so a library
that exposes an `async` surface must not call blocking APIs from inside
its `async def` functions — that stalls the whole loop. A purely sync
library is fine: Home Assistant integrations are expected to wrap such
calls in an executor.
**Two modes — pick by inspecting `package.old_version`:**
- `old_version` is `null` → **new package**: review the *entire current
source tree*. Nothing about this dependency has been vetted before.
- `old_version` is a string → **version bump**: review only the *diff
between `old_version` and `new_version`*. The previous version was
already accepted, so blocking calls that were present in
`old_version` are not regressions; report only what `new_version`
introduces.
#### Step 1 — Decide whether the library exposes an async surface
Use the `github` MCP tool (for `github.com` repos) or `web-fetch`
(other hosts) on `package.repo_url`. Always inspect the tag /
ref matching `new_version` (e.g. `v{new_version}` or `{new_version}`).
- Locate the top-level package directory (usually named after the
import name, often equal or close to `package.name`).
- Check `pyproject.toml` / `setup.py` / `setup.cfg` / `README*` for
async indicators (`Framework :: AsyncIO` trove classifier, `asyncio`
/ `aiohttp` / `httpx` / `anyio` in dependencies, an async usage
example in the README).
- Grep the package source for `async def`. A handful of `async def`
entries in the public modules is enough to treat the library as
having an async surface.
If the library is **sync-only** (no `async def` in its public modules
and no async framework dependency) → ✅
`Sync-only library; Home Assistant integrations must wrap calls in an
executor.` *This verdict is the same in both modes.*
#### Step 2a — Mode: new package (`old_version` is `null`)
Inspect **every `async def` in the public modules** for blocking
patterns. Walk transitively into helpers the async functions call.
#### Step 2b — Mode: version bump (`old_version` is a string)
Fetch the diff between the two tags and review **only changed lines**:
- GitHub: `GET /repos/{owner}/{repo}/compare/{old_tag}...{new_tag}` via
the `github` MCP tool, or
`https://github.com/{owner}/{repo}/compare/{old_tag}...{new_tag}.diff`
via `web-fetch`. Try the common tag formats in order until one
resolves: `v{version}`, `{version}`, `release-{version}`.
- GitLab: `https://gitlab.com/{namespace}/{project}/-/compare/{old_tag}...{new_tag}.diff`.
- Other hosts: use the project's equivalent compare URL via
`web-fetch`.
If neither tag format resolves on the host, fall back to a full review
(Step 2a) and mention in the detail that the diff was unavailable.
When reviewing the diff, only flag blocking patterns that appear in
**added lines** *inside or reachable from* an `async def`. A blocking
call that existed in `old_version` and is unchanged is not a regression
for this bump.
#### Step 3 — Blocking patterns to look for
In both modes, the patterns to flag inside `async def` bodies are:
- Sync HTTP: `requests.`, `urllib.request`, `urllib3.` direct use,
`http.client.`, sync `httpx.Client(` / `httpx.get(` (NOT the
`AsyncClient`), `pycurl`.
- `time.sleep(` (must be `await asyncio.sleep(`).
- Sync sockets: bare `socket.socket` reads/writes, `ssl.wrap_socket`,
blocking `select.select`.
- File I/O: `open(` / `pathlib.Path.read_*` / `.write_*` for
non-trivial sizes (small one-shot reads during import are
acceptable; reads/writes on the request path are not — prefer
`aiofiles` / executor).
- Sync DB drivers used directly: `sqlite3`, `psycopg2`, `pymysql`,
`pymongo` (sync client), `redis.Redis` (sync client).
- `subprocess.run` / `subprocess.call` / `os.system` (must be
`asyncio.create_subprocess_*`).
A call that is clearly dispatched to an executor
(`run_in_executor`, `asyncio.to_thread`, `anyio.to_thread.run_sync`)
does NOT count as blocking.
#### Step 4 — Verdict
- ✅ — no offending blocking pattern in the surface being reviewed
(whole tree for a new package, added lines for a bump). For a bump,
phrase the detail as `No new blocking calls introduced in
{old_version} → {new_version}.`.
- ⚠️ — blocking calls exist only in sync helpers that the async API
does not call, or only on a clearly non-hot path (e.g. one-shot
setup before the event loop is running). Cite at least one
`<file>:<line>` and explain why it is not on the hot path.
- ❌ — a blocking call is reachable from an `async def` that is part
of the public API on the request / polling path (for a bump: the
call was introduced or moved onto the hot path by this version).
Cite the offending `<file>:<line>` as a clickable link on the repo
host so the contributor can jump to it.
## Notes
- Be constructive and helpful. Reference the inspected workflow / CI
file by URL where useful so the contributor can fix the issue.
- The dedup of the requirements-check comment is handled by gh-aw's
`add_comment` safe-output via the `<!-- requirements-check -->`
marker on the first line of `rendered_comment`.
- If the deterministic workflow concluded with a non-success status,
this workflow's `if:` guard on `Download deterministic-results
artifact` skipped the download. If you find no file at
`/tmp/gh-aw/deterministic/results.json`, emit nothing — the post-step
verification is also gated and will not complain.
+9 -13
View File
@@ -281,7 +281,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -302,7 +302,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
with:
extra-args: --all-files zizmor
@@ -632,7 +632,7 @@ jobs:
with:
persist-credentials: false
- name: Dependency review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
with:
license-check: false # We use our own license audit checks
@@ -853,7 +853,7 @@ jobs:
run: |
. venv/bin/activate
python --version
mypy --num-workers=4 homeassistant pylint
mypy homeassistant pylint
- name: Run mypy (partially)
if: needs.info.outputs.test_full_suite == 'false'
shell: bash
@@ -862,7 +862,7 @@ jobs:
run: |
. venv/bin/activate
python --version
mypy --num-workers=4 $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
mypy $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
prepare-pytest-full:
name: Split tests for full run
@@ -1088,7 +1088,6 @@ jobs:
options: >-
--health-cmd="if command -v mariadb-admin >/dev/null; then mariadb-admin ping -uroot -ppassword; else mysqladmin ping -uroot -ppassword; fi"
--health-interval=5s --health-timeout=2s --health-retries=3
--tmpfs /var/lib/mysql:size=2g,mode=0750
needs:
- info
- base
@@ -1246,10 +1245,7 @@ jobs:
- 5432:5432
env:
POSTGRES_PASSWORD: password
options: >-
--health-cmd="pg_isready -hlocalhost -Upostgres"
--health-interval=5s --health-timeout=2s --health-retries=3
--tmpfs /var/lib/postgresql/data:size=2g,mode=0700
options: --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3
needs:
- info
- base
@@ -1421,7 +1417,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
fail_ci_if_error: true
flags: full-suite
@@ -1592,7 +1588,7 @@ jobs:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
@@ -1620,7 +1616,7 @@ jobs:
with:
pattern: test-results-*
- name: Upload test results to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
report_type: test_results
fail_ci_if_error: true
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
with:
category: "/language:python"
@@ -236,7 +236,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@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
with:
model: openai/gpt-4o
system-prompt: |
@@ -62,7 +62,7 @@ jobs:
- name: Detect language using AI
id: ai_language_detection
if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
with:
model: openai/gpt-4o-mini
system-prompt: |
+4 -4
View File
@@ -55,11 +55,11 @@ jobs:
- name: Generate app token
id: token
# Pinned to a specific version of the action for security reasons
# v3.2.0
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
# v1.7.0
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
with:
app-id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
private-key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
# The 90 day stale policy for issues
# Used for:
+1 -3
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.13
rev: v0.15.12
hooks:
- id: ruff-check
args:
@@ -23,7 +23,6 @@ repos:
- id: zizmor
args:
- --pedantic
exclude: ^\.github/workflows/.*\.lock\.yml$
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
@@ -47,7 +46,6 @@ repos:
additional_dependencies:
- prettier@3.6.2
- prettier-plugin-sort-json@4.2.0
exclude: ^\.github/workflows/.*\.lock\.yml$
- repo: https://github.com/cdce8p/python-typing-update
rev: v0.6.0
hooks:
+1 -1
View File
@@ -1 +1 @@
3.14.5
3.14.4
-10
View File
@@ -139,7 +139,6 @@ homeassistant.components.cambridge_audio.*
homeassistant.components.camera.*
homeassistant.components.canary.*
homeassistant.components.casper_glow.*
homeassistant.components.centriconnect.*
homeassistant.components.cert_expiry.*
homeassistant.components.clickatell.*
homeassistant.components.clicksend.*
@@ -156,7 +155,6 @@ homeassistant.components.counter.*
homeassistant.components.cover.*
homeassistant.components.cpuspeed.*
homeassistant.components.crownstone.*
homeassistant.components.data_grand_lyon.*
homeassistant.components.date.*
homeassistant.components.datetime.*
homeassistant.components.deako.*
@@ -250,7 +248,6 @@ homeassistant.components.gpsd.*
homeassistant.components.greeneye_monitor.*
homeassistant.components.group.*
homeassistant.components.guardian.*
homeassistant.components.guntamatic.*
homeassistant.components.habitica.*
homeassistant.components.hardkernel.*
homeassistant.components.hardware.*
@@ -298,7 +295,6 @@ homeassistant.components.imap.*
homeassistant.components.imgw_pib.*
homeassistant.components.immich.*
homeassistant.components.incomfort.*
homeassistant.components.indevolt.*
homeassistant.components.inels.*
homeassistant.components.infrared.*
homeassistant.components.input_button.*
@@ -358,7 +354,6 @@ homeassistant.components.lunatone.*
homeassistant.components.lutron.*
homeassistant.components.madvr.*
homeassistant.components.manual.*
homeassistant.components.marantz_infrared.*
homeassistant.components.mastodon.*
homeassistant.components.matrix.*
homeassistant.components.matter.*
@@ -425,11 +420,9 @@ homeassistant.components.opower.*
homeassistant.components.oralb.*
homeassistant.components.otbr.*
homeassistant.components.otp.*
homeassistant.components.ouman_eh_800.*
homeassistant.components.overkiz.*
homeassistant.components.overseerr.*
homeassistant.components.p1_monitor.*
homeassistant.components.paj_gps.*
homeassistant.components.panel_custom.*
homeassistant.components.paperless_ngx.*
homeassistant.components.peblar.*
@@ -492,7 +485,6 @@ homeassistant.components.rss_feed_template.*
homeassistant.components.russound_rio.*
homeassistant.components.ruuvi_gateway.*
homeassistant.components.ruuvitag_ble.*
homeassistant.components.samsung_infrared.*
homeassistant.components.samsungtv.*
homeassistant.components.saunum.*
homeassistant.components.scene.*
@@ -565,7 +557,6 @@ homeassistant.components.technove.*
homeassistant.components.tedee.*
homeassistant.components.telegram_bot.*
homeassistant.components.teleinfo.*
homeassistant.components.teltonika.*
homeassistant.components.teslemetry.*
homeassistant.components.text.*
homeassistant.components.thethingsnetwork.*
@@ -610,7 +601,6 @@ homeassistant.components.valve.*
homeassistant.components.velbus.*
homeassistant.components.velux.*
homeassistant.components.victron_gx.*
homeassistant.components.vistapool.*
homeassistant.components.vivotek.*
homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.*
+3 -3
View File
@@ -132,7 +132,7 @@
"problemMatcher": []
},
{
"label": "Install all production Requirements",
"label": "Install all Requirements",
"type": "shell",
"command": "uv pip install -r requirements_all.txt",
"group": {
@@ -146,9 +146,9 @@
"problemMatcher": []
},
{
"label": "Install all (test & production) Requirements",
"label": "Install all Test Requirements",
"type": "shell",
"command": "uv pip install -r requirements_all.txt -r requirements_test.txt",
"command": "uv pip install -r requirements.txt -r requirements_test_all.txt",
"group": {
"kind": "build",
"isDefault": true
-1
View File
@@ -1,6 +1,5 @@
ignore: |
tests/fixtures/core/config/yaml_errors/
.github/workflows/*.lock.yml
rules:
braces:
level: error
+2 -13
View File
@@ -6,16 +6,9 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review
## Pull Requests
- When opening a pull request, use the repository's PR template (`.github/PULL_REQUEST_TEMPLATE.md`). NEVER REMOVE ANYTHING from the template.
- Do not remove checkboxes that are not checked — leave all unchecked checkboxes in place so reviewers can see which options were not selected.
## Development Commands
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
- .vscode/tasks.json contains useful commands used for development.
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
.vscode/tasks.json contains useful commands used for development.
## Python Syntax Notes
@@ -25,14 +18,10 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
## Testing
- Use `uv run pytest` to run tests
- After modifying `strings.json` for an integration, regenerate the English translation file before running tests: `.venv/bin/python3 -m script.translations develop --integration <integration_name>`. Tests load translations from the generated `translations/en.json`, not directly from `strings.json`.
- When writing or modifying tests, ensure all test function parameters have type annotations.
- Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
- Prefer `@pytest.mark.usefixtures` over arguments, if the argument is not going to be used.
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body.
## Good practices
Generated
+14 -37
View File
@@ -68,8 +68,6 @@ CLAUDE.md @home-assistant/core
/tests/components/agent_dvr/ @ispysoftware
/homeassistant/components/ai_task/ @home-assistant/core
/tests/components/ai_task/ @home-assistant/core
/homeassistant/components/aidot/ @s1eedz @HongBryan
/tests/components/aidot/ @s1eedz @HongBryan
/homeassistant/components/air_quality/ @home-assistant/core
/tests/components/air_quality/ @home-assistant/core
/homeassistant/components/airgradient/ @airgradienthq @joostlek
@@ -198,7 +196,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/autoskope/ @mcisk
/tests/components/autoskope/ @mcisk
/homeassistant/components/avea/ @pattyland
/tests/components/avea/ @pattyland
/homeassistant/components/awair/ @ahayworth @ricohageman
/tests/components/awair/ @ahayworth @ricohageman
/homeassistant/components/aws_s3/ @tomasbedrich
@@ -236,8 +233,8 @@ CLAUDE.md @home-assistant/core
/tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx
/homeassistant/components/blink/ @fronzbot
/tests/components/blink/ @fronzbot
/homeassistant/components/blue_current/ @gleeuwen @jtodorova23
/tests/components/blue_current/ @gleeuwen @jtodorova23
/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
/tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
/homeassistant/components/bluemaestro/ @bdraco
/tests/components/bluemaestro/ @bdraco
/homeassistant/components/blueprint/ @home-assistant/core
@@ -291,16 +288,12 @@ CLAUDE.md @home-assistant/core
/tests/components/cast/ @emontnemery
/homeassistant/components/ccm15/ @ocalvo
/tests/components/ccm15/ @ocalvo
/homeassistant/components/centriconnect/ @gresrun
/tests/components/centriconnect/ @gresrun
/homeassistant/components/cert_expiry/ @jjlawren
/tests/components/cert_expiry/ @jjlawren
/homeassistant/components/chacon_dio/ @cnico
/tests/components/chacon_dio/ @cnico
/homeassistant/components/chess_com/ @joostlek
/tests/components/chess_com/ @joostlek
/homeassistant/components/cielo_home/ @ihsan-cielo @mudasar-cielo
/tests/components/cielo_home/ @ihsan-cielo @mudasar-cielo
/homeassistant/components/cisco_ios/ @fbradyirl
/homeassistant/components/cisco_mobility_express/ @fbradyirl
/homeassistant/components/cisco_webex_teams/ @fbradyirl
@@ -352,8 +345,6 @@ CLAUDE.md @home-assistant/core
/tests/components/cync/ @Kinachi249
/homeassistant/components/daikin/ @fredrike
/tests/components/daikin/ @fredrike
/homeassistant/components/data_grand_lyon/ @Crocmagnon
/tests/components/data_grand_lyon/ @Crocmagnon
/homeassistant/components/date/ @home-assistant/core
/tests/components/date/ @home-assistant/core
/homeassistant/components/datetime/ @home-assistant/core
@@ -697,8 +688,6 @@ CLAUDE.md @home-assistant/core
/tests/components/growatt_server/ @johanzander
/homeassistant/components/guardian/ @bachya
/tests/components/guardian/ @bachya
/homeassistant/components/guntamatic/ @JensTimmerman
/tests/components/guntamatic/ @JensTimmerman
/homeassistant/components/habitica/ @tr4nt0r
/tests/components/habitica/ @tr4nt0r
/homeassistant/components/hanna/ @bestycame
@@ -945,6 +934,8 @@ CLAUDE.md @home-assistant/core
/tests/components/knx/ @Julius2342 @farmio @marvin-w
/homeassistant/components/kodi/ @OnFreund
/tests/components/kodi/ @OnFreund
/homeassistant/components/konnected/ @heythisisnate
/tests/components/konnected/ @heythisisnate
/homeassistant/components/kostal_plenticore/ @stegm
/tests/components/kostal_plenticore/ @stegm
/homeassistant/components/kraken/ @eifinger
@@ -981,8 +972,8 @@ CLAUDE.md @home-assistant/core
/tests/components/lektrico/ @lektrico
/homeassistant/components/letpot/ @jpelgrom
/tests/components/letpot/ @jpelgrom
/homeassistant/components/lg_infrared/ @abmantis
/tests/components/lg_infrared/ @abmantis
/homeassistant/components/lg_infrared/ @home-assistant/core
/tests/components/lg_infrared/ @home-assistant/core
/homeassistant/components/lg_netcast/ @Drafteed @splinter98
/tests/components/lg_netcast/ @Drafteed @splinter98
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
@@ -1045,8 +1036,6 @@ CLAUDE.md @home-assistant/core
/tests/components/lyric/ @timmo001
/homeassistant/components/madvr/ @iloveicedgreentea
/tests/components/madvr/ @iloveicedgreentea
/homeassistant/components/marantz_infrared/ @balloob
/tests/components/marantz_infrared/ @balloob
/homeassistant/components/mastodon/ @fabaff @andrew-codechimp
/tests/components/mastodon/ @fabaff @andrew-codechimp
/homeassistant/components/matrix/ @PaarthShah
@@ -1309,8 +1298,6 @@ CLAUDE.md @home-assistant/core
/tests/components/osoenergy/ @osohotwateriot
/homeassistant/components/otbr/ @home-assistant/core
/tests/components/otbr/ @home-assistant/core
/homeassistant/components/ouman_eh_800/ @Markus98
/tests/components/ouman_eh_800/ @Markus98
/homeassistant/components/ourgroceries/ @OnFreund
/tests/components/ourgroceries/ @OnFreund
/homeassistant/components/overkiz/ @imicknl
@@ -1321,8 +1308,6 @@ CLAUDE.md @home-assistant/core
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas
/tests/components/p1_monitor/ @klaasnicolaas
/homeassistant/components/paj_gps/ @skipperro
/tests/components/paj_gps/ @skipperro
/homeassistant/components/palazzetti/ @dotvav
/tests/components/palazzetti/ @dotvav
/homeassistant/components/panel_custom/ @home-assistant/frontend
@@ -1411,8 +1396,8 @@ CLAUDE.md @home-assistant/core
/tests/components/pushover/ @engrbm87
/homeassistant/components/pvoutput/ @frenck
/tests/components/pvoutput/ @frenck
/homeassistant/components/pvpc_hourly_pricing/ @azogue @chiro79
/tests/components/pvpc_hourly_pricing/ @azogue @chiro79
/homeassistant/components/pvpc_hourly_pricing/ @azogue
/tests/components/pvpc_hourly_pricing/ @azogue
/homeassistant/components/pyload/ @tr4nt0r
/tests/components/pyload/ @tr4nt0r
/homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39
@@ -1510,8 +1495,8 @@ CLAUDE.md @home-assistant/core
/tests/components/roku/ @ctalkington
/homeassistant/components/romy/ @xeniter
/tests/components/romy/ @xeniter
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
/homeassistant/components/roon/ @pavoni
/tests/components/roon/ @pavoni
/homeassistant/components/route_b_smart_meter/ @SeraphicRav
@@ -1534,10 +1519,8 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/sabnzbd/ @shaiu @jpbede
/tests/components/sabnzbd/ @shaiu @jpbede
/homeassistant/components/saj/ @fredericvl
/homeassistant/components/samsung_infrared/ @lmaertin
/tests/components/samsung_infrared/ @lmaertin
/homeassistant/components/samsungtv/ @chemelli74
/tests/components/samsungtv/ @chemelli74
/homeassistant/components/samsungtv/ @chemelli74 @epenet
/tests/components/samsungtv/ @chemelli74 @epenet
/homeassistant/components/sanix/ @tomaszsluszniak
/tests/components/sanix/ @tomaszsluszniak
/homeassistant/components/satel_integra/ @Tommatheussen
@@ -1930,8 +1913,6 @@ CLAUDE.md @home-assistant/core
/tests/components/victron_remote_monitoring/ @AndyTempel
/homeassistant/components/vilfo/ @ManneW
/tests/components/vilfo/ @ManneW
/homeassistant/components/vistapool/ @fdebrus
/tests/components/vistapool/ @fdebrus
/homeassistant/components/vivotek/ @HarlemSquirrel
/tests/components/vivotek/ @HarlemSquirrel
/homeassistant/components/vizio/ @raman325
@@ -2036,8 +2017,6 @@ CLAUDE.md @home-assistant/core
/tests/components/xiaomi_miio/ @rytilahti @syssi @starkillerOG
/homeassistant/components/xiaomi_tv/ @simse
/homeassistant/components/xmpp/ @fabaff @flowolf
/homeassistant/components/xthings_cloud/ @XthingsJacobs
/tests/components/xthings_cloud/ @XthingsJacobs
/homeassistant/components/yale/ @bdraco
/tests/components/yale/ @bdraco
/homeassistant/components/yale_smart_alarm/ @gjohansson-ST
@@ -2056,8 +2035,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/yi/ @bachya
/homeassistant/components/yolink/ @matrixd2
/tests/components/yolink/ @matrixd2
/homeassistant/components/yoto/ @cdnninja @piitaya
/tests/components/yoto/ @cdnninja @piitaya
/homeassistant/components/youless/ @gjong
/tests/components/youless/ @gjong
/homeassistant/components/youtube/ @joostlek
@@ -2070,8 +2047,8 @@ CLAUDE.md @home-assistant/core
/tests/components/zeroconf/ @bdraco
/homeassistant/components/zerproc/ @emlove
/tests/components/zerproc/ @emlove
/homeassistant/components/zeversolar/ @kvanzuijlen @mhuiskes
/tests/components/zeversolar/ @kvanzuijlen @mhuiskes
/homeassistant/components/zeversolar/ @kvanzuijlen
/tests/components/zeversolar/ @kvanzuijlen
/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/homeassistant/components/zimi/ @markhannon
Generated
+2 -2
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile@sha256:2780b5c3bab67f1f76c781860de469442999ed1a0d7992a5efdf2cffc0e3d769
# Partly generated by hassfest.
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM
@@ -26,7 +26,7 @@ WORKDIR /usr/src
COPY rootfs /
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc:1.9.14@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
## Setup Home Assistant Core dependencies
COPY --parents requirements.txt homeassistant/package_constraints.txt homeassistant/
+5 -7
View File
@@ -73,12 +73,10 @@ async def auth_manager_from_config(
provider_hash[key] = provider
if isinstance(provider, HassAuthProvider):
# Can be removed in 2026.7 with the legacy mode of
# homeassistant auth provider.
# We need to initialize the provider to create the repair
# if needed as otherwise the provider will be initialized
# on first use, which could be rare as users don't
# frequently change auth settings
# Can be removed in 2026.7 with the legacy mode of homeassistant auth provider
# We need to initialize the provider to create the repair if needed as otherwise
# the provider will be initialized on first use, which could be rare as users
# don't frequently change auth settings
await provider.async_initialize()
if module_configs:
@@ -134,7 +132,7 @@ class AuthManagerFlowManager(
"""
flow = cast(LoginFlow, flow)
if result["type"] is not FlowResultType.CREATE_ENTRY:
if result["type"] != FlowResultType.CREATE_ENTRY:
return result
# we got final result
@@ -120,10 +120,9 @@ class Data:
if self.normalize_username(username, force_normalize=True) != username:
logging.getLogger(__name__).warning(
(
"Home Assistant auth provider is running in"
" legacy mode because we detected usernames"
" that are normalized (lowercase and without"
" spaces). Please change the username: '%s'."
"Home Assistant auth provider is running in legacy mode "
"because we detected usernames that are normalized (lowercase and without spaces)."
" Please change the username: '%s'."
),
username,
)
@@ -140,9 +139,7 @@ class Data:
severity=ir.IssueSeverity.WARNING,
translation_key="homeassistant_provider_not_normalized_usernames",
translation_placeholders={
"usernames": (
f'- "{'"\n- "'.join(sorted(not_normalized_usernames))}"'
)
"usernames": f'- "{'"\n- "'.join(sorted(not_normalized_usernames))}"'
},
learn_more_url="homeassistant://config/users",
)
+2 -6
View File
@@ -60,10 +60,7 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent |
def _clear_configuration_directory(config_dir: Path, keep: Iterable[str]) -> None:
"""Delete all files and directories in the config dir.
Entries in the keep list are preserved.
"""
"""Delete all files and directories in the config directory except entries in the keep list."""
keep_paths = [config_dir.joinpath(path) for path in keep]
entries_to_remove = sorted(
entry for entry in config_dir.iterdir() if entry not in keep_paths
@@ -104,8 +101,7 @@ def _extract_backup(
)
) > HA_VERSION:
raise ValueError(
f"You need at least Home Assistant version"
f" {backup_meta_version} to restore this backup"
f"You need at least Home Assistant version {backup_meta_version} to restore this backup"
)
with securetar.SecureTarFile(
+5 -10
View File
@@ -17,8 +17,7 @@ from time import monotonic
from typing import TYPE_CHECKING, Any
# Import cryptography early since import openssl is not thread-safe
# _frozen_importlib._DeadlockError: deadlock detected by
# _ModuleLock('cryptography.hazmat.backends.openssl.backend')
# _frozen_importlib._DeadlockError: deadlock detected by _ModuleLock('cryptography.hazmat.backends.openssl.backend')
import cryptography.hazmat.backends.openssl.backend # noqa: F401
import voluptuous as vol
import yarl
@@ -166,14 +165,10 @@ FRONTEND_INTEGRATIONS = {
# visible in frontend
"frontend",
}
# Stage 0 is divided into substages. Each substage has a name,
# a set of integrations and a timeout.
# The substage containing recorder should have no timeout, as it
# could cancel a database migration.
# Recorder freezes "recorder" timeout during a migration, but it
# does not freeze other timeouts.
# If we add timeouts to the frontend substages, we should make sure
# they don't apply in recovery mode.
# Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout.
# The substage containing recorder should have no timeout, as it could cancel a database migration.
# Recorder freezes "recorder" timeout during a migration, but it does not freeze other timeouts.
# If we add timeouts to the frontend substages, we should make sure they don't apply in recovery mode.
STAGE_0_INTEGRATIONS = (
# Load logging and http deps as soon as possible
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None),
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "marantz",
"name": "Marantz",
"integrations": ["marantz", "marantz_infrared"]
}
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "mitsubishi",
"name": "Mitsubishi",
"integrations": ["melcloud", "mitsubishi_comfort"]
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"domain": "samsung",
"name": "Samsung",
"integrations": ["familyhub", "samsung_infrared", "samsungtv", "syncthru"]
"integrations": ["familyhub", "samsungtv", "syncthru"]
}
@@ -44,7 +44,6 @@ def _change_setting(call: ServiceCall) -> None:
try:
_get_abode_system(call.hass).abode.set_setting(setting, value)
# pylint: disable-next=home-assistant-action-swallowed-exception
except AbodeException as ex:
LOGGER.warning(ex)
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.8.0"]
"requirements": ["serialx==1.7.0"]
}
@@ -79,12 +79,6 @@
"exceptions": {
"config_entry_not_loaded": {
"message": "Config entry not loaded."
},
"error_while_turn_off": {
"message": "An error occurred while turning off AdGuard Home switch."
},
"error_while_turn_on": {
"message": "An error occurred while turning on AdGuard Home switch."
}
},
"services": {
+5 -12
View File
@@ -9,11 +9,10 @@ from adguardhome import AdGuardHome, AdGuardHomeError
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdGuardConfigEntry, AdGuardData
from .const import DOMAIN
from .const import DOMAIN, LOGGER
from .entity import AdGuardHomeEntity
SCAN_INTERVAL = timedelta(seconds=10)
@@ -117,23 +116,17 @@ class AdGuardHomeSwitch(AdGuardHomeEntity, SwitchEntity):
"""Turn off the switch."""
try:
await self.entity_description.turn_off_fn(self.adguard)()
except AdGuardHomeError as err:
except AdGuardHomeError:
LOGGER.error("An error occurred while turning off AdGuard Home switch")
self._attr_available = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="error_while_turn_off",
) from err
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the switch."""
try:
await self.entity_description.turn_on_fn(self.adguard)()
except AdGuardHomeError as err:
except AdGuardHomeError:
LOGGER.error("An error occurred while turning on AdGuard Home switch")
self._attr_available = False
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="error_while_turn_on",
) from err
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
+1 -2
View File
@@ -106,8 +106,7 @@ class AdsLight(AdsEntity, LightEntity):
self._attr_supported_color_modes = filter_supported_color_modes(color_modes)
self._attr_color_mode = next(iter(self._attr_supported_color_modes))
# Set color temperature range
# (static config values take precedence over defaults)
# Set color temperature range (static config values take precedence over defaults)
if ads_var_color_temp_kelvin is not None:
self._attr_min_color_temp_kelvin = (
min_color_temp_kelvin
-1
View File
@@ -19,7 +19,6 @@ from .hub import AdsHub
DEFAULT_NAME = "ADS select"
# pylint: disable-next=home-assistant-duplicate-const
CONF_OPTIONS = "options"
PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend(
@@ -171,8 +171,7 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
@property
def target_temperature(self) -> float | None:
"""Return the current target temperature."""
# If the system is in MyZone mode, and a zone is set,
# return that temperature instead.
# If the system is in MyZone mode, and a zone is set, return that temperature instead.
if self._myzone and self.preset_mode == ADVANTAGE_AIR_MYZONE:
return self._myzone["setTemp"]
return self._ac["setTemp"]
@@ -297,11 +296,7 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
@property
def hvac_action(self) -> HVACAction | None:
"""Return the HVAC action.
Inherits from master AC if zone is open but idle if air
is <= 5%.
"""
"""Return the HVAC action, inheriting from master AC if zone is open but idle if air is <= 5%."""
if self._ac["state"] == ADVANTAGE_AIR_STATE_OFF:
return HVACAction.OFF
master_action = HVAC_ACTIONS.get(self._ac["mode"], HVACAction.OFF)
@@ -59,8 +59,6 @@ class AemetConfigFlow(ConfigFlow, domain=DOMAIN):
schema = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
# Name field is no longer allowed in config flow schemas
# pylint: disable-next=home-assistant-config-flow-name-field
vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
vol.Optional(
CONF_LATITUDE, default=self.hass.config.latitude
@@ -56,7 +56,6 @@ async def async_setup_entry(
)
async_dispatcher_send(hass, UPDATE_TOPIC)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_ADD_TRACKING,
@@ -72,7 +71,6 @@ async def async_setup_entry(
)
async_dispatcher_send(hass, UPDATE_TOPIC)
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
SERVICE_REMOVE_TRACKING,
@@ -1,25 +0,0 @@
"""The aidot integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AidotConfigEntry, AidotDeviceManagerCoordinator
PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: AidotConfigEntry) -> bool:
"""Set up aidot from a config entry."""
coordinator = AidotDeviceManagerCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
return True
async def async_unload_entry(hass: HomeAssistant, entry: AidotConfigEntry) -> bool:
"""Unload a config entry."""
await entry.runtime_data.async_cleanup()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,66 +0,0 @@
"""Config flow for Aidot integration."""
from typing import Any
from aidot.client import AidotClient
from aidot.const import CONF_ID, DEFAULT_COUNTRY_CODE, SUPPORTED_COUNTRY_CODES
from aidot.exceptions import AidotUserOrPassIncorrect
from aiohttp import ClientError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import selector
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
DATA_SCHEMA = vol.Schema(
{
vol.Required(
CONF_COUNTRY_CODE,
default=DEFAULT_COUNTRY_CODE,
): selector.CountrySelector(
selector.CountrySelectorConfig(
countries=SUPPORTED_COUNTRY_CODES,
)
),
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class AidotConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle aidot config flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
client = AidotClient(
session=async_get_clientsession(self.hass),
country_code=user_input[CONF_COUNTRY_CODE],
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
)
try:
login_info = await client.async_post_login()
except AidotUserOrPassIncorrect:
errors["base"] = "invalid_auth"
except TimeoutError, ClientError:
errors["base"] = "cannot_connect"
if not errors:
await self.async_set_unique_id(login_info[CONF_ID])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"{user_input[CONF_USERNAME]} {user_input[CONF_COUNTRY_CODE]}",
data=login_info,
)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
-3
View File
@@ -1,3 +0,0 @@
"""Constants for the aidot integration."""
DOMAIN = "aidot"
@@ -1,163 +0,0 @@
"""Coordinator for Aidot."""
from datetime import timedelta
import logging
from aidot.client import AidotClient
from aidot.const import (
CONF_ACCESS_TOKEN,
CONF_AES_KEY,
CONF_DEVICE_LIST,
CONF_ID,
CONF_TYPE,
)
from aidot.device_client import DeviceClient, DeviceStatusData
from aidot.exceptions import AidotAuthFailed, AidotUserOrPassIncorrect
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
type AidotConfigEntry = ConfigEntry[AidotDeviceManagerCoordinator]
_LOGGER = logging.getLogger(__name__)
UPDATE_DEVICE_LIST_INTERVAL = timedelta(hours=6)
class AidotDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceStatusData]):
"""Class to manage Aidot data."""
def __init__(
self,
hass: HomeAssistant,
config_entry: AidotConfigEntry,
device_client: DeviceClient,
) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=None,
)
self.device_client = device_client
async def _async_setup(self) -> None:
"""Set up the coordinator."""
self.device_client.on_status_update = self._handle_status_update
def _handle_status_update(self, status: DeviceStatusData) -> None:
"""Handle status callback."""
self.async_set_updated_data(status)
async def _async_update_data(self) -> DeviceStatusData:
"""Return current status."""
return self.device_client.status
class AidotDeviceManagerCoordinator(DataUpdateCoordinator[None]):
"""Class to manage fetching Aidot data."""
config_entry: AidotConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: AidotConfigEntry,
) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=UPDATE_DEVICE_LIST_INTERVAL,
)
self.client = AidotClient(
session=async_get_clientsession(hass),
token=config_entry.data,
)
self.client.set_token_fresh_cb(self.token_fresh_cb)
self.device_coordinators: dict[str, AidotDeviceUpdateCoordinator] = {}
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
await self.async_auto_login()
except AidotUserOrPassIncorrect as error:
raise ConfigEntryError from error
async def _async_update_data(self) -> None:
"""Update data async."""
try:
data = await self.client.async_get_all_device()
except AidotAuthFailed as error:
raise ConfigEntryError from error
current_devices = {
device[CONF_ID]: device
for device in data[CONF_DEVICE_LIST]
if (
device[CONF_TYPE] == "light"
and CONF_AES_KEY in device
and device[CONF_AES_KEY][0] is not None
)
}
removed_ids = set(self.device_coordinators) - set(current_devices)
for dev_id in removed_ids:
coordinator = self.device_coordinators.pop(dev_id)
coordinator.device_client.on_status_update = None
if removed_ids:
self._purge_deleted_lists()
for dev_id, device in current_devices.items():
if dev_id not in self.device_coordinators:
device_client = self.client.get_device_client(device)
device_coordinator = AidotDeviceUpdateCoordinator(
self.hass, self.config_entry, device_client
)
await device_coordinator.async_config_entry_first_refresh()
self.device_coordinators[dev_id] = device_coordinator
async def async_cleanup(self) -> None:
"""Perform cleanup actions."""
for coordinator in self.device_coordinators.values():
coordinator.device_client.on_status_update = None
await self.client.async_cleanup()
def token_fresh_cb(self) -> None:
"""Update token."""
self.hass.config_entries.async_update_entry(
self.config_entry, data=self.client.login_info.copy()
)
async def async_auto_login(self) -> None:
"""Async auto login."""
if self.client.login_info.get(CONF_ACCESS_TOKEN) is None:
await self.client.async_post_login()
def _purge_deleted_lists(self) -> None:
"""Purge device entries of deleted lists."""
device_reg = dr.async_get(self.hass)
identifiers = {
(
DOMAIN,
device_coordinator.device_client.info.dev_id,
)
for device_coordinator in self.device_coordinators.values()
}
for device in dr.async_entries_for_config_entry(
device_reg, self.config_entry.entry_id
):
if not set(device.identifiers) & identifiers:
_LOGGER.debug("Removing obsolete device entry %s", device.name)
device_reg.async_update_device(
device.id, remove_config_entry_id=self.config_entry.entry_id
)
-122
View File
@@ -1,122 +0,0 @@
"""Support for Aidot lights."""
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGBW_COLOR,
ColorMode,
LightEntity,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AidotConfigEntry, AidotDeviceUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: AidotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Light."""
coordinator = entry.runtime_data
async_add_entities(
AidotLight(device_coordinator)
for device_coordinator in coordinator.device_coordinators.values()
)
class AidotLight(CoordinatorEntity[AidotDeviceUpdateCoordinator], LightEntity):
"""Representation of a Aidot Wi-Fi Light."""
_attr_has_entity_name = True
_attr_name = None
def __init__(self, coordinator: AidotDeviceUpdateCoordinator) -> None:
"""Initialize the light."""
super().__init__(coordinator)
self._attr_unique_id = coordinator.device_client.info.dev_id
if hasattr(coordinator.device_client.info, "cct_max"):
self._attr_max_color_temp_kelvin = coordinator.device_client.info.cct_max
if hasattr(coordinator.device_client.info, "cct_min"):
self._attr_min_color_temp_kelvin = coordinator.device_client.info.cct_min
model_id = coordinator.device_client.info.model_id
manufacturer = model_id.split(".")[0]
model = model_id[len(manufacturer) + 1 :]
mac = coordinator.device_client.info.mac
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
connections={(CONNECTION_NETWORK_MAC, mac)},
manufacturer=manufacturer,
model=model,
name=coordinator.device_client.info.name,
hw_version=coordinator.device_client.info.hw_version,
)
if coordinator.device_client.info.enable_rgbw:
self._attr_color_mode = ColorMode.RGBW
self._attr_supported_color_modes = {ColorMode.RGBW, ColorMode.COLOR_TEMP}
elif coordinator.device_client.info.enable_cct:
self._attr_color_mode = ColorMode.COLOR_TEMP
self._attr_supported_color_modes = {ColorMode.COLOR_TEMP}
else:
self._attr_color_mode = ColorMode.BRIGHTNESS
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
self._update_status()
def _update_status(self) -> None:
"""Update light status from coordinator data."""
self._attr_is_on = self.coordinator.data.on
self._attr_brightness = self.coordinator.data.dimming
self._attr_color_temp_kelvin = self.coordinator.data.cct
self._attr_rgbw_color = self.coordinator.data.rgbw
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.data.online
@callback
def _handle_coordinator_update(self) -> None:
"""Update."""
self._update_status()
super()._handle_coordinator_update()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on, applying brightness, color temperature, RGBW, or plain on."""
if ATTR_BRIGHTNESS in kwargs:
brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
await self.coordinator.device_client.async_set_brightness(brightness)
self.coordinator.data.dimming = brightness
self._attr_brightness = brightness
elif ATTR_COLOR_TEMP_KELVIN in kwargs:
color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
await self.coordinator.device_client.async_set_cct(color_temp_kelvin)
self.coordinator.data.cct = color_temp_kelvin
self._attr_color_temp_kelvin = color_temp_kelvin
self._attr_color_mode = ColorMode.COLOR_TEMP
elif ATTR_RGBW_COLOR in kwargs:
rgbw_color = kwargs.get(ATTR_RGBW_COLOR)
await self.coordinator.device_client.async_set_rgbw(rgbw_color)
self.coordinator.data.rgbw = rgbw_color
self._attr_rgbw_color = rgbw_color
self._attr_color_mode = ColorMode.RGBW
else:
await self.coordinator.device_client.async_turn_on()
self.coordinator.data.on = True
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self.coordinator.device_client.async_turn_off()
self.coordinator.data.on = False
self._attr_is_on = False
self.async_write_ha_state()
@@ -1,11 +0,0 @@
{
"domain": "aidot",
"name": "AiDot",
"codeowners": ["@s1eedz", "@HongBryan"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aidot",
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["python-aidot==0.3.53"]
}
@@ -1,67 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: This integration does not register any events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration has no option flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
entity-disabled-by-default: todo
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: todo
@@ -1,25 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"step": {
"user": {
"data": {
"country_code": "Country",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"country_code": "The country selected by AiDot app when logging in",
"password": "Password for logging in through AiDot app",
"username": "Account logged in through AiDot app"
}
}
}
}
}
+36 -58
View File
@@ -68,24 +68,20 @@ TRIGGERS: dict[str, type[Trigger]] = {
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"co_crossed_threshold": (
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
)
"co_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"ozone_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"ozone_crossed_threshold": (
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
)
"ozone_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"voc_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
@@ -96,16 +92,14 @@ TRIGGERS: dict[str, type[Trigger]] = {
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
),
"voc_crossed_threshold": (
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
)
"voc_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
),
"voc_ratio_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
@@ -116,60 +110,44 @@ TRIGGERS: dict[str, type[Trigger]] = {
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
),
"voc_ratio_crossed_threshold": (
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
)
"voc_ratio_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
),
"no_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"no_crossed_threshold": (
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.NITROGEN_MONOXIDE
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
)
"no_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"no2_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"no2_crossed_threshold": (
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: DomainSpec(
device_class=SensorDeviceClass.NITROGEN_DIOXIDE
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
)
"no2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"so2_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
"so2_crossed_threshold": (
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
)
"so2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
# Numerical sensor triggers without unit conversion (single-unit device classes)
"co2_changed": make_entity_numerical_state_changed_trigger(
+1 -2
View File
@@ -184,8 +184,7 @@ async def async_setup_entry(
(
AirlySensor(coordinator, name, description)
for description in SENSOR_TYPES
# When we use the nearest method, we are not sure
# which sensors are available
# When we use the nearest method, we are not sure which sensors are available
if coordinator.data.get(description.key)
),
False,
@@ -1,67 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling:
status: done
comment: Reports are polled every 30 minutes so newly published hourly AirNow reports are picked up promptly.
brands: done
common-modules: done
config-flow-test-coverage: todo
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: todo
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: todo
diagnostics: done
discovery: todo
discovery-update-info: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class:
status: todo
comment: The ozone sensor can still use the ozone device class.
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
stale-devices: todo
repair-issues: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
+1 -3
View File
@@ -75,9 +75,7 @@ def aqi_extra_attrs(data: dict[str, Any]) -> dict[str, Any]:
ATTR_DESCR: data[ATTR_API_AQI_DESCRIPTION],
ATTR_LEVEL: data[ATTR_API_AQI_LEVEL],
ATTR_TIME: parser.parse(
f"{data[ATTR_API_REPORT_DATE]} "
f"{data[ATTR_API_REPORT_HOUR]}:00 "
f"{data[ATTR_API_REPORT_TZ]}",
f"{data[ATTR_API_REPORT_DATE]} {data[ATTR_API_REPORT_HOUR]}:00 {data[ATTR_API_REPORT_TZ]}",
tzinfos=US_TZ_OFFSETS,
).isoformat(),
}
@@ -46,9 +46,6 @@
"init": {
"data": {
"radius": "Station radius (miles)"
},
"data_description": {
"radius": "The radius in miles around your location to search for reporting stations."
}
}
}
@@ -83,7 +83,6 @@ class AirobotButton(AirobotEntity, ButtonEntity):
"""Handle the button press."""
try:
await self.entity_description.press_fn(self.coordinator)
# pylint: disable-next=home-assistant-action-swallowed-exception
except AirobotConnectionError, AirobotTimeoutError:
# Connection errors during reboot are expected as device restarts
pass
@@ -81,10 +81,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
) as err:
raise ConfigEntryAuthFailed from err
except AirOSKeyDataMissingError as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryError("key_data_missing") from err
except Exception as err:
# pylint: disable-next=home-assistant-exception-not-translated
raise ConfigEntryError("unknown") from err
airos_class: type[AirOS8 | AirOS6] = (
@@ -2,6 +2,7 @@
from collections.abc import Callable
from dataclasses import dataclass
from typing import Generic, TypeVar
from airos.data import AirOSDataBaseClass
@@ -19,10 +20,13 @@ from .entity import AirOSEntity
PARALLEL_UPDATES = 0
AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass)
@dataclass(frozen=True, kw_only=True)
class AirOSBinarySensorEntityDescription[AirOSDataModel: AirOSDataBaseClass](
class AirOSBinarySensorEntityDescription(
BinarySensorEntityDescription,
Generic[AirOSDataModel],
):
"""Describe an AirOS binary sensor."""
+4 -3
View File
@@ -3,6 +3,7 @@
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Generic, TypeVar
from airos.data import (
AirOSDataBaseClass,
@@ -40,11 +41,11 @@ WIRELESS_ROLE_OPTIONS = [mode.value for mode in DerivedWirelessRole]
PARALLEL_UPDATES = 0
AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass)
@dataclass(frozen=True, kw_only=True)
class AirOSSensorEntityDescription[AirOSDataModel: AirOSDataBaseClass](
SensorEntityDescription
):
class AirOSSensorEntityDescription(SensorEntityDescription, Generic[AirOSDataModel]):
"""Describe an AirOS sensor."""
value_fn: Callable[[AirOSDataModel], StateType]
+1 -2
View File
@@ -54,8 +54,7 @@ class AirQCoordinator(DataUpdateCoordinator):
"""Fetch the data from the device."""
if "name" not in self.device_info:
_LOGGER.debug(
"'name' not found in AirQCoordinator.device_info,"
" fetching from the device"
"'name' not found in AirQCoordinator.device_info, fetching from the device"
)
info = await self.airq.fetch_device_info()
self.device_info.update(
@@ -158,8 +158,7 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
await self._airtouch.SetCoolingModeForAc(
self._ac_number, HA_STATE_TO_AT[hvac_mode]
)
# in case it isn't already, unless the HVAC mode was off,
# then the ac should be on
# in case it isn't already, unless the HVAC mode was off, then the ac should be on
await self.async_turn_on()
self._unit = self._airtouch.GetAcs()[self._ac_number]
_LOGGER.debug("Setting operation mode of %s to %s", self._ac_number, hvac_mode)
@@ -247,8 +246,7 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac target hvac state."""
# there are other power states that aren't 'on' but still
# count as on (eg. 'Turbo')
# there are other power states that aren't 'on' but still count as on (eg. 'Turbo')
is_off = self._unit.PowerState == "Off"
if is_off:
return HVACMode.OFF
@@ -178,8 +178,7 @@ class Airtouch5AC(Airtouch5ClimateEntity):
if ability.supports_fan_speed_intelligent_auto:
self._attr_fan_modes.append(FAN_INTELLIGENT_AUTO)
# We can have different setpoints for heat cool,
# we expose the lowest low and highest high
# We can have different setpoints for heat cool, we expose the lowest low and highest high
self._attr_min_temp = min(
ability.min_cool_set_point, ability.min_heat_set_point
)
@@ -291,8 +290,7 @@ class Airtouch5Zone(Airtouch5ClimateEntity):
manufacturer="Polyaire",
model="AirTouch 5",
)
# We can have different setpoints for heat and cool,
# we expose the lowest low and highest high
# We can have different setpoints for heat and cool, we expose the lowest low and highest high
self._attr_min_temp = min(ac.min_cool_set_point, ac.min_heat_set_point)
self._attr_max_temp = max(ac.max_cool_set_point, ac.max_heat_set_point)
@@ -34,9 +34,8 @@ class AirTouch5ConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors = {"base": "cannot_connect"}
else:
# Uses the host/IP value from CONF_HOST as unique ID,
# which is no longer allowed
# pylint: disable-next=home-assistant-unique-id-ip-based
# Uses the host/IP value from CONF_HOST as unique ID, which is no longer allowed
# pylint: disable-next=hass-unique-id-ip-based
await self.async_set_unique_id(user_input[CONF_HOST])
self._abort_if_unique_id_configured()
return self.async_create_entry(
@@ -75,7 +75,7 @@ def async_get_cloud_api_update_interval(
def async_get_cloud_coordinators_by_api_key(
hass: HomeAssistant, api_key: str
) -> list[AirVisualDataUpdateCoordinator]:
"""Get all coordinators related to a particular API key."""
"""Get all AirVisualDataUpdateCoordinator objects related to a particular API key."""
return [
entry.runtime_data
for entry in hass.config_entries.async_entries(DOMAIN)
@@ -24,7 +24,7 @@ class AsyncConfigFlowAuth(Auth):
class AsyncConfigEntryAuth(Auth):
"""Provide Aladdin Connect Genie auth tied to an OAuth2 config entry."""
"""Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry."""
def __init__(
self,
@@ -354,9 +354,8 @@ def _validate_zone_input(zone_input: dict[str, Any] | None) -> dict[str, str]:
def _fix_input_types(zone_input: dict[str, Any]) -> dict[str, Any]:
"""Convert necessary keys to int.
Since ConfigFlow inputs of type int cannot default to an empty
string, we collect the values below as strings and then convert
them to ints.
Since ConfigFlow inputs of type int cannot default to an empty string, we collect the values below as
strings and then convert them to ints.
"""
for key in (CONF_ZONE_LOOP, CONF_RELAY_ADDR, CONF_RELAY_CHAN):
@@ -11,7 +11,7 @@
"service": "mdi:dialpad"
},
"alarm_toggle_chime": {
"service": "mdi:bell-ring"
"service": "mdi:abc"
}
}
}
@@ -39,6 +39,7 @@ from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_SUPPORTED_FEATURES,
ATTR_UNIT_OF_MEASUREMENT,
CLOUD_NEVER_EXPOSED_ENTITIES,
CONF_DESCRIPTION,
CONF_NAME,
UnitOfTemperature,
@@ -372,6 +373,9 @@ def async_get_entities(
"""Return all entities that are supported by Alexa."""
entities: list[AlexaEntity] = []
for state in hass.states.async_all():
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
continue
if state.domain not in ENTITY_ADAPTERS:
continue
+1 -4
View File
@@ -1255,10 +1255,7 @@ async def async_api_set_mode(
service = water_heater.SERVICE_SET_OPERATION_MODE
data[water_heater.ATTR_OPERATION_MODE] = operation_mode
else:
msg = (
f"Entity '{entity.entity_id}' does not support"
f" Operation mode '{operation_mode}'"
)
msg = f"Entity '{entity.entity_id}' does not support Operation mode '{operation_mode}'"
raise AlexaInvalidValueError(msg)
# Cover Position
+1 -2
View File
@@ -224,8 +224,7 @@ def resolve_slot_data(key: str, request: dict[str, Any]) -> dict[str, str]:
resolved_data["id"] = possible_values[0]["id"]
# If there is only one match use the resolved value, otherwise the
# resolution cannot be determined, so use the spoken slot
# value and empty string as id
# resolution cannot be determined, so use the spoken slot value and empty string as id
if len(possible_values) == 1:
resolved_data["value"] = possible_values[0]["name"]
else:
+3 -36
View File
@@ -2,7 +2,6 @@
from asyncio import timeout
from collections.abc import Mapping
from datetime import datetime, timedelta
from http import HTTPStatus
import json
import logging
@@ -12,12 +11,7 @@ from uuid import uuid4
import aiohttp
from homeassistant.components import event
from homeassistant.const import (
EVENT_STATE_CHANGED,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.const import EVENT_STATE_CHANGED, STATE_ON
from homeassistant.core import (
CALLBACK_TYPE,
Event,
@@ -57,25 +51,6 @@ DEFAULT_TIMEOUT = 10
TO_REDACT = {"correlationToken", "token"}
def valid_doorbell_timestamp(entity_id: str, event_state: str) -> bool:
"""Check if doorbell event timestamp is valid."""
if event_state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
try:
timestamp = datetime.fromisoformat(event_state)
except ValueError:
_LOGGER.debug(
"Unable to parse ISO timestamp from state for %s. Got %s",
entity_id,
event_state,
)
return False
else:
if (dt_util.utcnow() - timestamp) < timedelta(seconds=30):
return True
return False
class AlexaDirective:
"""An incoming Alexa directive."""
@@ -340,17 +315,9 @@ async def async_enable_proactive_mode(
if should_doorbell:
old_state = data["old_state"]
if (
new_state.domain == event.DOMAIN
and valid_doorbell_timestamp(new_state.entity_id, new_state.state)
and (old_state is None or old_state.state != STATE_UNAVAILABLE)
and (old_state is None or old_state.state != new_state.state)
) or (
if new_state.domain == event.DOMAIN or (
new_state.state == STATE_ON
and (
old_state is None
or old_state.state not in (STATE_ON, STATE_UNAVAILABLE)
)
and (old_state is None or old_state.state != STATE_ON)
):
await async_send_doorbell_event_message(
hass, smart_home_config, alexa_changed_entity
@@ -1,13 +1,9 @@
"""Alexa Devices integration."""
import asyncio
import contextlib
from homeassistant.const import CONF_COUNTRY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, config_validation as cv, httpx_client
from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
from .const import _LOGGER, CONF_LOGIN_DATA, CONF_SITE, COUNTRY_DOMAINS, DOMAIN
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
@@ -16,7 +12,6 @@ from .services import async_setup_services
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.EVENT,
Platform.NOTIFY,
Platform.SENSOR,
Platform.SWITCH,
@@ -39,27 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
await coordinator.async_config_entry_first_refresh()
await coordinator.sync_history_state()
async def _on_http2_reauth_required() -> None:
entry.async_start_reauth(hass)
async def _cancel_http2() -> None:
http2_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await http2_task
alexa_httpx_client = httpx_client.get_async_client(
hass,
alpn_protocols=SSL_ALPN_HTTP11_HTTP2,
)
http2_task = await coordinator.api.start_http2_processing(
alexa_httpx_client, on_reauth_required=_on_http2_reauth_required
)
entry.async_on_unload(_cancel_http2)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -70,10 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version > 1:
# This means the user has downgraded from a future version
return False
if entry.version == 1 and entry.minor_version < 3:
if CONF_SITE in entry.data:
# Site in data (wrong place), just move to login data
@@ -27,6 +27,9 @@ COUNTRY_DOMAINS = {
"za": "co.za",
}
CATEGORY_SENSORS = "sensors"
CATEGORY_NOTIFICATIONS = "notifications"
# Map service translation keys to Alexa API
INFO_SKILLS_MAPPING = {
"calendar_today": "Alexa.Calendar.PlayToday",
@@ -8,13 +8,13 @@ from aioamazondevices.exceptions import (
CannotConnect,
CannotRetrieveData,
)
from aioamazondevices.structures import AmazonDevice, AmazonVocalRecord
from aioamazondevices.structures import AmazonDevice
from aiohttp import ClientSession
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -73,11 +73,6 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
if routine.domain == Platform.BUTTON
}
self._vocal_records: dict[str, AmazonVocalRecord] = {}
self.api.on_history_event.append(self.history_state_event_handler)
self.api.on_history_event.freeze()
async def _async_update_data(self) -> dict[str, AmazonDevice]:
"""Update device data."""
try:
@@ -154,38 +149,3 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
)
if entity_id:
entity_registry.async_remove(entity_id)
async def sync_history_state(self) -> None:
"""Sync history state."""
try:
self._vocal_records = await self.api.sync_history_state()
except CannotAuthenticate as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(e)},
) from e
except CannotConnect as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect_with_error",
translation_placeholders={"error": repr(e)},
) from e
except BaseException as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(e)},
) from e
async def history_state_event_handler(
self, vocal_records: dict[str, AmazonVocalRecord]
) -> None:
"""Handle pushed vocal record events."""
self._vocal_records = {**self._vocal_records, **vocal_records}
self.async_update_listeners()
@property
def vocal_records(self) -> dict[str, AmazonVocalRecord]:
"""Vocal records of devices."""
return self._vocal_records
@@ -1,86 +0,0 @@
"""Support for events."""
from typing import Final
from homeassistant.components.event import EventEntity, EventEntityDescription
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import _LOGGER
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .entity import AmazonEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
EVENTS: Final = {
EventEntityDescription(
key="voice_event",
translation_key="voice_event",
),
}
EVENT_TYPE = "triggered"
async def async_setup_entry(
hass: HomeAssistant,
entry: AmazonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Alexa Devices events based on a config entry."""
coordinator = entry.runtime_data
known_devices: set[str] = set()
def _check_device() -> None:
current_devices = set(coordinator.data)
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
AlexaVoiceEvent(coordinator, serial_num, event_desc)
for event_desc in EVENTS
for serial_num in new_devices
)
_check_device()
entry.async_on_unload(coordinator.async_add_listener(_check_device))
class AlexaVoiceEvent(AmazonEntity, EventEntity):
"""Representation of an Alexa voice event."""
_attr_event_types = [EVENT_TYPE]
coordinator: AmazonDevicesCoordinator
_last_seen_timestamp: int | None = None
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if not (
vocal_record := self.coordinator.vocal_records.get(
self.device.serial_number
)
):
_LOGGER.debug(
"No vocal record found for device %s [%s]",
self.device.account_name,
self.device.serial_number,
)
return
if vocal_record.timestamp == self._last_seen_timestamp:
return
self._last_seen_timestamp = vocal_record.timestamp
self._trigger_event(
EVENT_TYPE,
{
"intent": vocal_record.intent,
"voice_command": vocal_record.title,
"voice_reply": vocal_record.sub_title,
},
)
self.async_write_ha_state()
@@ -1,10 +1,5 @@
{
"entity": {
"event": {
"voice_event": {
"default": "mdi:chat-processing"
}
},
"sensor": {
"voc_index": {
"default": "mdi:molecule"
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.8.0"]
"requirements": ["aioamazondevices==13.4.3"]
}
@@ -29,7 +29,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
from .const import CATEGORY_NOTIFICATIONS, CATEGORY_SENSORS
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import async_remove_unsupported_notification_sensors
@@ -37,44 +38,30 @@ from .utils import async_remove_unsupported_notification_sensors
PARALLEL_UPDATES = 0
type ValueFn = Callable[
[AmazonDevice, str, AmazonDevicesCoordinator], StateType | datetime
]
@dataclass(frozen=True, kw_only=True)
class AmazonBaseEntityDescription(SensorEntityDescription):
"""Shared Amazon Devices entity description."""
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
device.online
)
value_fn: ValueFn
@dataclass(frozen=True, kw_only=True)
class AmazonSensorEntityDescription(AmazonBaseEntityDescription):
class AmazonSensorEntityDescription(SensorEntityDescription):
"""Amazon Devices sensor entity description."""
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
device.online
and (sensor := device.sensors.get(key)) is not None
and sensor.error is False
)
value_fn: ValueFn = lambda device, key, _: device.sensors[key].value
category: str = CATEGORY_SENSORS
@dataclass(frozen=True, kw_only=True)
class AmazonNotificationEntityDescription(AmazonBaseEntityDescription):
class AmazonNotificationEntityDescription(SensorEntityDescription):
"""Amazon Devices notification entity description."""
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
device.online
and (notification := device.notifications.get(key)) is not None
and notification.next_occurrence is not None
)
value_fn: ValueFn = lambda device, key, _: device.notifications[key].next_occurrence
category: str = CATEGORY_NOTIFICATIONS
SENSORS: Final = (
@@ -206,11 +193,11 @@ class AmazonSensorEntity(AmazonEntity, SensorEntity):
@property
def native_value(self) -> StateType | datetime:
"""Return the state of the sensor."""
return self.entity_description.value_fn(
self.device,
self.entity_description.key,
self.coordinator,
)
# Sensors
if self.entity_description.category == CATEGORY_SENSORS:
return self.device.sensors[self.entity_description.key].value
# Notifications
return self.device.notifications[self.entity_description.key].next_occurrence
@property
def available(self) -> bool:
@@ -58,18 +58,6 @@
}
},
"entity": {
"event": {
"voice_event": {
"name": "Voice event",
"state_attributes": {
"event_type": {
"state": {
"triggered": "Triggered"
}
}
}
}
},
"notify": {
"announce": {
"name": "Announce"
@@ -114,9 +102,6 @@
"entry_not_loaded": {
"message": "Entry not loaded: {entry}"
},
"invalid_auth": {
"message": "Invalid authentication credentials: {error}"
},
"invalid_device_id": {
"message": "Invalid device ID specified: {device_id}"
},
@@ -7,11 +7,10 @@ from altruistclient import AltruistClient, AltruistDeviceModel, AltruistError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
from .const import CONF_HOST, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -1,3 +1,5 @@
"""Constants for the Altruist integration."""
DOMAIN = "altruist"
CONF_HOST = "host"
@@ -10,12 +10,13 @@ import logging
from altruistclient import AltruistClient, AltruistError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_HOST
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(seconds=15)
@@ -21,8 +21,7 @@ API_URL = "https://app.amber.com.au/developers"
def generate_site_selector_name(site: Site) -> str:
"""Generate the name to show in the site drop down in the configuration flow."""
# For some reason the generated API key returns this as any,
# not a string. Thanks pydantic
# For some reason the generated API key returns this as any, not a string. Thanks pydantic
nmi = str(site.nmi)
if site.status == SiteStatus.CLOSED:
if site.closed_on is None:
@@ -48,7 +48,7 @@ def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) ->
class AmberUpdateCoordinator(DataUpdateCoordinator):
"""Coordinator in charge of downloading site data for all sensors."""
"""AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read."""
config_entry: AmberConfigEntry
@@ -14,10 +14,7 @@ DESCRIPTOR_MAP: dict[str, str] = {
def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None:
"""Return the snake case versions of descriptor names.
Returns None if the name is not recognized.
"""
"""Return the snake case versions of descriptor names. Returns None if the name is not recognized."""
if descriptor in DESCRIPTOR_MAP:
return DESCRIPTOR_MAP[descriptor]
return None
@@ -26,5 +26,4 @@ def get_station_name(station: dict[str, Any]) -> str:
.get(API_STATION_LOCATION)
)
station_type = station.get(API_LAST_DATA, {}).get(API_STATION_TYPE)
separator = "" if location is None or station_type is None else " "
return f"{location}{separator}{station_type}"
return f"{location}{'' if location is None or station_type is None else ' '}{station_type}"
@@ -192,8 +192,7 @@ class AmcrestBinarySensor(BinarySensorEntity):
if self._api.available:
# Send a command to the camera to test if we can still communicate with it.
# Override of Http.async_command() in __init__.py will
# set self._api.available
# Override of Http.async_command() in __init__.py will set self._api.available
# accordingly.
with suppress(AmcrestError):
await self._api.async_current_time
+2 -4
View File
@@ -461,8 +461,7 @@ class AmcrestCam(Camera):
async def _async_set_recording(self, enable: bool) -> None:
rec_mode = {"Automatic": 0, "Manual": 1}
# The property has a str type, but setter has int type,
# which causes mypy confusion
# The property has a str type, but setter has int type, which causes mypy confusion
await self._api.async_set_record_mode(
rec_mode["Manual" if enable else "Automatic"]
)
@@ -480,8 +479,7 @@ class AmcrestCam(Camera):
return await self._api.async_is_motion_detector_on()
async def _async_set_motion_detection(self, enable: bool) -> None:
# The property has a str type, but setter has bool type,
# which causes mypy confusion
# The property has a str type, but setter has bool type, which causes mypy confusion
await self._api.async_set_motion_detection(enable)
async def _async_enable_motion_detection(self, enable: bool) -> None:
+18 -52
View File
@@ -5,12 +5,8 @@ from typing import Any
import voluptuous as vol
from homeassistant.components import labs, websocket_api
from homeassistant.components.hassio import HassioNotReadyError
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.start import async_at_started
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
@@ -53,7 +49,6 @@ CONFIG_SCHEMA = vol.Schema(
)
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
_DATA_SNAPSHOTS_URL: HassKey[str | None] = HassKey(f"{DOMAIN}_snapshots_url")
LABS_SNAPSHOT_FEATURE = "snapshots"
@@ -62,39 +57,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the analytics integration."""
analytics_config = config.get(DOMAIN, {})
snapshots_url: str | None = None
if CONF_SNAPSHOTS_URL in analytics_config:
await labs.async_update_preview_feature(
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True
)
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
else:
snapshots_url = None
hass.data[_DATA_SNAPSHOTS_URL] = snapshots_url
discovery_flow.async_create_flow(
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
)
websocket_api.async_register_command(hass, websocket_analytics)
websocket_api.async_register_command(hass, websocket_analytics_preferences)
hass.http.register_view(AnalyticsDevicesView)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Analytics from a config entry."""
snapshots_url = hass.data.get(_DATA_SNAPSHOTS_URL)
analytics = Analytics(hass, snapshots_url)
try:
await analytics.load()
except HassioNotReadyError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="supervisor_not_ready",
) from err
# Load stored data
await analytics.load()
started = False
@@ -106,30 +80,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if started:
await analytics.async_schedule()
async def start_schedule(hass: HomeAssistant) -> None:
"""Start the send schedule once Home Assistant has started."""
async def start_schedule(_event: Event) -> None:
"""Start the send schedule after the started event."""
nonlocal started
started = True
await analytics.async_schedule()
entry.async_on_unload(
labs.async_subscribe_preview_feature(
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
)
labs.async_subscribe_preview_feature(
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
)
entry.async_on_unload(async_at_started(hass, start_schedule))
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
websocket_api.async_register_command(hass, websocket_analytics)
websocket_api.async_register_command(hass, websocket_analytics_preferences)
hass.http.register_view(AnalyticsDevicesView)
hass.data[DATA_COMPONENT] = analytics
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload an Analytics config entry."""
analytics = hass.data.pop(DATA_COMPONENT)
analytics.cancel_scheduled()
return True
@callback
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "analytics"})
@@ -139,9 +109,7 @@ def websocket_analytics(
msg: dict[str, Any],
) -> None:
"""Return analytics preferences."""
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
return
analytics = hass.data[DATA_COMPONENT]
connection.send_result(
msg["id"],
{ATTR_PREFERENCES: analytics.preferences, ATTR_ONBOARDED: analytics.onboarded},
@@ -162,10 +130,8 @@ async def websocket_analytics_preferences(
msg: dict[str, Any],
) -> None:
"""Update analytics preferences."""
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
return
preferences = msg[ATTR_PREFERENCES]
analytics = hass.data[DATA_COMPONENT]
await analytics.save_preferences(preferences)
await analytics.async_schedule()
+21 -40
View File
@@ -3,7 +3,6 @@
import asyncio
from asyncio import timeout
from collections.abc import Awaitable, Callable, Iterable, Mapping
import contextlib
from dataclasses import asdict as dataclass_asdict, dataclass, field
from datetime import datetime
import random
@@ -298,20 +297,20 @@ class Analytics:
if stored:
self._data = AnalyticsData.from_dict(stored)
if self.supervisor and not self.onboarded:
# This may raise HassioNotReadyError if Supervisor was unreachable.
# The caller is responsible for handling this and triggering a retry.
supervisor_info = hassio.get_supervisor_info(self._hass)
# User have not configured analytics, get this setting from the supervisor
if supervisor_info[ATTR_DIAGNOSTICS] and not self.preferences.get(
ATTR_DIAGNOSTICS, False
):
self._data.preferences[ATTR_DIAGNOSTICS] = True
elif not supervisor_info[ATTR_DIAGNOSTICS] and self.preferences.get(
ATTR_DIAGNOSTICS, False
):
self._data.preferences[ATTR_DIAGNOSTICS] = False
if (
self.supervisor
and (supervisor_info := hassio.get_supervisor_info(self._hass)) is not None
):
if not self.onboarded:
# User have not configured analytics, get this setting from the supervisor
if supervisor_info[ATTR_DIAGNOSTICS] and not self.preferences.get(
ATTR_DIAGNOSTICS, False
):
self._data.preferences[ATTR_DIAGNOSTICS] = True
elif not supervisor_info[ATTR_DIAGNOSTICS] and self.preferences.get(
ATTR_DIAGNOSTICS, False
):
self._data.preferences[ATTR_DIAGNOSTICS] = False
async def _save(self) -> None:
"""Save data."""
@@ -345,14 +344,9 @@ class Analytics:
await self._save()
if self.supervisor:
# Try to pull Supervisor information, but don't fail if some or all
# of it is unavailable due to setup failures in the hassio integration.
with contextlib.suppress(hassio.HassioNotReadyError):
supervisor_info = hassio.get_supervisor_info(hass)
with contextlib.suppress(hassio.HassioNotReadyError):
operating_system_info = hassio.get_os_info(hass)
with contextlib.suppress(hassio.HassioNotReadyError):
addons_info = hassio.get_addons_info(hass)
supervisor_info = hassio.get_supervisor_info(hass)
operating_system_info = hassio.get_os_info(hass) or {}
addons_info = hassio.get_addons_info(hass) or {}
system_info = await async_get_system_info(hass)
integrations = []
@@ -425,7 +419,7 @@ class Analytics:
integrations.append(integration.domain)
if addons_info:
if addons_info is not None:
supervisor_client = hassio.get_supervisor_client(hass)
installed_addons = await asyncio.gather(
*(supervisor_client.addons.addon_info(slug) for slug in addons_info)
@@ -608,8 +602,7 @@ class Analytics:
else:
LOGGER.warning(
"Unexpected status code %s when submitting"
" snapshot analytics to %s",
"Unexpected status code %s when submitting snapshot analytics to %s",
response.status,
url,
)
@@ -626,16 +619,6 @@ class Analytics:
err,
)
@callback
def cancel_scheduled(self) -> None:
"""Cancel all scheduled analytics tasks."""
if self._basic_scheduled is not None:
self._basic_scheduled()
self._basic_scheduled = None
if self._snapshot_scheduled is not None:
self._snapshot_scheduled()
self._snapshot_scheduled = None
async def async_schedule(self) -> None:
"""Schedule analytics."""
if not self.onboarded:
@@ -821,8 +804,7 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
if not isinstance(integration_config, AnalyticsModifications):
LOGGER.error( # type: ignore[unreachable]
"Calling async_modify_analytics for integration"
" '%s' did not return an AnalyticsConfig",
"Calling async_modify_analytics for integration '%s' did not return an AnalyticsConfig",
integration_domain,
)
integration_configs[integration_domain] = AnalyticsModifications(
@@ -836,8 +818,7 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
# We need to refer to other devices, for example in `via_device` field.
# We don't however send the original device ids outside of Home Assistant,
# instead we refer to devices by
# (integration_domain, index_in_integration_device_list).
# instead we refer to devices by (integration_domain, index_in_integration_device_list).
device_id_mapping: dict[str, tuple[str, int]] = {}
# Fill out information about devices
@@ -1,19 +0,0 @@
"""Config flow for Analytics integration."""
from typing import Any
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from .const import DOMAIN
class AnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Analytics."""
VERSION = 1
async def async_step_system(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
return self.async_create_entry(title="Analytics", data={})
@@ -3,7 +3,6 @@
"name": "Analytics",
"after_dependencies": ["energy", "hassio", "recorder"],
"codeowners": ["@home-assistant/core"],
"config_flow": true,
"dependencies": ["api", "websocket_api", "http"],
"documentation": "https://www.home-assistant.io/integrations/analytics",
"integration_type": "system",
@@ -15,6 +14,5 @@
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
}
},
"quality_scale": "internal",
"single_config_entry": true
"quality_scale": "internal"
}
@@ -1,9 +1,4 @@
{
"exceptions": {
"supervisor_not_ready": {
"message": "Supervisor was not ready during setup, will retry"
}
},
"preview_features": {
"snapshots": {
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
@@ -15,7 +15,6 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_PORT
from homeassistant.core import callback
from homeassistant.data_entry_flow import SectionConfig, section
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
ObjectSelector,
@@ -33,7 +32,6 @@ from .const import (
CONF_APPS,
CONF_EXCLUDE_UNNAMED_APPS,
CONF_GET_SOURCES,
CONF_MORE_OPTIONS,
CONF_SCREENCAP_INTERVAL,
CONF_STATE_DETECTION_RULES,
CONF_TURN_OFF_COMMAND,
@@ -99,22 +97,20 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
)
),
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Required(CONF_MORE_OPTIONS): section(
vol.Schema(
{
vol.Optional(CONF_ADBKEY): str,
vol.Optional(CONF_ADB_SERVER_IP): str,
vol.Optional(
CONF_ADB_SERVER_PORT,
default=DEFAULT_ADB_SERVER_PORT,
): cv.port,
}
),
SectionConfig(collapsed=True),
),
},
)
if self.show_advanced_options:
data_schema = data_schema.extend(
{
vol.Optional(CONF_ADBKEY): str,
vol.Optional(CONF_ADB_SERVER_IP): str,
vol.Required(
CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT
): cv.port,
}
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
@@ -159,10 +155,6 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
error = None
if user_input is not None:
user_input = user_input.copy()
more_options = user_input.pop(CONF_MORE_OPTIONS, {})
user_input.update(more_options)
host = user_input[CONF_HOST]
adb_key = user_input.get(CONF_ADBKEY)
if CONF_ADB_SERVER_IP in user_input:
@@ -3,7 +3,6 @@
DOMAIN = "androidtv"
CONF_ADB_SERVER_IP = "adb_server_ip"
CONF_MORE_OPTIONS = "more_options"
CONF_ADB_SERVER_PORT = "adb_server_port"
CONF_ADBKEY = "adbkey"
CONF_APPS = "apps"
+4 -3
View File
@@ -94,9 +94,10 @@ def adb_decorator[_ADBDeviceT: AndroidTVEntity, **_P, _R](
# it doesn't happen over and over again.
if self.available:
_LOGGER.error(
"Unexpected exception executing an ADB"
" command. ADB connection re-establishing"
" attempt in the next update. Error: %s",
(
"Unexpected exception executing an ADB command. ADB connection"
" re-establishing attempt in the next update. Error: %s"
),
err,
)
@@ -281,7 +281,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
@adb_decorator()
async def service_download(self, device_path: str, local_path: str) -> None:
"""Download a file from your Android / Fire TV device."""
"""Download a file from your Android / Fire TV device to your Home Assistant instance."""
if not self.hass.config.is_allowed_path(local_path):
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
return
@@ -290,7 +290,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
@adb_decorator()
async def service_upload(self, device_path: str, local_path: str) -> None:
"""Upload a file to an Android / Fire TV device."""
"""Upload a file from your Home Assistant instance to an Android / Fire TV device."""
if not self.hass.config.is_allowed_path(local_path):
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
return
@@ -14,19 +14,12 @@
"step": {
"user": {
"data": {
"adb_server_ip": "IP address of the ADB server (leave empty to not use)",
"adb_server_port": "Port of the ADB server",
"adbkey": "Path to your ADB key file (leave empty to auto generate)",
"device_class": "The type of device",
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"sections": {
"more_options": {
"data": {
"adb_server_ip": "IP address of the ADB server (leave empty to not use)",
"adb_server_port": "Port of the ADB server",
"adbkey": "Path to your ADB key file (leave empty to auto generate)"
},
"name": "More options"
}
}
}
}
@@ -41,9 +41,8 @@ async def async_setup_entry(
# The Android TV is hard reset or the certificate and key files were deleted.
raise ConfigEntryAuthFailed from exc
except (CannotConnect, ConnectionClosed, TimeoutError) as exc:
# The Android TV is network unreachable. Raise exception and
# let Home Assistant retry later. If device gets a new IP
# address the zeroconf flow will update the config.
# The Android TV is network unreachable. Raise exception and let Home Assistant retry
# later. If device gets a new IP address the zeroconf flow will update the config.
raise ConfigEntryNotReady from exc
def reauth_needed() -> None:
@@ -107,10 +107,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def _async_start_pair(self) -> ConfigFlowResult:
"""Start pairing with the Android TV.
Navigate to the pair flow to enter the PIN shown on screen.
"""
"""Start pairing with the Android TV. Navigate to the pair flow to enter the PIN shown on screen."""
self.api = create_api(self.hass, self.host, enable_ime=False)
await self.api.async_generate_cert_if_missing()
await self.api.async_start_pairing()
@@ -138,10 +135,9 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
return await self._async_start_pair()
except CannotConnect, ConnectionClosed:
# Device doesn't respond to the specified host. Abort.
# If we are in the user flow we could go back
# to the user step to allow them to enter a
# new IP address but we cannot do that for the
# zeroconf flow. Simpler to abort for both.
# If we are in the user flow we could go back to the user step to allow
# them to enter a new IP address but we cannot do that for the zeroconf
# flow. Simpler to abort for both flows.
return self.async_abort(reason="cannot_connect")
else:
if self.source == SOURCE_REAUTH:
@@ -42,7 +42,7 @@ class AndroidTVRemoteBaseEntity(Entity):
@callback
def _is_available_updated(self, is_available: bool) -> None:
"""Update the state when the device is ready or unavailable."""
"""Update the state when the device is ready to receive commands or is unavailable."""
self._attr_available = is_available
self.async_write_ha_state()
@@ -65,8 +65,7 @@ class AndroidTVRemoteBaseEntity(Entity):
def _send_key_command(self, key_code: str, direction: str = "SHORT") -> None:
"""Send a key press to Android TV.
This does not block; it buffers the data and arranges
for it to be sent out asynchronously.
This does not block; it buffers the data and arranges for it to be sent out asynchronously.
"""
try:
self._api.send_key_command(key_code, direction)
@@ -78,8 +77,7 @@ class AndroidTVRemoteBaseEntity(Entity):
def _send_launch_app_command(self, app_link: str) -> None:
"""Launch an app on Android TV.
This does not block; it buffers the data and arranges
for it to be sent out asynchronously.
This does not block; it buffers the data and arranges for it to be sent out asynchronously.
"""
try:
self._api.send_launch_app_command(app_link)
@@ -95,10 +95,8 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
if not meter.readings or len(meter.readings) == 0:
_LOGGER.debug("No recent usage statistics found, skipping update")
continue
# Anglian Water stats are hourly, the read_at time
# is the time that the meter took the reading.
# We remove 1 hour from this so that the data is
# shown in the correct hour on the dashboards
# Anglian Water stats are hourly, the read_at time is the time that the meter took the reading
# We remove 1 hour from this so that the data is shown in the correct hour on the dashboards
parsed_read_at = dt_util.parse_datetime(meter.readings[0]["read_at"])
if not parsed_read_at:
_LOGGER.debug(
@@ -132,9 +130,8 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
if not stats or not stats.get(usage_statistic_id):
_LOGGER.debug(
"Could not find existing statistics during"
" period lookup for %s, falling back to"
" last stored statistic",
"Could not find existing statistics during period lookup for %s, "
"falling back to last stored statistic",
usage_statistic_id,
)
allow_update_last_stored_hour = True
+1 -2
View File
@@ -43,8 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnovaConfigEntry) -> boo
except NoDevicesFound as err:
# Can later setup successfully and spawn a repair.
raise ConfigEntryNotReady(
"No devices were found on the websocket, perhaps you"
" don't have any devices on this account?"
"No devices were found on the websocket, perhaps you don't have any devices on this account?"
) from err
except WebsocketFailure as err:
raise ConfigEntryNotReady("Failed connecting to the websocket.") from err

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