Compare commits

..

3 commits

Author SHA1 Message Date
c0ffeeca7
83458d24c7 Implement review comments from #104658 2023-11-29 05:36:29 +00:00
c0ffeeca7
df6d43adc4 Fix style 2023-11-28 17:44:45 +00:00
c0ffeeca7
5cbfc1c224 Add info what to enter into host field 2023-11-28 17:35:21 +00:00
15608 changed files with 343042 additions and 1130718 deletions

View file

@ -14,7 +14,6 @@ core: &core
base_platforms: &base_platforms base_platforms: &base_platforms
- homeassistant/components/air_quality/** - homeassistant/components/air_quality/**
- homeassistant/components/alarm_control_panel/** - homeassistant/components/alarm_control_panel/**
- homeassistant/components/assist_satellite/**
- homeassistant/components/binary_sensor/** - homeassistant/components/binary_sensor/**
- homeassistant/components/button/** - homeassistant/components/button/**
- homeassistant/components/calendar/** - homeassistant/components/calendar/**
@ -50,7 +49,6 @@ base_platforms: &base_platforms
- homeassistant/components/tts/** - homeassistant/components/tts/**
- homeassistant/components/update/** - homeassistant/components/update/**
- homeassistant/components/vacuum/** - homeassistant/components/vacuum/**
- homeassistant/components/valve/**
- homeassistant/components/water_heater/** - homeassistant/components/water_heater/**
- homeassistant/components/weather/** - homeassistant/components/weather/**
@ -62,7 +60,6 @@ components: &components
- homeassistant/components/auth/** - homeassistant/components/auth/**
- homeassistant/components/automation/** - homeassistant/components/automation/**
- homeassistant/components/backup/** - homeassistant/components/backup/**
- homeassistant/components/blueprint/**
- homeassistant/components/bluetooth/** - homeassistant/components/bluetooth/**
- homeassistant/components/cloud/** - homeassistant/components/cloud/**
- homeassistant/components/config/** - homeassistant/components/config/**
@ -79,7 +76,6 @@ components: &components
- homeassistant/components/group/** - homeassistant/components/group/**
- homeassistant/components/hassio/** - homeassistant/components/hassio/**
- homeassistant/components/homeassistant/** - homeassistant/components/homeassistant/**
- homeassistant/components/homeassistant_hardware/**
- homeassistant/components/http/** - homeassistant/components/http/**
- homeassistant/components/image/** - homeassistant/components/image/**
- homeassistant/components/input_boolean/** - homeassistant/components/input_boolean/**
@ -112,7 +108,6 @@ components: &components
- homeassistant/components/tag/** - homeassistant/components/tag/**
- homeassistant/components/template/** - homeassistant/components/template/**
- homeassistant/components/timer/** - homeassistant/components/timer/**
- homeassistant/components/trace/**
- homeassistant/components/usb/** - homeassistant/components/usb/**
- homeassistant/components/webhook/** - homeassistant/components/webhook/**
- homeassistant/components/websocket_api/** - homeassistant/components/websocket_api/**
@ -125,21 +120,21 @@ tests: &tests
- pylint/** - pylint/**
- requirements_test_pre_commit.txt - requirements_test_pre_commit.txt
- requirements_test.txt - requirements_test.txt
- tests/*.py
- tests/auth/** - tests/auth/**
- tests/backports/** - tests/backports/**
- tests/components/conftest.py - tests/common.py
- tests/components/diagnostics/**
- tests/components/history/** - tests/components/history/**
- tests/components/logbook/** - tests/components/logbook/**
- tests/components/recorder/** - tests/components/recorder/**
- tests/components/repairs/**
- tests/components/sensor/** - tests/components/sensor/**
- tests/conftest.py
- tests/hassfest/** - tests/hassfest/**
- tests/helpers/** - tests/helpers/**
- tests/ignore_uncaught_exceptions.py
- tests/mock/** - tests/mock/**
- tests/pylint/** - tests/pylint/**
- tests/scripts/** - tests/scripts/**
- tests/syrupy.py
- tests/test_util/** - tests/test_util/**
- tests/testing_config/** - tests/testing_config/**
- tests/util/** - tests/util/**
@ -153,7 +148,6 @@ requirements: &requirements
- homeassistant/package_constraints.txt - homeassistant/package_constraints.txt
- requirements*.txt - requirements*.txt
- pyproject.toml - pyproject.toml
- script/licenses.py
any: any:
- *base_platforms - *base_platforms

1642
.coveragerc Normal file

File diff suppressed because it is too large Load diff

View file

@ -2,22 +2,11 @@
"name": "Home Assistant Dev", "name": "Home Assistant Dev",
"context": "..", "context": "..",
"dockerFile": "../Dockerfile.dev", "dockerFile": "../Dockerfile.dev",
"postCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder} && script/setup", "postCreateCommand": "script/setup",
"postStartCommand": "script/bootstrap", "postStartCommand": "script/bootstrap",
"containerEnv": { "containerEnv": { "DEVCONTAINER": "1" },
"PYTHONASYNCIODEBUG": "1" "appPort": ["8123:8123"],
}, "runArgs": ["-e", "GIT_EDITOR=code --wait"],
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {}
},
// Port 5683 udp is used by Shelly integration
"appPort": ["8123:8123", "5683:5683/udp"],
"runArgs": [
"-e",
"GIT_EDITOR=code --wait",
"--security-opt",
"label=disable"
],
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": [
@ -27,17 +16,12 @@
"visualstudioexptteam.vscodeintellicode", "visualstudioexptteam.vscodeintellicode",
"redhat.vscode-yaml", "redhat.vscode-yaml",
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
"GitHub.vscode-pull-request-github", "GitHub.vscode-pull-request-github"
"GitHub.copilot"
], ],
// Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json // Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json
"settings": { "settings": {
"python.experiments.optOutFrom": ["pythonTestAdapter"], "python.pythonPath": "/usr/local/bin/python",
"python.defaultInterpreterPath": "/home/vscode/.local/ha-venv/bin/python",
"python.pythonPath": "/home/vscode/.local/ha-venv/bin/python",
"python.terminal.activateEnvInCurrentTerminal": true,
"python.testing.pytestArgs": ["--no-cov"], "python.testing.pytestArgs": ["--no-cov"],
"pylint.importStrategy": "fromEnvironment",
"editor.formatOnPaste": false, "editor.formatOnPaste": false,
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.formatOnType": true, "editor.formatOnType": true,
@ -58,13 +42,7 @@
], ],
"[python]": { "[python]": {
"editor.defaultFormatter": "charliermarsh.ruff" "editor.defaultFormatter": "charliermarsh.ruff"
}, }
"json.schemas": [
{
"fileMatch": ["homeassistant/components/*/manifest.json"],
"url": "./script/json_schemas/manifest_schema.json"
}
]
} }
} }
} }

View file

@ -7,7 +7,6 @@ docs
# Development # Development
.devcontainer .devcontainer
.vscode .vscode
.tool-versions
# Test related files # Test related files
tests tests

View file

@ -1,14 +0,0 @@
# Black
4de97abc3aa83188666336ce0a015a5bab75bc8f
# Switch formatting from black to ruff-format (#102893)
706add4a57120a93d7b7fe40e722b00d634c76c2
# Prettify json (component test fixtures) (#68892)
053c4428a933c3c04c22642f93c93fccba3e8bfd
# Prettify json (tests) (#68888)
496d90bf00429d9d924caeb0155edc0bf54e86b9
# Bump ruff to 0.3.4 (#112690)
6bb4e7d62c60389608acf4a7d7dacd8f029307dd

3
.github/FUNDING.yml vendored
View file

@ -1 +1,2 @@
custom: https://www.openhomefoundation.org custom: https://www.nabucasa.com
github: balloob

View file

@ -74,6 +74,7 @@ If the code communicates with devices, web services, or third-party tools:
- [ ] New or updated dependencies have been added to `requirements_all.txt`. - [ ] New or updated dependencies have been added to `requirements_all.txt`.
Updated by running `python3 -m script.gen_requirements_all`. Updated by running `python3 -m script.gen_requirements_all`.
- [ ] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description. - [ ] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description.
- [ ] Untested files have been added to `.coveragerc`.
<!-- <!--
This project is very active and we have a high turnover of pull requests. This project is very active and we have a high turnover of pull requests.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

View file

@ -10,10 +10,7 @@ on:
env: env:
BUILD_TYPE: core BUILD_TYPE: core
DEFAULT_PYTHON: "3.13" DEFAULT_PYTHON: "3.11"
PIP_TIMEOUT: 60
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
jobs: jobs:
init: init:
@ -27,12 +24,12 @@ jobs:
publish: ${{ steps.version.outputs.publish }} publish: ${{ steps.version.outputs.publish }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.1.1
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.3.0 uses: actions/setup-python@v4.7.1
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@ -51,29 +48,41 @@ jobs:
with: with:
ignore-dev: true ignore-dev: true
- name: Fail if translations files are checked in build_python:
run: | name: Build PyPi package
if [ -n "$(find homeassistant/components/*/translations -type f)" ]; then environment: ${{ needs.init.outputs.channel }}
echo "Translations files are checked in, please remove the following files:" needs: ["init", "build_base"]
find homeassistant/components/*/translations -type f runs-on: ubuntu-latest
exit 1 if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
fi steps:
- name: Checkout the repository
uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v4.7.1
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download Translations - name: Download Translations
run: python3 -m script.translations download run: python3 -m script.translations download
env: env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
- name: Archive translations - name: Build package
shell: bash shell: bash
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - run: |
# Remove dist, build, and homeassistant.egg-info
# when build locally for testing!
pip install twine build
python -m build
- name: Upload translations - name: Upload package
uses: actions/upload-artifact@v4.4.3 shell: bash
with: run: |
name: translations export TWINE_USERNAME="__token__"
path: translations.tar.gz export TWINE_PASSWORD="${{ secrets.TWINE_TOKEN }}"
if-no-files-found: error
twine upload dist/* --skip-existing
build_base: build_base:
name: Build ${{ matrix.arch }} base core image name: Build ${{ matrix.arch }} base core image
@ -85,16 +94,15 @@ jobs:
packages: write packages: write
id-token: write id-token: write
strategy: strategy:
fail-fast: false
matrix: matrix:
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.1.1
- name: Download nightly wheels of frontend - name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@v6 uses: dawidd6/action-download-artifact@v2
with: with:
github_token: ${{secrets.GITHUB_TOKEN}} github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend repo: home-assistant/frontend
@ -105,7 +113,7 @@ jobs:
- name: Download nightly wheels of intents - name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@v6 uses: dawidd6/action-download-artifact@v2
with: with:
github_token: ${{secrets.GITHUB_TOKEN}} github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/intents-package repo: home-assistant/intents-package
@ -116,20 +124,17 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@v5.3.0 uses: actions/setup-python@v4.7.1
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Adjust nightly version - name: Adjust nightly version
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
shell: bash shell: bash
env:
UV_PRERELEASE: allow
run: | run: |
python3 -m pip install "$(grep '^uv' < requirements.txt)" python3 -m pip install packaging tomli
uv pip install packaging tomli python3 -m pip install .
uv pip install . version="$(python3 script/version_bump.py nightly)"
python3 script/version_bump.py nightly --set-nightly-version "${{ needs.init.outputs.version }}"
if [[ "$(ls home_assistant_frontend*.whl)" =~ ^home_assistant_frontend-(.*)-py3-none-any.whl$ ]]; then if [[ "$(ls home_assistant_frontend*.whl)" =~ ^home_assistant_frontend-(.*)-py3-none-any.whl$ ]]; then
echo "Found frontend wheel, setting version to: ${BASH_REMATCH[1]}" echo "Found frontend wheel, setting version to: ${BASH_REMATCH[1]}"
@ -141,7 +146,7 @@ jobs:
sed -i "s|home-assistant-frontend==.*|home-assistant-frontend==${BASH_REMATCH[1]}|" \ sed -i "s|home-assistant-frontend==.*|home-assistant-frontend==${BASH_REMATCH[1]}|" \
homeassistant/package_constraints.txt homeassistant/package_constraints.txt
sed -i "s|home-assistant-frontend==.*||" requirements_all.txt python -m script.gen_requirements_all
fi fi
if [[ "$(ls home_assistant_intents*.whl)" =~ ^home_assistant_intents-(.*)-py3-none-any.whl$ ]]; then if [[ "$(ls home_assistant_intents*.whl)" =~ ^home_assistant_intents-(.*)-py3-none-any.whl$ ]]; then
@ -159,7 +164,7 @@ jobs:
sed -i "s|home-assistant-intents==.*|home-assistant-intents==${BASH_REMATCH[1]}|" \ sed -i "s|home-assistant-intents==.*|home-assistant-intents==${BASH_REMATCH[1]}|" \
homeassistant/package_constraints.txt homeassistant/package_constraints.txt
sed -i "s|home-assistant-intents==.*||" requirements_all.txt python -m script.gen_requirements_all
fi fi
- name: Adjustments for armhf - name: Adjustments for armhf
@ -174,15 +179,10 @@ jobs:
sed -i "s|pyezviz|# pyezviz|g" requirements_all.txt sed -i "s|pyezviz|# pyezviz|g" requirements_all.txt
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations - name: Download Translations
uses: actions/download-artifact@v4.1.8 run: python3 -m script.translations download
with: env:
name: translations LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
- name: Extract translations
run: |
tar xvf translations.tar.gz
rm translations.tar.gz
- name: Write meta info file - name: Write meta info file
shell: bash shell: bash
@ -190,14 +190,14 @@ jobs:
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3.3.0 uses: docker/login-action@v3.0.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image - name: Build base image
uses: home-assistant/builder@2024.08.2 uses: home-assistant/builder@2023.09.0
with: with:
args: | args: |
$BUILD_ARGS \ $BUILD_ARGS \
@ -206,6 +206,17 @@ jobs:
--target /data \ --target /data \
--generic ${{ needs.init.outputs.version }} --generic ${{ needs.init.outputs.version }}
- 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@v3
with:
name: translations
path: translations.tar.gz
if-no-files-found: error
build_machine: build_machine:
name: Build ${{ matrix.machine }} machine core image name: Build ${{ matrix.machine }} machine core image
if: github.repository_owner == 'home-assistant' if: github.repository_owner == 'home-assistant'
@ -236,13 +247,12 @@ jobs:
- raspberrypi3-64 - raspberrypi3-64
- raspberrypi4 - raspberrypi4
- raspberrypi4-64 - raspberrypi4-64
- raspberrypi5-64
- tinker - tinker
- yellow - yellow
- green - green
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.1.1
- name: Set build additional args - name: Set build additional args
run: | run: |
@ -256,14 +266,14 @@ jobs:
fi fi
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3.3.0 uses: docker/login-action@v3.0.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image - name: Build base image
uses: home-assistant/builder@2024.08.2 uses: home-assistant/builder@2023.09.0
with: with:
args: | args: |
$BUILD_ARGS \ $BUILD_ARGS \
@ -279,7 +289,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.1.1
- name: Initialize git - name: Initialize git
uses: home-assistant/actions/helpers/git-init@master uses: home-assistant/actions/helpers/git-init@master
@ -315,29 +325,23 @@ jobs:
contents: read contents: read
packages: write packages: write
id-token: write id-token: write
strategy:
fail-fast: false
matrix:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.1.1
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@v3.7.0 uses: sigstore/cosign-installer@v3.2.0
with: with:
cosign-release: "v2.2.3" cosign-release: "v2.0.2"
- name: Login to DockerHub - name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant' uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.3.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
if: matrix.registry == 'ghcr.io/home-assistant' uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.3.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@ -351,37 +355,41 @@ jobs:
function create_manifest() { function create_manifest() {
local tag_l=${1} local tag_l=${1}
local tag_r=${2} local tag_r=${2}
local registry=${{ matrix.registry }}
docker manifest create "${registry}/home-assistant:${tag_l}" \ for registry in "ghcr.io/home-assistant" "docker.io/homeassistant"
"${registry}/amd64-homeassistant:${tag_r}" \ do
"${registry}/i386-homeassistant:${tag_r}" \
"${registry}/armhf-homeassistant:${tag_r}" \
"${registry}/armv7-homeassistant:${tag_r}" \
"${registry}/aarch64-homeassistant:${tag_r}"
docker manifest annotate "${registry}/home-assistant:${tag_l}" \ docker manifest create "${registry}/home-assistant:${tag_l}" \
"${registry}/amd64-homeassistant:${tag_r}" \ "${registry}/amd64-homeassistant:${tag_r}" \
--os linux --arch amd64 "${registry}/i386-homeassistant:${tag_r}" \
"${registry}/armhf-homeassistant:${tag_r}" \
"${registry}/armv7-homeassistant:${tag_r}" \
"${registry}/aarch64-homeassistant:${tag_r}"
docker manifest annotate "${registry}/home-assistant:${tag_l}" \ docker manifest annotate "${registry}/home-assistant:${tag_l}" \
"${registry}/i386-homeassistant:${tag_r}" \ "${registry}/amd64-homeassistant:${tag_r}" \
--os linux --arch 386 --os linux --arch amd64
docker manifest annotate "${registry}/home-assistant:${tag_l}" \ docker manifest annotate "${registry}/home-assistant:${tag_l}" \
"${registry}/armhf-homeassistant:${tag_r}" \ "${registry}/i386-homeassistant:${tag_r}" \
--os linux --arch arm --variant=v6 --os linux --arch 386
docker manifest annotate "${registry}/home-assistant:${tag_l}" \ docker manifest annotate "${registry}/home-assistant:${tag_l}" \
"${registry}/armv7-homeassistant:${tag_r}" \ "${registry}/armhf-homeassistant:${tag_r}" \
--os linux --arch arm --variant=v7 --os linux --arch arm --variant=v6
docker manifest annotate "${registry}/home-assistant:${tag_l}" \ docker manifest annotate "${registry}/home-assistant:${tag_l}" \
"${registry}/aarch64-homeassistant:${tag_r}" \ "${registry}/armv7-homeassistant:${tag_r}" \
--os linux --arch arm64 --variant=v8 --os linux --arch arm --variant=v7
docker manifest push --purge "${registry}/home-assistant:${tag_l}" docker manifest annotate "${registry}/home-assistant:${tag_l}" \
cosign sign --yes "${registry}/home-assistant:${tag_l}" "${registry}/aarch64-homeassistant:${tag_r}" \
--os linux --arch arm64 --variant=v8
docker manifest push --purge "${registry}/home-assistant:${tag_l}"
cosign sign --yes "${registry}/home-assistant:${tag_l}"
done
} }
function validate_image() { function validate_image() {
@ -414,14 +422,12 @@ jobs:
validate_image "ghcr.io/home-assistant/armv7-homeassistant:${{ needs.init.outputs.version }}" validate_image "ghcr.io/home-assistant/armv7-homeassistant:${{ needs.init.outputs.version }}"
validate_image "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}" validate_image "ghcr.io/home-assistant/aarch64-homeassistant:${{ needs.init.outputs.version }}"
if [[ "${{ matrix.registry }}" == "docker.io/homeassistant" ]]; then # Upload images to dockerhub
# Upload images to dockerhub push_dockerhub "amd64-homeassistant" "${{ needs.init.outputs.version }}"
push_dockerhub "amd64-homeassistant" "${{ needs.init.outputs.version }}" push_dockerhub "i386-homeassistant" "${{ needs.init.outputs.version }}"
push_dockerhub "i386-homeassistant" "${{ needs.init.outputs.version }}" push_dockerhub "armhf-homeassistant" "${{ needs.init.outputs.version }}"
push_dockerhub "armhf-homeassistant" "${{ needs.init.outputs.version }}" push_dockerhub "armv7-homeassistant" "${{ needs.init.outputs.version }}"
push_dockerhub "armv7-homeassistant" "${{ needs.init.outputs.version }}" push_dockerhub "aarch64-homeassistant" "${{ needs.init.outputs.version }}"
push_dockerhub "aarch64-homeassistant" "${{ needs.init.outputs.version }}"
fi
# Create version tag # Create version tag
create_manifest "${{ needs.init.outputs.version }}" "${{ needs.init.outputs.version }}" create_manifest "${{ needs.init.outputs.version }}" "${{ needs.init.outputs.version }}"
@ -442,97 +448,3 @@ jobs:
v="${{ needs.init.outputs.version }}" v="${{ needs.init.outputs.version }}"
create_manifest "${v%.*}" "${{ needs.init.outputs.version }}" create_manifest "${v%.*}" "${{ needs.init.outputs.version }}"
fi fi
build_python:
name: Build PyPi package
environment: ${{ needs.init.outputs.channel }}
needs: ["init", "build_base"]
runs-on: ubuntu-latest
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
uses: actions/download-artifact@v4.1.8
with:
name: translations
- 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 twine build
python -m build
- name: Upload package
shell: bash
run: |
export TWINE_USERNAME="__token__"
export TWINE_PASSWORD="${{ secrets.TWINE_TOKEN }}"
twine upload dist/* --skip-existing
hassfest-image:
name: Build and test hassfest image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
needs: ["init"]
if: github.repository_owner == 'home-assistant'
env:
HASSFEST_IMAGE_NAME: ghcr.io/home-assistant/hassfest
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Login to GitHub Container Registry
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
load: true
tags: ${{ env.HASSFEST_IMAGE_TAG }}
- name: Run hassfest against core
run: docker run --rm -v ${{ github.workspace }}/homeassistant:/github/workspace/homeassistant ${{ env.HASSFEST_IMAGE_TAG }} --core-integrations-path=/github/workspace/homeassistant/components
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
push: true
tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018 # v1.4.4
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,11 @@ name: "CodeQL"
# yamllint disable-line rule:truthy # yamllint disable-line rule:truthy
on: on:
push:
branches:
- dev
- rc
- master
schedule: schedule:
- cron: "30 18 * * 4" - cron: "30 18 * * 4"
@ -21,14 +26,14 @@ jobs:
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.1.1
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3.27.3 uses: github/codeql-action/init@v2.22.8
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.27.3 uses: github/codeql-action/analyze@v2.22.8
with: with:
category: "/language:python" category: "/language:python"

View file

@ -11,16 +11,16 @@ jobs:
if: github.repository_owner == 'home-assistant' if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# The 60 day stale policy for PRs # The 90 day stale policy for PRs
# Used for: # Used for:
# - PRs # - PRs
# - No PRs marked as no-stale # - No PRs marked as no-stale
# - No issues (-1) # - No issues (-1)
- name: 60 days stale PRs policy - name: 90 days stale PRs policy
uses: actions/stale@v9.0.0 uses: actions/stale@v8.0.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60 days-before-stale: 90
days-before-close: 7 days-before-close: 7
days-before-issue-stale: -1 days-before-issue-stale: -1
days-before-issue-close: -1 days-before-issue-close: -1
@ -33,11 +33,7 @@ jobs:
pull request has been automatically marked as stale because of that pull request has been automatically marked as stale because of that
and will be closed if no further activity occurs within 7 days. and will be closed if no further activity occurs within 7 days.
If you are the author of this PR, please leave a comment if you want Thank you for your contributions.
to keep it open. Also, please rebase your PR onto the latest dev
branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!
# Generate a token for the GitHub App, we use this method to avoid # Generate a token for the GitHub App, we use this method to avoid
# hitting API limits for our GitHub actions + have a higher rate limit. # hitting API limits for our GitHub actions + have a higher rate limit.
@ -57,7 +53,7 @@ jobs:
# - No issues marked as no-stale or help-wanted # - No issues marked as no-stale or help-wanted
# - No PRs (-1) # - No PRs (-1)
- name: 90 days stale issues - name: 90 days stale issues
uses: actions/stale@v9.0.0 uses: actions/stale@v8.0.0
with: with:
repo-token: ${{ steps.token.outputs.token }} repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90 days-before-stale: 90
@ -87,7 +83,7 @@ jobs:
# - No Issues marked as no-stale or help-wanted # - No Issues marked as no-stale or help-wanted
# - No PRs (-1) # - No PRs (-1)
- name: Needs more information stale issues policy - name: Needs more information stale issues policy
uses: actions/stale@v9.0.0 uses: actions/stale@v8.0.0
with: with:
repo-token: ${{ steps.token.outputs.token }} repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information" only-labels: "needs-more-information"

View file

@ -10,7 +10,7 @@ on:
- "**strings.json" - "**strings.json"
env: env:
DEFAULT_PYTHON: "3.12" DEFAULT_PYTHON: "3.11"
jobs: jobs:
upload: upload:
@ -19,10 +19,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@v5.3.0 uses: actions/setup-python@v4.7.1
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}

View file

@ -14,10 +14,6 @@ on:
- "homeassistant/package_constraints.txt" - "homeassistant/package_constraints.txt"
- "requirements_all.txt" - "requirements_all.txt"
- "requirements.txt" - "requirements.txt"
- "script/gen_requirements_all.py"
env:
DEFAULT_PYTHON: "3.12"
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref_name}} group: ${{ github.workflow }}-${{ github.ref_name}}
@ -32,22 +28,7 @@ jobs:
architectures: ${{ steps.info.outputs.architectures }} architectures: ${{ steps.info.outputs.architectures }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.1.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@v5.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Create Python virtual environment
run: |
python -m venv venv
. venv/bin/activate
python --version
pip install "$(grep '^uv' < requirements.txt)"
uv pip install -r requirements.txt
- name: Get information - name: Get information
id: info id: info
@ -64,8 +45,11 @@ jobs:
- name: Write env-file - name: Write env-file
run: | run: |
( (
echo "GRPC_BUILD_WITH_BORING_SSL_ASM=false"
echo "GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=true" echo "GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=true"
echo "GRPC_PYTHON_BUILD_WITH_CYTHON=true" echo "GRPC_PYTHON_BUILD_WITH_CYTHON=true"
echo "GRPC_PYTHON_DISABLE_LIBC_COMPATIBILITY=true"
echo "GRPC_PYTHON_LDFLAGS=-lpthread -Wl,-wrap,memcpy -static-libgcc"
# Fix out of memory issues with rust # Fix out of memory issues with rust
echo "CARGO_NET_GIT_FETCH_WITH_CLI=true" echo "CARGO_NET_GIT_FETCH_WITH_CLI=true"
@ -79,30 +63,16 @@ jobs:
) > .env_file ) > .env_file
- name: Upload env_file - name: Upload env_file
uses: actions/upload-artifact@v4.4.3 uses: actions/upload-artifact@v3.1.2
with: with:
name: env_file name: env_file
path: ./.env_file path: ./.env_file
include-hidden-files: true
overwrite: true
- name: Upload requirements_diff - name: Upload requirements_diff
uses: actions/upload-artifact@v4.4.3 uses: actions/upload-artifact@v3.1.2
with: with:
name: requirements_diff name: requirements_diff
path: ./requirements_diff.txt path: ./requirements_diff.txt
overwrite: true
- name: Generate requirements
run: |
. venv/bin/activate
python -m script.gen_requirements_all ci
- name: Upload requirements_all_wheels
uses: actions/upload-artifact@v4.4.3
with:
name: requirements_all_wheels
path: ./requirements_all_wheels_*.txt
core: core:
name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2) name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2)
@ -112,38 +82,32 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
abi: ["cp312", "cp313"] abi: ["cp311", "cp312"]
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.1.1
- name: Download env_file - name: Download env_file
uses: actions/download-artifact@v4.1.8 uses: actions/download-artifact@v3
with: with:
name: env_file name: env_file
- name: Download requirements_diff - name: Download requirements_diff
uses: actions/download-artifact@v4.1.8 uses: actions/download-artifact@v3
with: with:
name: requirements_diff name: requirements_diff
- name: Adjust build env
run: |
# Don't build wheels for uv as uv requires a greater version of rust as currently available on alpine
sed -i "/uv/d" requirements.txt
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels - name: Build wheels
uses: home-assistant/wheels@2024.11.0 uses: home-assistant/wheels@2023.10.5
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
arch: ${{ matrix.arch }} arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true env-file: true
apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-dev" apk: "libffi-dev;openssl-dev;yaml-dev"
skip-binary: aiohttp;multidict;yarl skip-binary: aiohttp
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"
requirements: "requirements.txt" requirements: "requirements.txt"
@ -156,26 +120,67 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
abi: ["cp312", "cp313"] abi: ["cp311", "cp312"]
arch: ${{ fromJson(needs.init.outputs.architectures) }} arch: ${{ fromJson(needs.init.outputs.architectures) }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.1.1
- name: Download env_file - name: Download env_file
uses: actions/download-artifact@v4.1.8 uses: actions/download-artifact@v3
with: with:
name: env_file name: env_file
- name: Download requirements_diff - name: Download requirements_diff
uses: actions/download-artifact@v4.1.8 uses: actions/download-artifact@v3
with: with:
name: requirements_diff name: requirements_diff
- name: Download requirements_all_wheels - name: (Un)comment packages
uses: actions/download-artifact@v4.1.8 run: |
with: requirement_files="requirements_all.txt requirements_diff.txt"
name: requirements_all_wheels for requirement_file in ${requirement_files}; do
sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file}
sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file}
sed -i "s|# evdev|evdev|g" ${requirement_file}
sed -i "s|# pycups|pycups|g" ${requirement_file}
sed -i "s|# homekit|homekit|g" ${requirement_file}
sed -i "s|# decora-wifi|decora-wifi|g" ${requirement_file}
sed -i "s|# python-gammu|python-gammu|g" ${requirement_file}
# Some packages are not buildable on armhf anymore
if [ "${{ matrix.arch }}" = "armhf" ]; then
# Pandas has issues building on armhf, it is expected they
# will drop the platform in the near future (they consider it
# "flimsy" on 386). The following packages depend on pandas,
# so we comment them out.
sed -i "s|env-canada|# env-canada|g" ${requirement_file}
sed -i "s|noaa-coops|# noaa-coops|g" ${requirement_file}
sed -i "s|pyezviz|# pyezviz|g" ${requirement_file}
sed -i "s|pykrakenapi|# pykrakenapi|g" ${requirement_file}
fi
done
- name: Split requirements all
run: |
# We split requirements all into two different files.
# This is to prevent the build from running out of memory when
# resolving packages on 32-bits systems (like armhf, armv7).
split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all.txt requirements_all.txt
- name: Create requirements for cython<3
run: |
# Some dependencies still require 'cython<3'
# and don't yet use isolated build environments.
# Build these first.
# grpcio: https://github.com/grpc/grpc/issues/33918
# pydantic: https://github.com/pydantic/pydantic/issues/7689
touch requirements_old-cython.txt
cat homeassistant/package_constraints.txt | grep 'grpcio==' >> requirements_old-cython.txt
cat homeassistant/package_constraints.txt | grep 'pydantic==' >> requirements_old-cython.txt
- name: Adjust build env - name: Adjust build env
run: | run: |
@ -185,32 +190,9 @@ jobs:
# Do not pin numpy in wheels building # Do not pin numpy in wheels building
sed -i "/numpy/d" homeassistant/package_constraints.txt sed -i "/numpy/d" homeassistant/package_constraints.txt
# Don't build wheels for uv as uv requires a greater version of rust as currently available on alpine
sed -i "/uv/d" requirements.txt
sed -i "/uv/d" requirements_diff.txt
- name: Split requirements all
run: |
# We split requirements all into multiple files.
# This is to prevent the build from running out of memory when
# resolving packages on 32-bits systems (like armhf, armv7).
split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt
- name: Create requirements for cython<3
if: matrix.abi == 'cp312'
run: |
# Some dependencies still require 'cython<3'
# and don't yet use isolated build environments.
# Build these first.
# pydantic: https://github.com/pydantic/pydantic/issues/7689
touch requirements_old-cython.txt
cat homeassistant/package_constraints.txt | grep 'pydantic==' >> requirements_old-cython.txt
- name: Build wheels (old cython) - name: Build wheels (old cython)
uses: home-assistant/wheels@2024.11.0 uses: home-assistant/wheels@2023.10.5
if: matrix.abi == 'cp312'
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
@ -218,50 +200,50 @@ jobs:
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"
requirements: "requirements_old-cython.txt" requirements: "requirements_old-cython.txt"
pip: "'cython<3'" pip: "'cython<3'"
- name: Build wheels (part 1) - name: Build wheels (part 1)
uses: home-assistant/wheels@2024.11.0 uses: home-assistant/wheels@2023.10.5
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
arch: ${{ matrix.arch }} arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtaa" requirements: "requirements_all.txtaa"
- name: Build wheels (part 2) - name: Build wheels (part 2)
uses: home-assistant/wheels@2024.11.0 uses: home-assistant/wheels@2023.10.5
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
arch: ${{ matrix.arch }} arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtab" requirements: "requirements_all.txtab"
- name: Build wheels (part 3) - name: Build wheels (part 3)
uses: home-assistant/wheels@2024.11.0 uses: home-assistant/wheels@2023.10.5
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
arch: ${{ matrix.arch }} arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }} wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf
constraints: "homeassistant/package_constraints.txt" constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt" requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtac" requirements: "requirements_all.txtac"

5
.gitignore vendored
View file

@ -34,7 +34,6 @@ Icon
# GITHUB Proposed Python stuff: # GITHUB Proposed Python stuff:
*.py[cod] *.py[cod]
__pycache__
# C extensions # C extensions
*.so *.so
@ -79,7 +78,6 @@ pytest-*.txt
.pydevproject .pydevproject
.python-version .python-version
.tool-versions
# emacs auto backups # emacs auto backups
*~ *~
@ -134,6 +132,3 @@ tmp_cache
# python-language-server / Rope # python-language-server / Rope
.ropeproject .ropeproject
# Will be created from script/split_tests.py
pytest_buckets.txt

View file

@ -1,21 +1,21 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.3 rev: v0.1.6
hooks: hooks:
- id: ruff - id: ruff
args: args:
- --fix - --fix
- id: ruff-format - id: ruff-format
files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$ files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.py$
- repo: https://github.com/codespell-project/codespell - repo: https://github.com/codespell-project/codespell
rev: v2.3.0 rev: v2.2.2
hooks: hooks:
- id: codespell - id: codespell
args: args:
- --ignore-words-list=astroid,checkin,currenty,hass,iif,incomfort,lookin,nam,NotIn - --ignore-words-list=additionals,alle,alot,bund,currenty,datas,farenheit,falsy,fo,haa,hass,iif,incomfort,ines,ist,nam,nd,pres,pullrequests,resset,rime,ser,serie,te,technik,ue,unsecure,withing,zar
- --skip="./.*,*.csv,*.json,*.ambr" - --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2 - --quiet-level=2
exclude_types: [csv, json, html] exclude_types: [csv, json]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/ exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0 rev: v4.4.0
@ -30,7 +30,7 @@ repos:
- --branch=master - --branch=master
- --branch=rc - --branch=rc
- repo: https://github.com/adrienverge/yamllint.git - repo: https://github.com/adrienverge/yamllint.git
rev: v1.35.1 rev: v1.32.0
hooks: hooks:
- id: yamllint - id: yamllint
- repo: https://github.com/pre-commit/mirrors-prettier - repo: https://github.com/pre-commit/mirrors-prettier
@ -61,15 +61,15 @@ repos:
name: mypy name: mypy
entry: script/run-in-env.sh mypy entry: script/run-in-env.sh mypy
language: script language: script
types_or: [python, pyi] types: [python]
require_serial: true require_serial: true
files: ^(homeassistant|pylint)/.+\.(py|pyi)$ files: ^(homeassistant|pylint)/.+\.py$
- id: pylint - id: pylint
name: pylint name: pylint
entry: script/run-in-env.sh pylint -j 0 --ignore-missing-annotations=y entry: script/run-in-env.sh pylint -j 0 --ignore-missing-annotations=y
language: script language: script
types_or: [python, pyi] types: [python]
files: ^(homeassistant|tests)/.+\.(py|pyi)$ files: ^homeassistant/.+\.py$
- id: gen_requirements_all - id: gen_requirements_all
name: gen_requirements_all name: gen_requirements_all
entry: script/run-in-env.sh python3 -m script.gen_requirements_all entry: script/run-in-env.sh python3 -m script.gen_requirements_all
@ -83,14 +83,14 @@ repos:
pass_filenames: false pass_filenames: false
language: script language: script
types: [text] types: [text]
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$ files: ^(homeassistant/.+/(manifest|strings)\.json|homeassistant/brands/.*\.json|\.coveragerc|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py)$
- id: hassfest-metadata - id: hassfest-metadata
name: hassfest-metadata name: hassfest-metadata
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker entry: script/run-in-env.sh python3 -m script.hassfest -p metadata
pass_filenames: false pass_filenames: false
language: script language: script
types: [text] types: [text]
files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml|homeassistant/components/go2rtc/const\.py)$ files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml)$
- id: hassfest-mypy-config - id: hassfest-mypy-config
name: hassfest-mypy-config name: hassfest-mypy-config
entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config

View file

@ -1,5 +1,6 @@
*.md *.md
.strict-typing .strict-typing
azure-*.yml
homeassistant/components/*/translations/*.json homeassistant/components/*/translations/*.json
homeassistant/generated/* homeassistant/generated/*
tests/components/lidarr/fixtures/initialize.js tests/components/lidarr/fixtures/initialize.js

View file

@ -21,7 +21,6 @@ homeassistant.helpers.entity_platform
homeassistant.helpers.entity_values homeassistant.helpers.entity_values
homeassistant.helpers.event homeassistant.helpers.event
homeassistant.helpers.reload homeassistant.helpers.reload
homeassistant.helpers.script
homeassistant.helpers.script_variables homeassistant.helpers.script_variables
homeassistant.helpers.singleton homeassistant.helpers.singleton
homeassistant.helpers.sun homeassistant.helpers.sun
@ -43,108 +42,59 @@ homeassistant.components
homeassistant.components.abode.* homeassistant.components.abode.*
homeassistant.components.accuweather.* homeassistant.components.accuweather.*
homeassistant.components.acer_projector.* homeassistant.components.acer_projector.*
homeassistant.components.acmeda.*
homeassistant.components.actiontec.* homeassistant.components.actiontec.*
homeassistant.components.adax.*
homeassistant.components.adguard.* homeassistant.components.adguard.*
homeassistant.components.aftership.* homeassistant.components.aftership.*
homeassistant.components.air_quality.* homeassistant.components.air_quality.*
homeassistant.components.airgradient.*
homeassistant.components.airly.* homeassistant.components.airly.*
homeassistant.components.airnow.*
homeassistant.components.airq.*
homeassistant.components.airthings.*
homeassistant.components.airthings_ble.*
homeassistant.components.airtouch5.*
homeassistant.components.airvisual.* homeassistant.components.airvisual.*
homeassistant.components.airvisual_pro.*
homeassistant.components.airzone.* homeassistant.components.airzone.*
homeassistant.components.airzone_cloud.* homeassistant.components.airzone_cloud.*
homeassistant.components.aladdin_connect.* homeassistant.components.aladdin_connect.*
homeassistant.components.alarm_control_panel.* homeassistant.components.alarm_control_panel.*
homeassistant.components.alert.* homeassistant.components.alert.*
homeassistant.components.alexa.* homeassistant.components.alexa.*
homeassistant.components.alpha_vantage.*
homeassistant.components.amazon_polly.* homeassistant.components.amazon_polly.*
homeassistant.components.amberelectric.*
homeassistant.components.ambient_network.*
homeassistant.components.ambient_station.* homeassistant.components.ambient_station.*
homeassistant.components.amcrest.* homeassistant.components.amcrest.*
homeassistant.components.ampio.* homeassistant.components.ampio.*
homeassistant.components.analytics.* homeassistant.components.analytics.*
homeassistant.components.analytics_insights.*
homeassistant.components.android_ip_webcam.*
homeassistant.components.androidtv.*
homeassistant.components.androidtv_remote.*
homeassistant.components.anel_pwrctrl.*
homeassistant.components.anova.* homeassistant.components.anova.*
homeassistant.components.anthemav.* homeassistant.components.anthemav.*
homeassistant.components.apache_kafka.*
homeassistant.components.apcupsd.* homeassistant.components.apcupsd.*
homeassistant.components.api.*
homeassistant.components.apple_tv.*
homeassistant.components.apprise.*
homeassistant.components.aprs.*
homeassistant.components.apsystems.*
homeassistant.components.aqualogic.* homeassistant.components.aqualogic.*
homeassistant.components.aquostv.*
homeassistant.components.aranet.*
homeassistant.components.arcam_fmj.*
homeassistant.components.arris_tg2492lg.*
homeassistant.components.aruba.*
homeassistant.components.arwn.*
homeassistant.components.aseko_pool_live.* homeassistant.components.aseko_pool_live.*
homeassistant.components.assist_pipeline.* homeassistant.components.assist_pipeline.*
homeassistant.components.assist_satellite.*
homeassistant.components.asuswrt.* homeassistant.components.asuswrt.*
homeassistant.components.autarco.*
homeassistant.components.auth.* homeassistant.components.auth.*
homeassistant.components.automation.* homeassistant.components.automation.*
homeassistant.components.awair.* homeassistant.components.awair.*
homeassistant.components.axis.*
homeassistant.components.backup.* homeassistant.components.backup.*
homeassistant.components.baf.* homeassistant.components.baf.*
homeassistant.components.bang_olufsen.*
homeassistant.components.bayesian.* homeassistant.components.bayesian.*
homeassistant.components.binary_sensor.* homeassistant.components.binary_sensor.*
homeassistant.components.bitcoin.* homeassistant.components.bitcoin.*
homeassistant.components.blockchain.* homeassistant.components.blockchain.*
homeassistant.components.blue_current.*
homeassistant.components.blueprint.*
homeassistant.components.bluesound.*
homeassistant.components.bluetooth.* homeassistant.components.bluetooth.*
homeassistant.components.bluetooth_adapters.*
homeassistant.components.bluetooth_tracker.* homeassistant.components.bluetooth_tracker.*
homeassistant.components.bmw_connected_drive.* homeassistant.components.bmw_connected_drive.*
homeassistant.components.bond.* homeassistant.components.bond.*
homeassistant.components.braviatv.* homeassistant.components.braviatv.*
homeassistant.components.brother.* homeassistant.components.brother.*
homeassistant.components.browser.* homeassistant.components.browser.*
homeassistant.components.bryant_evolution.*
homeassistant.components.bthome.*
homeassistant.components.button.* homeassistant.components.button.*
homeassistant.components.calendar.* homeassistant.components.calendar.*
homeassistant.components.cambridge_audio.*
homeassistant.components.camera.* homeassistant.components.camera.*
homeassistant.components.canary.* homeassistant.components.canary.*
homeassistant.components.cert_expiry.*
homeassistant.components.clickatell.* homeassistant.components.clickatell.*
homeassistant.components.clicksend.* homeassistant.components.clicksend.*
homeassistant.components.climate.* homeassistant.components.climate.*
homeassistant.components.cloud.* homeassistant.components.cloud.*
homeassistant.components.co2signal.*
homeassistant.components.command_line.*
homeassistant.components.config.*
homeassistant.components.configurator.* homeassistant.components.configurator.*
homeassistant.components.counter.*
homeassistant.components.cover.* homeassistant.components.cover.*
homeassistant.components.cpuspeed.* homeassistant.components.cpuspeed.*
homeassistant.components.crownstone.* homeassistant.components.crownstone.*
homeassistant.components.date.*
homeassistant.components.datetime.*
homeassistant.components.deako.*
homeassistant.components.deconz.* homeassistant.components.deconz.*
homeassistant.components.default_config.*
homeassistant.components.demo.* homeassistant.components.demo.*
homeassistant.components.derivative.* homeassistant.components.derivative.*
homeassistant.components.device_automation.* homeassistant.components.device_automation.*
@ -155,36 +105,21 @@ homeassistant.components.dhcp.*
homeassistant.components.diagnostics.* homeassistant.components.diagnostics.*
homeassistant.components.discovergy.* homeassistant.components.discovergy.*
homeassistant.components.dlna_dmr.* homeassistant.components.dlna_dmr.*
homeassistant.components.dlna_dms.*
homeassistant.components.dnsip.* homeassistant.components.dnsip.*
homeassistant.components.doorbird.* homeassistant.components.doorbird.*
homeassistant.components.dormakaba_dkey.* homeassistant.components.dormakaba_dkey.*
homeassistant.components.downloader.*
homeassistant.components.dsmr.* homeassistant.components.dsmr.*
homeassistant.components.duckdns.*
homeassistant.components.dunehd.* homeassistant.components.dunehd.*
homeassistant.components.duotecno.*
homeassistant.components.easyenergy.*
homeassistant.components.ecovacs.*
homeassistant.components.ecowitt.*
homeassistant.components.efergy.* homeassistant.components.efergy.*
homeassistant.components.electrasmart.* homeassistant.components.electrasmart.*
homeassistant.components.electric_kiwi.* homeassistant.components.electric_kiwi.*
homeassistant.components.elevenlabs.*
homeassistant.components.elgato.* homeassistant.components.elgato.*
homeassistant.components.elkm1.* homeassistant.components.elkm1.*
homeassistant.components.emulated_hue.* homeassistant.components.emulated_hue.*
homeassistant.components.energenie_power_sockets.*
homeassistant.components.energy.* homeassistant.components.energy.*
homeassistant.components.energyzero.*
homeassistant.components.enigma2.*
homeassistant.components.enphase_envoy.*
homeassistant.components.eq3btsmart.*
homeassistant.components.esphome.* homeassistant.components.esphome.*
homeassistant.components.event.* homeassistant.components.event.*
homeassistant.components.evil_genius_labs.* homeassistant.components.evil_genius_labs.*
homeassistant.components.evohome.*
homeassistant.components.faa_delays.*
homeassistant.components.fan.* homeassistant.components.fan.*
homeassistant.components.fastdotcom.* homeassistant.components.fastdotcom.*
homeassistant.components.feedreader.* homeassistant.components.feedreader.*
@ -192,7 +127,6 @@ homeassistant.components.file_upload.*
homeassistant.components.filesize.* homeassistant.components.filesize.*
homeassistant.components.filter.* homeassistant.components.filter.*
homeassistant.components.fitbit.* homeassistant.components.fitbit.*
homeassistant.components.flexit_bacnet.*
homeassistant.components.flux_led.* homeassistant.components.flux_led.*
homeassistant.components.forecast_solar.* homeassistant.components.forecast_solar.*
homeassistant.components.fritz.* homeassistant.components.fritz.*
@ -200,23 +134,14 @@ homeassistant.components.fritzbox.*
homeassistant.components.fritzbox_callmonitor.* homeassistant.components.fritzbox_callmonitor.*
homeassistant.components.fronius.* homeassistant.components.fronius.*
homeassistant.components.frontend.* homeassistant.components.frontend.*
homeassistant.components.fujitsu_fglair.*
homeassistant.components.fully_kiosk.* homeassistant.components.fully_kiosk.*
homeassistant.components.fyta.*
homeassistant.components.generic_hygrostat.*
homeassistant.components.generic_thermostat.*
homeassistant.components.geo_location.* homeassistant.components.geo_location.*
homeassistant.components.geocaching.* homeassistant.components.geocaching.*
homeassistant.components.gios.* homeassistant.components.gios.*
homeassistant.components.glances.* homeassistant.components.glances.*
homeassistant.components.go2rtc.*
homeassistant.components.goalzero.* homeassistant.components.goalzero.*
homeassistant.components.google.* homeassistant.components.google.*
homeassistant.components.google_assistant_sdk.*
homeassistant.components.google_cloud.*
homeassistant.components.google_photos.*
homeassistant.components.google_sheets.* homeassistant.components.google_sheets.*
homeassistant.components.govee_ble.*
homeassistant.components.gpsd.* homeassistant.components.gpsd.*
homeassistant.components.greeneye_monitor.* homeassistant.components.greeneye_monitor.*
homeassistant.components.group.* homeassistant.components.group.*
@ -225,9 +150,8 @@ homeassistant.components.hardkernel.*
homeassistant.components.hardware.* homeassistant.components.hardware.*
homeassistant.components.here_travel_time.* homeassistant.components.here_travel_time.*
homeassistant.components.history.* homeassistant.components.history.*
homeassistant.components.history_stats.* homeassistant.components.homeassistant.exposed_entities
homeassistant.components.holiday.* homeassistant.components.homeassistant.triggers.event
homeassistant.components.homeassistant.*
homeassistant.components.homeassistant_alerts.* homeassistant.components.homeassistant_alerts.*
homeassistant.components.homeassistant_green.* homeassistant.components.homeassistant_green.*
homeassistant.components.homeassistant_hardware.* homeassistant.components.homeassistant_hardware.*
@ -244,11 +168,8 @@ homeassistant.components.homekit_controller.select
homeassistant.components.homekit_controller.storage homeassistant.components.homekit_controller.storage
homeassistant.components.homekit_controller.utils homeassistant.components.homekit_controller.utils
homeassistant.components.homewizard.* homeassistant.components.homewizard.*
homeassistant.components.homeworks.*
homeassistant.components.http.* homeassistant.components.http.*
homeassistant.components.huawei_lte.* homeassistant.components.huawei_lte.*
homeassistant.components.humidifier.*
homeassistant.components.husqvarna_automower.*
homeassistant.components.hydrawise.* homeassistant.components.hydrawise.*
homeassistant.components.hyperion.* homeassistant.components.hyperion.*
homeassistant.components.ibeacon.* homeassistant.components.ibeacon.*
@ -257,15 +178,10 @@ homeassistant.components.image.*
homeassistant.components.image_processing.* homeassistant.components.image_processing.*
homeassistant.components.image_upload.* homeassistant.components.image_upload.*
homeassistant.components.imap.* homeassistant.components.imap.*
homeassistant.components.imgw_pib.*
homeassistant.components.input_button.* homeassistant.components.input_button.*
homeassistant.components.input_select.* homeassistant.components.input_select.*
homeassistant.components.input_text.* homeassistant.components.input_text.*
homeassistant.components.integration.* homeassistant.components.integration.*
homeassistant.components.intent.*
homeassistant.components.intent_script.*
homeassistant.components.ios.*
homeassistant.components.iotty.*
homeassistant.components.ipp.* homeassistant.components.ipp.*
homeassistant.components.iqvia.* homeassistant.components.iqvia.*
homeassistant.components.islamic_prayer_times.* homeassistant.components.islamic_prayer_times.*
@ -274,24 +190,19 @@ homeassistant.components.jellyfin.*
homeassistant.components.jewish_calendar.* homeassistant.components.jewish_calendar.*
homeassistant.components.jvc_projector.* homeassistant.components.jvc_projector.*
homeassistant.components.kaleidescape.* homeassistant.components.kaleidescape.*
homeassistant.components.knocki.*
homeassistant.components.knx.* homeassistant.components.knx.*
homeassistant.components.kraken.* homeassistant.components.kraken.*
homeassistant.components.lacrosse.* homeassistant.components.lacrosse.*
homeassistant.components.lacrosse_view.* homeassistant.components.lacrosse_view.*
homeassistant.components.lamarzocco.*
homeassistant.components.lametric.* homeassistant.components.lametric.*
homeassistant.components.laundrify.* homeassistant.components.laundrify.*
homeassistant.components.lawn_mower.* homeassistant.components.lawn_mower.*
homeassistant.components.lcn.* homeassistant.components.lcn.*
homeassistant.components.ld2410_ble.* homeassistant.components.ld2410_ble.*
homeassistant.components.led_ble.*
homeassistant.components.lektrico.*
homeassistant.components.lidarr.* homeassistant.components.lidarr.*
homeassistant.components.lifx.* homeassistant.components.lifx.*
homeassistant.components.light.* homeassistant.components.light.*
homeassistant.components.linear_garage_door.* homeassistant.components.linear_garage_door.*
homeassistant.components.linkplay.*
homeassistant.components.litejet.* homeassistant.components.litejet.*
homeassistant.components.litterrobot.* homeassistant.components.litterrobot.*
homeassistant.components.local_ip.* homeassistant.components.local_ip.*
@ -302,82 +213,60 @@ homeassistant.components.logger.*
homeassistant.components.london_underground.* homeassistant.components.london_underground.*
homeassistant.components.lookin.* homeassistant.components.lookin.*
homeassistant.components.luftdaten.* homeassistant.components.luftdaten.*
homeassistant.components.madvr.* homeassistant.components.mailbox.*
homeassistant.components.manual.*
homeassistant.components.mastodon.* homeassistant.components.mastodon.*
homeassistant.components.matrix.* homeassistant.components.matrix.*
homeassistant.components.matter.* homeassistant.components.matter.*
homeassistant.components.media_extractor.* homeassistant.components.media_extractor.*
homeassistant.components.media_player.* homeassistant.components.media_player.*
homeassistant.components.media_source.* homeassistant.components.media_source.*
homeassistant.components.met_eireann.*
homeassistant.components.metoffice.* homeassistant.components.metoffice.*
homeassistant.components.mikrotik.* homeassistant.components.mikrotik.*
homeassistant.components.min_max.* homeassistant.components.min_max.*
homeassistant.components.minecraft_server.*
homeassistant.components.mjpeg.* homeassistant.components.mjpeg.*
homeassistant.components.modbus.* homeassistant.components.modbus.*
homeassistant.components.modem_callerid.* homeassistant.components.modem_callerid.*
homeassistant.components.mold_indicator.*
homeassistant.components.monzo.*
homeassistant.components.moon.* homeassistant.components.moon.*
homeassistant.components.mopeka.* homeassistant.components.mopeka.*
homeassistant.components.motionmount.*
homeassistant.components.mqtt.* homeassistant.components.mqtt.*
homeassistant.components.music_assistant.*
homeassistant.components.my.*
homeassistant.components.mysensors.* homeassistant.components.mysensors.*
homeassistant.components.myuplink.*
homeassistant.components.nam.* homeassistant.components.nam.*
homeassistant.components.nanoleaf.* homeassistant.components.nanoleaf.*
homeassistant.components.nasweb.*
homeassistant.components.neato.* homeassistant.components.neato.*
homeassistant.components.nest.* homeassistant.components.nest.*
homeassistant.components.netatmo.* homeassistant.components.netatmo.*
homeassistant.components.network.* homeassistant.components.network.*
homeassistant.components.nextdns.* homeassistant.components.nextdns.*
homeassistant.components.nfandroidtv.* homeassistant.components.nfandroidtv.*
homeassistant.components.nightscout.*
homeassistant.components.nissan_leaf.* homeassistant.components.nissan_leaf.*
homeassistant.components.no_ip.* homeassistant.components.no_ip.*
homeassistant.components.nordpool.*
homeassistant.components.notify.* homeassistant.components.notify.*
homeassistant.components.notion.* homeassistant.components.notion.*
homeassistant.components.number.* homeassistant.components.number.*
homeassistant.components.nut.* homeassistant.components.nut.*
homeassistant.components.onboarding.*
homeassistant.components.oncue.* homeassistant.components.oncue.*
homeassistant.components.onewire.* homeassistant.components.onewire.*
homeassistant.components.onkyo.*
homeassistant.components.open_meteo.* homeassistant.components.open_meteo.*
homeassistant.components.openai_conversation.*
homeassistant.components.openexchangerates.* homeassistant.components.openexchangerates.*
homeassistant.components.opensky.* homeassistant.components.opensky.*
homeassistant.components.openuv.* homeassistant.components.openuv.*
homeassistant.components.oralb.*
homeassistant.components.otbr.* homeassistant.components.otbr.*
homeassistant.components.overkiz.* homeassistant.components.overkiz.*
homeassistant.components.p1_monitor.*
homeassistant.components.panel_custom.*
homeassistant.components.peco.* homeassistant.components.peco.*
homeassistant.components.persistent_notification.* homeassistant.components.persistent_notification.*
homeassistant.components.pi_hole.* homeassistant.components.pi_hole.*
homeassistant.components.ping.* homeassistant.components.ping.*
homeassistant.components.plugwise.* homeassistant.components.plugwise.*
homeassistant.components.poolsense.*
homeassistant.components.powerwall.* homeassistant.components.powerwall.*
homeassistant.components.private_ble_device.* homeassistant.components.private_ble_device.*
homeassistant.components.prometheus.*
homeassistant.components.proximity.* homeassistant.components.proximity.*
homeassistant.components.prusalink.* homeassistant.components.prusalink.*
homeassistant.components.pure_energie.* homeassistant.components.pure_energie.*
homeassistant.components.purpleair.* homeassistant.components.purpleair.*
homeassistant.components.pushbullet.*
homeassistant.components.pvoutput.* homeassistant.components.pvoutput.*
homeassistant.components.qnap_qsw.* homeassistant.components.qnap_qsw.*
homeassistant.components.rabbitair.*
homeassistant.components.radarr.* homeassistant.components.radarr.*
homeassistant.components.radio_browser.*
homeassistant.components.rainforest_raven.*
homeassistant.components.rainmachine.* homeassistant.components.rainmachine.*
homeassistant.components.raspberry_pi.* homeassistant.components.raspberry_pi.*
homeassistant.components.rdw.* homeassistant.components.rdw.*
@ -387,15 +276,11 @@ homeassistant.components.remote.*
homeassistant.components.renault.* homeassistant.components.renault.*
homeassistant.components.repairs.* homeassistant.components.repairs.*
homeassistant.components.rest.* homeassistant.components.rest.*
homeassistant.components.rest_command.*
homeassistant.components.rfxtrx.* homeassistant.components.rfxtrx.*
homeassistant.components.rhasspy.* homeassistant.components.rhasspy.*
homeassistant.components.ridwell.* homeassistant.components.ridwell.*
homeassistant.components.ring.*
homeassistant.components.rituals_perfume_genie.* homeassistant.components.rituals_perfume_genie.*
homeassistant.components.roborock.*
homeassistant.components.roku.* homeassistant.components.roku.*
homeassistant.components.romy.*
homeassistant.components.rpi_power.* homeassistant.components.rpi_power.*
homeassistant.components.rss_feed_template.* homeassistant.components.rss_feed_template.*
homeassistant.components.rtsp_to_webrtc.* homeassistant.components.rtsp_to_webrtc.*
@ -405,76 +290,50 @@ homeassistant.components.samsungtv.*
homeassistant.components.scene.* homeassistant.components.scene.*
homeassistant.components.schedule.* homeassistant.components.schedule.*
homeassistant.components.scrape.* homeassistant.components.scrape.*
homeassistant.components.script.*
homeassistant.components.search.*
homeassistant.components.select.* homeassistant.components.select.*
homeassistant.components.sensibo.* homeassistant.components.sensibo.*
homeassistant.components.sensirion_ble.* homeassistant.components.sensirion_ble.*
homeassistant.components.sensor.* homeassistant.components.sensor.*
homeassistant.components.sensoterra.*
homeassistant.components.senz.* homeassistant.components.senz.*
homeassistant.components.sfr_box.* homeassistant.components.sfr_box.*
homeassistant.components.shell_command.*
homeassistant.components.shelly.* homeassistant.components.shelly.*
homeassistant.components.shopping_list.*
homeassistant.components.simplepush.* homeassistant.components.simplepush.*
homeassistant.components.simplisafe.* homeassistant.components.simplisafe.*
homeassistant.components.siren.*
homeassistant.components.skybell.* homeassistant.components.skybell.*
homeassistant.components.slack.* homeassistant.components.slack.*
homeassistant.components.sleepiq.* homeassistant.components.sleepiq.*
homeassistant.components.smhi.* homeassistant.components.smhi.*
homeassistant.components.smlight.*
homeassistant.components.snooz.* homeassistant.components.snooz.*
homeassistant.components.solarlog.*
homeassistant.components.sonarr.* homeassistant.components.sonarr.*
homeassistant.components.speedtestdotnet.* homeassistant.components.speedtestdotnet.*
homeassistant.components.spotify.*
homeassistant.components.sql.* homeassistant.components.sql.*
homeassistant.components.squeezebox.*
homeassistant.components.ssdp.* homeassistant.components.ssdp.*
homeassistant.components.starlink.* homeassistant.components.starlink.*
homeassistant.components.statistics.* homeassistant.components.statistics.*
homeassistant.components.steamist.* homeassistant.components.steamist.*
homeassistant.components.stookalert.* homeassistant.components.stookalert.*
homeassistant.components.stream.* homeassistant.components.stream.*
homeassistant.components.streamlabswater.*
homeassistant.components.stt.*
homeassistant.components.suez_water.*
homeassistant.components.sun.* homeassistant.components.sun.*
homeassistant.components.surepetcare.* homeassistant.components.surepetcare.*
homeassistant.components.switch.* homeassistant.components.switch.*
homeassistant.components.switch_as_x.*
homeassistant.components.switchbee.* homeassistant.components.switchbee.*
homeassistant.components.switchbot_cloud.* homeassistant.components.switchbot_cloud.*
homeassistant.components.switcher_kis.* homeassistant.components.switcher_kis.*
homeassistant.components.synology_dsm.* homeassistant.components.synology_dsm.*
homeassistant.components.system_health.*
homeassistant.components.system_log.*
homeassistant.components.systemmonitor.* homeassistant.components.systemmonitor.*
homeassistant.components.tag.* homeassistant.components.tag.*
homeassistant.components.tailscale.* homeassistant.components.tailscale.*
homeassistant.components.tailwind.*
homeassistant.components.tami4.* homeassistant.components.tami4.*
homeassistant.components.tautulli.* homeassistant.components.tautulli.*
homeassistant.components.tcp.* homeassistant.components.tcp.*
homeassistant.components.technove.*
homeassistant.components.tedee.*
homeassistant.components.text.* homeassistant.components.text.*
homeassistant.components.thethingsnetwork.*
homeassistant.components.threshold.* homeassistant.components.threshold.*
homeassistant.components.tibber.* homeassistant.components.tibber.*
homeassistant.components.tile.* homeassistant.components.tile.*
homeassistant.components.tilt_ble.* homeassistant.components.tilt_ble.*
homeassistant.components.time.*
homeassistant.components.time_date.*
homeassistant.components.timer.*
homeassistant.components.tod.*
homeassistant.components.todo.*
homeassistant.components.tolo.* homeassistant.components.tolo.*
homeassistant.components.tplink.* homeassistant.components.tplink.*
homeassistant.components.tplink_omada.* homeassistant.components.tplink_omada.*
homeassistant.components.trace.*
homeassistant.components.tractive.* homeassistant.components.tractive.*
homeassistant.components.tradfri.* homeassistant.components.tradfri.*
homeassistant.components.trafikverket_camera.* homeassistant.components.trafikverket_camera.*
@ -492,20 +351,15 @@ homeassistant.components.update.*
homeassistant.components.uptime.* homeassistant.components.uptime.*
homeassistant.components.uptimerobot.* homeassistant.components.uptimerobot.*
homeassistant.components.usb.* homeassistant.components.usb.*
homeassistant.components.uvc.*
homeassistant.components.vacuum.* homeassistant.components.vacuum.*
homeassistant.components.vallox.* homeassistant.components.vallox.*
homeassistant.components.valve.*
homeassistant.components.velbus.* homeassistant.components.velbus.*
homeassistant.components.vlc_telnet.* homeassistant.components.vlc_telnet.*
homeassistant.components.wake_on_lan.* homeassistant.components.wake_on_lan.*
homeassistant.components.wake_word.*
homeassistant.components.wallbox.* homeassistant.components.wallbox.*
homeassistant.components.waqi.*
homeassistant.components.water_heater.* homeassistant.components.water_heater.*
homeassistant.components.watttime.* homeassistant.components.watttime.*
homeassistant.components.weather.* homeassistant.components.weather.*
homeassistant.components.webhook.*
homeassistant.components.webostv.* homeassistant.components.webostv.*
homeassistant.components.websocket_api.* homeassistant.components.websocket_api.*
homeassistant.components.wemo.* homeassistant.components.wemo.*
@ -513,12 +367,9 @@ homeassistant.components.whois.*
homeassistant.components.withings.* homeassistant.components.withings.*
homeassistant.components.wiz.* homeassistant.components.wiz.*
homeassistant.components.wled.* homeassistant.components.wled.*
homeassistant.components.workday.*
homeassistant.components.worldclock.* homeassistant.components.worldclock.*
homeassistant.components.xiaomi_ble.*
homeassistant.components.yale_smart_alarm.* homeassistant.components.yale_smart_alarm.*
homeassistant.components.yalexs_ble.* homeassistant.components.yalexs_ble.*
homeassistant.components.youtube.*
homeassistant.components.zeroconf.* homeassistant.components.zeroconf.*
homeassistant.components.zodiac.* homeassistant.components.zodiac.*
homeassistant.components.zone.* homeassistant.components.zone.*

42
.vscode/launch.json vendored
View file

@ -6,52 +6,38 @@
"configurations": [ "configurations": [
{ {
"name": "Home Assistant", "name": "Home Assistant",
"type": "debugpy", "type": "python",
"request": "launch", "request": "launch",
"module": "homeassistant", "module": "homeassistant",
"justMyCode": false, "justMyCode": false,
"args": [ "args": ["--debug", "-c", "config"],
"--debug",
"-c",
"config"
],
"preLaunchTask": "Compile English translations" "preLaunchTask": "Compile English translations"
}, },
{ {
"name": "Home Assistant (skip pip)", "name": "Home Assistant (skip pip)",
"type": "debugpy", "type": "python",
"request": "launch", "request": "launch",
"module": "homeassistant", "module": "homeassistant",
"justMyCode": false, "justMyCode": false,
"args": [ "args": ["--debug", "-c", "config", "--skip-pip"],
"--debug",
"-c",
"config",
"--skip-pip"
],
"preLaunchTask": "Compile English translations" "preLaunchTask": "Compile English translations"
}, },
{ {
"name": "Home Assistant: Changed tests", "name": "Home Assistant: Changed tests",
"type": "debugpy", "type": "python",
"request": "launch", "request": "launch",
"module": "pytest", "module": "pytest",
"justMyCode": false, "justMyCode": false,
"args": [ "args": ["--timeout=10", "--picked"],
"--timeout=10",
"--picked"
],
}, },
{ {
// Debug by attaching to local Home Assistant server using Remote Python Debugger. // Debug by attaching to local Home Assistant server using Remote Python Debugger.
// See https://www.home-assistant.io/integrations/debugpy/ // See https://www.home-assistant.io/integrations/debugpy/
"name": "Home Assistant: Attach Local", "name": "Home Assistant: Attach Local",
"type": "debugpy", "type": "python",
"request": "attach", "request": "attach",
"connect": { "port": 5678,
"port": 5678, "host": "localhost",
"host": "localhost"
},
"pathMappings": [ "pathMappings": [
{ {
"localRoot": "${workspaceFolder}", "localRoot": "${workspaceFolder}",
@ -63,12 +49,10 @@
// Debug by attaching to remote Home Assistant server using Remote Python Debugger. // Debug by attaching to remote Home Assistant server using Remote Python Debugger.
// See https://www.home-assistant.io/integrations/debugpy/ // See https://www.home-assistant.io/integrations/debugpy/
"name": "Home Assistant: Attach Remote", "name": "Home Assistant: Attach Remote",
"type": "debugpy", "type": "python",
"request": "attach", "request": "attach",
"connect": { "port": 5678,
"port": 5678, "host": "homeassistant.local",
"host": "homeassistant.local"
},
"pathMappings": [ "pathMappings": [
{ {
"localRoot": "${workspaceFolder}", "localRoot": "${workspaceFolder}",
@ -77,4 +61,4 @@
] ]
} }
] ]
} }

View file

@ -4,15 +4,5 @@
// https://github.com/microsoft/vscode-python/issues/14067 // https://github.com/microsoft/vscode-python/issues/14067
"python.testing.pytestArgs": ["--no-cov"], "python.testing.pytestArgs": ["--no-cov"],
// https://code.visualstudio.com/docs/python/testing#_pytest-configuration-settings // https://code.visualstudio.com/docs/python/testing#_pytest-configuration-settings
"python.testing.pytestEnabled": false, "python.testing.pytestEnabled": false
// https://code.visualstudio.com/docs/python/linting#_general-settings
"pylint.importStrategy": "fromEnvironment",
"json.schemas": [
{
"fileMatch": [
"homeassistant/components/*/manifest.json"
],
"url": "./script/json_schemas/manifest_schema.json"
}
]
} }

19
.vscode/tasks.json vendored
View file

@ -76,7 +76,6 @@
"detail": "Generate code coverage report for a given integration.", "detail": "Generate code coverage report for a given integration.",
"type": "shell", "type": "shell",
"command": "python3 -m pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing --durations-min=1 --durations=0 --numprocesses=auto", "command": "python3 -m pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing --durations-min=1 --durations=0 --numprocesses=auto",
"dependsOn": ["Compile English translations"],
"group": { "group": {
"kind": "test", "kind": "test",
"isDefault": true "isDefault": true
@ -104,7 +103,7 @@
{ {
"label": "Install all Requirements", "label": "Install all Requirements",
"type": "shell", "type": "shell",
"command": "uv pip install -r requirements_all.txt", "command": "pip3 install -r requirements_all.txt",
"group": { "group": {
"kind": "build", "kind": "build",
"isDefault": true "isDefault": true
@ -118,7 +117,7 @@
{ {
"label": "Install all Test Requirements", "label": "Install all Test Requirements",
"type": "shell", "type": "shell",
"command": "uv pip install -r requirements_test_all.txt", "command": "pip3 install -r requirements_test_all.txt",
"group": { "group": {
"kind": "build", "kind": "build",
"isDefault": true "isDefault": true
@ -158,20 +157,6 @@
"kind": "build", "kind": "build",
"isDefault": true "isDefault": true
} }
},
{
"label": "Install integration requirements",
"detail": "Install all requirements of a given integration.",
"type": "shell",
"command": "${command:python.interpreterPath} -m script.install_integration_requirements ${input:integrationName}",
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "new"
}
} }
], ],
"inputs": [ "inputs": [

View file

@ -1,4 +1,5 @@
ignore: | ignore: |
azure-*.yml
tests/fixtures/core/config/yaml_errors/ tests/fixtures/core/config/yaml_errors/
rules: rules:
braces: braces:

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@
We as members, contributors, and leaders pledge to make participation in our We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socioeconomic status, identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity nationality, personal appearance, race, religion, or sexual identity
and orientation. and orientation.

View file

@ -1,43 +1,42 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM ARG BUILD_FROM
FROM ${BUILD_FROM} FROM ${BUILD_FROM}
# Synchronize with homeassistant/core.py:async_stop # Synchronize with homeassistant/core.py:async_stop
ENV \ ENV \
S6_SERVICES_GRACETIME=240000 \ S6_SERVICES_GRACETIME=220000
UV_SYSTEM_PYTHON=true \
UV_NO_CACHE=true
ARG QEMU_CPU ARG QEMU_CPU
# Install uv
RUN pip3 install uv==0.5.0
WORKDIR /usr/src WORKDIR /usr/src
## Setup Home Assistant Core dependencies ## Setup Home Assistant Core dependencies
COPY requirements.txt homeassistant/ COPY requirements.txt homeassistant/
COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ COPY homeassistant/package_constraints.txt homeassistant/homeassistant/
RUN \ RUN \
uv pip install \ pip3 install \
--no-build \ --only-binary=:all: \
-r homeassistant/requirements.txt -r homeassistant/requirements.txt
COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/
RUN \ RUN \
if ls homeassistant/home_assistant_*.whl 1> /dev/null 2>&1; then \ if ls homeassistant/home_assistant_frontend*.whl 1> /dev/null 2>&1; then \
uv pip install homeassistant/home_assistant_*.whl; \ pip3 install homeassistant/home_assistant_frontend-*.whl; \
fi \ fi \
&& uv pip install \ && if ls homeassistant/home_assistant_intents*.whl 1> /dev/null 2>&1; then \
--no-build \ pip3 install homeassistant/home_assistant_intents-*.whl; \
-r homeassistant/requirements_all.txt fi \
&& \
LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \
MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \
pip3 install \
--only-binary=:all: \
-r homeassistant/requirements_all.txt
## Setup Home Assistant Core ## Setup Home Assistant Core
COPY . homeassistant/ COPY . homeassistant/
RUN \ RUN \
uv pip install \ pip3 install \
--only-binary=:all: \
-e ./homeassistant \ -e ./homeassistant \
&& python3 -m compileall \ && python3 -m compileall \
homeassistant/homeassistant homeassistant/homeassistant
@ -45,19 +44,4 @@ RUN \
# Home Assistant S6-Overlay # Home Assistant S6-Overlay
COPY rootfs / COPY rootfs /
# Needs to be redefined inside the FROM statement to be set for RUN commands
ARG BUILD_ARCH
# Get go2rtc binary
RUN \
case "${BUILD_ARCH}" in \
"aarch64") go2rtc_suffix='arm64' ;; \
"armhf") go2rtc_suffix='armv6' ;; \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.7/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
WORKDIR /config WORKDIR /config

View file

@ -1,4 +1,4 @@
FROM mcr.microsoft.com/devcontainers/python:1-3.12 FROM mcr.microsoft.com/vscode/devcontainers/python:0-3.11
SHELL ["/bin/bash", "-o", "pipefail", "-c"] SHELL ["/bin/bash", "-o", "pipefail", "-c"]
@ -16,13 +16,11 @@ RUN \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
# Additional library needed by some tests and accordingly by VScode Tests Discovery # Additional library needed by some tests and accordingly by VScode Tests Discovery
bluez \ bluez \
ffmpeg \
libudev-dev \ libudev-dev \
libavformat-dev \ libavformat-dev \
libavcodec-dev \ libavcodec-dev \
libavdevice-dev \ libavdevice-dev \
libavutil-dev \ libavutil-dev \
libgammu-dev \
libswscale-dev \ libswscale-dev \
libswresample-dev \ libswresample-dev \
libavfilter-dev \ libavfilter-dev \
@ -35,34 +33,21 @@ RUN \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc
# Install uv
RUN pip3 install uv
WORKDIR /usr/src WORKDIR /usr/src
# Setup hass-release # Setup hass-release
RUN git clone --depth 1 https://github.com/home-assistant/hass-release \ RUN git clone --depth 1 https://github.com/home-assistant/hass-release \
&& uv pip install --system -e hass-release/ \ && pip3 install -e hass-release/
&& chown -R vscode /usr/src/hass-release/data
USER vscode WORKDIR /workspaces
ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv"
RUN uv venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
WORKDIR /tmp
# Install Python dependencies from requirements # Install Python dependencies from requirements
COPY requirements.txt ./ COPY requirements.txt ./
COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt
RUN uv pip install -r requirements.txt RUN pip3 install -r requirements.txt
COPY requirements_test.txt requirements_test_pre_commit.txt ./ COPY requirements_test.txt requirements_test_pre_commit.txt ./
RUN uv pip install -r requirements_test.txt RUN pip3 install -r requirements_test.txt
RUN rm -rf requirements.txt requirements_test.txt requirements_test_pre_commit.txt homeassistant/
WORKDIR /workspaces
# Set the default shell to bash instead of sh # Set the default shell to bash instead of sh
ENV SHELL /bin/bash ENV SHELL /bin/bash

View file

@ -20,14 +20,9 @@ components <https://developers.home-assistant.io/docs/creating_component_index/>
If you run into issues while using Home Assistant or during development If you run into issues while using Home Assistant or during development
of a component, check the `Home Assistant help section <https://home-assistant.io/help/>`__ of our website for further help and information. of a component, check the `Home Assistant help section <https://home-assistant.io/help/>`__ of our website for further help and information.
|ohf-logo|
.. |Chat Status| image:: https://img.shields.io/discord/330944238910963714.svg .. |Chat Status| image:: https://img.shields.io/discord/330944238910963714.svg
:target: https://www.home-assistant.io/join-chat/ :target: https://www.home-assistant.io/join-chat/
.. |screenshot-states| image:: https://raw.githubusercontent.com/home-assistant/core/dev/.github/assets/screenshot-states.png .. |screenshot-states| image:: https://raw.githubusercontent.com/home-assistant/core/master/docs/screenshots.png
:target: https://demo.home-assistant.io :target: https://demo.home-assistant.io
.. |screenshot-integrations| image:: https://raw.githubusercontent.com/home-assistant/core/dev/.github/assets/screenshot-integrations.png .. |screenshot-integrations| image:: https://raw.githubusercontent.com/home-assistant/core/dev/docs/screenshot-integrations.png
:target: https://home-assistant.io/integrations/ :target: https://home-assistant.io/integrations/
.. |ohf-logo| image:: https://www.openhomefoundation.org/badges/home-assistant.png
:alt: Home Assistant - A project from the Open Home Foundation
:target: https://www.openhomefoundation.org/

View file

@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant image: ghcr.io/home-assistant/{arch}-homeassistant
build_from: build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.11.0 aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.10.1
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.11.0 armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.10.1
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.11.0 armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.10.1
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.11.0 amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.10.1
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.11.0 i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.10.1
codenotary: codenotary:
signer: notary@home-assistant.io signer: notary@home-assistant.io
base_image: notary@home-assistant.io base_image: notary@home-assistant.io

View file

@ -4,7 +4,7 @@ coverage:
status: status:
project: project:
default: default:
target: auto target: 90
threshold: 0.09 threshold: 0.09
required: required:
target: auto target: auto

View file

@ -1,15 +1,12 @@
"""Start Home Assistant.""" """Start Home Assistant."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from contextlib import suppress
import faulthandler import faulthandler
import os import os
import sys import sys
import threading import threading
from .backup_restore import restore_backup
from .const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__ from .const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__
FAULT_LOG_FILENAME = "home-assistant.log.fault" FAULT_LOG_FILENAME = "home-assistant.log.fault"
@ -148,7 +145,9 @@ def get_arguments() -> argparse.Namespace:
help="Skips validation of operating system", help="Skips validation of operating system",
) )
return parser.parse_args() arguments = parser.parse_args()
return arguments
def check_threads() -> None: def check_threads() -> None:
@ -183,9 +182,6 @@ def main() -> int:
return scripts.run(args.script) return scripts.run(args.script)
config_dir = os.path.abspath(os.path.join(os.getcwd(), args.config)) config_dir = os.path.abspath(os.path.join(os.getcwd(), args.config))
if restore_backup(config_dir):
return RESTART_EXIT_CODE
ensure_config_path(config_dir) ensure_config_path(config_dir)
# pylint: disable-next=import-outside-toplevel # pylint: disable-next=import-outside-toplevel
@ -213,10 +209,8 @@ def main() -> int:
exit_code = runner.run(runtime_conf) exit_code = runner.run(runtime_conf)
faulthandler.disable() faulthandler.disable()
# It's possible for the fault file to disappear, so suppress obvious errors if os.path.getsize(fault_file_name) == 0:
with suppress(FileNotFoundError): os.remove(fault_file_name)
if os.path.getsize(fault_file_name) == 0:
os.remove(fault_file_name)
check_threads() check_threads()

View file

@ -1,42 +1,31 @@
"""Provide an authentication layer for Home Assistant.""" """Provide an authentication layer for Home Assistant."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections import OrderedDict from collections import OrderedDict
from collections.abc import Mapping from collections.abc import Mapping
from datetime import datetime, timedelta from datetime import timedelta
from functools import partial
import time import time
from typing import Any, cast from typing import Any, cast
import jwt import jwt
from homeassistant.core import ( from homeassistant import data_entry_flow
CALLBACK_TYPE, from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
HassJob, from homeassistant.data_entry_flow import FlowResult
HassJobType,
HomeAssistant,
callback,
)
from homeassistant.data_entry_flow import FlowHandler, FlowManager, FlowResultType
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util import dt as dt_util
from . import auth_store, jwt_wrapper, models from . import auth_store, jwt_wrapper, models
from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN, REFRESH_TOKEN_EXPIRATION from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN
from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config
from .models import AuthFlowContext, AuthFlowResult
from .providers import AuthProvider, LoginFlow, auth_provider_from_config from .providers import AuthProvider, LoginFlow, auth_provider_from_config
from .providers.homeassistant import HassAuthProvider
EVENT_USER_ADDED = "user_added" EVENT_USER_ADDED = "user_added"
EVENT_USER_UPDATED = "user_updated" EVENT_USER_UPDATED = "user_updated"
EVENT_USER_REMOVED = "user_removed" EVENT_USER_REMOVED = "user_removed"
type _MfaModuleDict = dict[str, MultiFactorAuthModule] _MfaModuleDict = dict[str, MultiFactorAuthModule]
type _ProviderKey = tuple[str, str | None] _ProviderKey = tuple[str, str | None]
type _ProviderDict = dict[_ProviderKey, AuthProvider] _ProviderDict = dict[_ProviderKey, AuthProvider]
class InvalidAuthError(Exception): class InvalidAuthError(Exception):
@ -54,11 +43,10 @@ async def auth_manager_from_config(
) -> AuthManager: ) -> AuthManager:
"""Initialize an auth manager from config. """Initialize an auth manager from config.
CORE_CONFIG_SCHEMA will make sure no duplicated auth providers or CORE_CONFIG_SCHEMA will make sure do duplicated auth providers or
mfa modules exist in configs. mfa modules exist in configs.
""" """
store = auth_store.AuthStore(hass) store = auth_store.AuthStore(hass)
await store.async_load()
if provider_configs: if provider_configs:
providers = await asyncio.gather( providers = await asyncio.gather(
*( *(
@ -74,13 +62,6 @@ async def auth_manager_from_config(
key = (provider.type, provider.id) key = (provider.type, provider.id)
provider_hash[key] = provider 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
await provider.async_initialize()
if module_configs: if module_configs:
modules = await asyncio.gather( modules = await asyncio.gather(
*(auth_mfa_module_from_config(hass, config) for config in module_configs) *(auth_mfa_module_from_config(hass, config) for config in module_configs)
@ -93,17 +74,12 @@ async def auth_manager_from_config(
module_hash[module.id] = module module_hash[module.id] = module
manager = AuthManager(hass, store, provider_hash, module_hash) manager = AuthManager(hass, store, provider_hash, module_hash)
await manager.async_setup()
return manager return manager
class AuthManagerFlowManager( class AuthManagerFlowManager(data_entry_flow.FlowManager):
FlowManager[AuthFlowContext, AuthFlowResult, tuple[str, str]]
):
"""Manage authentication flows.""" """Manage authentication flows."""
_flow_result = AuthFlowResult
def __init__(self, hass: HomeAssistant, auth_manager: AuthManager) -> None: def __init__(self, hass: HomeAssistant, auth_manager: AuthManager) -> None:
"""Init auth manager flows.""" """Init auth manager flows."""
super().__init__(hass) super().__init__(hass)
@ -111,11 +87,11 @@ class AuthManagerFlowManager(
async def async_create_flow( async def async_create_flow(
self, self,
handler_key: tuple[str, str], handler_key: str,
*, *,
context: AuthFlowContext | None = None, context: dict[str, Any] | None = None,
data: dict[str, Any] | None = None, data: dict[str, Any] | None = None,
) -> LoginFlow: ) -> data_entry_flow.FlowHandler:
"""Create a login flow.""" """Create a login flow."""
auth_provider = self.auth_manager.get_auth_provider(*handler_key) auth_provider = self.auth_manager.get_auth_provider(*handler_key)
if not auth_provider: if not auth_provider:
@ -123,18 +99,12 @@ class AuthManagerFlowManager(
return await auth_provider.async_login_flow(context) return await auth_provider.async_login_flow(context)
async def async_finish_flow( async def async_finish_flow(
self, self, flow: data_entry_flow.FlowHandler, result: FlowResult
flow: FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]], ) -> FlowResult:
result: AuthFlowResult, """Return a user as result of login flow."""
) -> AuthFlowResult:
"""Return a user as result of login flow.
This method is called when a flow step returns FlowResultType.ABORT or
FlowResultType.CREATE_ENTRY.
"""
flow = cast(LoginFlow, flow) flow = cast(LoginFlow, flow)
if result["type"] != FlowResultType.CREATE_ENTRY: if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY:
return result return result
# we got final result # we got final result
@ -187,21 +157,7 @@ class AuthManager:
self._providers = providers self._providers = providers
self._mfa_modules = mfa_modules self._mfa_modules = mfa_modules
self.login_flow = AuthManagerFlowManager(hass, self) self.login_flow = AuthManagerFlowManager(hass, self)
self._revoke_callbacks: dict[str, set[CALLBACK_TYPE]] = {} self._revoke_callbacks: dict[str, list[CALLBACK_TYPE]] = {}
self._expire_callback: CALLBACK_TYPE | None = None
self._remove_expired_job = HassJob(
self._async_remove_expired_refresh_tokens, job_type=HassJobType.Callback
)
async def async_setup(self) -> None:
"""Set up the auth manager."""
hass = self.hass
hass.async_add_shutdown_job(
HassJob(
self._async_cancel_expiration_schedule, job_type=HassJobType.Callback
)
)
self._async_track_next_refresh_token_expiration()
@property @property
def auth_providers(self) -> list[AuthProvider]: def auth_providers(self) -> list[AuthProvider]:
@ -367,15 +323,15 @@ class AuthManager:
local_only: bool | None = None, local_only: bool | None = None,
) -> None: ) -> None:
"""Update a user.""" """Update a user."""
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {}
attr_name: value
for attr_name, value in ( for attr_name, value in (
("name", name), ("name", name),
("group_ids", group_ids), ("group_ids", group_ids),
("local_only", local_only), ("local_only", local_only),
) ):
if value is not None if value is not None:
} kwargs[attr_name] = value
await self._store.async_update_user(user, **kwargs) await self._store.async_update_user(user, **kwargs)
if is_active is not None: if is_active is not None:
@ -386,13 +342,6 @@ class AuthManager:
self.hass.bus.async_fire(EVENT_USER_UPDATED, {"user_id": user.id}) self.hass.bus.async_fire(EVENT_USER_UPDATED, {"user_id": user.id})
@callback
def async_update_user_credentials_data(
self, credentials: models.Credentials, data: dict[str, Any]
) -> None:
"""Update credentials data."""
self._store.async_update_user_credentials_data(credentials, data=data)
async def async_activate_user(self, user: models.User) -> None: async def async_activate_user(self, user: models.User) -> None:
"""Activate a user.""" """Activate a user."""
await self._store.async_activate_user(user) await self._store.async_activate_user(user)
@ -474,11 +423,6 @@ class AuthManager:
else: else:
token_type = models.TOKEN_TYPE_NORMAL token_type = models.TOKEN_TYPE_NORMAL
if token_type is models.TOKEN_TYPE_NORMAL:
expire_at = time.time() + REFRESH_TOKEN_EXPIRATION
else:
expire_at = None
if user.system_generated != (token_type == models.TOKEN_TYPE_SYSTEM): if user.system_generated != (token_type == models.TOKEN_TYPE_SYSTEM):
raise ValueError( raise ValueError(
"System generated users can only have system type refresh tokens" "System generated users can only have system type refresh tokens"
@ -510,88 +454,48 @@ class AuthManager:
client_icon, client_icon,
token_type, token_type,
access_token_expiration, access_token_expiration,
expire_at,
credential, credential,
) )
@callback async def async_get_refresh_token(
def async_get_refresh_token(self, token_id: str) -> models.RefreshToken | None: self, token_id: str
) -> models.RefreshToken | None:
"""Get refresh token by id.""" """Get refresh token by id."""
return self._store.async_get_refresh_token(token_id) return await self._store.async_get_refresh_token(token_id)
@callback async def async_get_refresh_token_by_token(
def async_get_refresh_token_by_token(
self, token: str self, token: str
) -> models.RefreshToken | None: ) -> models.RefreshToken | None:
"""Get refresh token by token.""" """Get refresh token by token."""
return self._store.async_get_refresh_token_by_token(token) return await self._store.async_get_refresh_token_by_token(token)
@callback async def async_remove_refresh_token(
def async_remove_refresh_token(self, refresh_token: models.RefreshToken) -> None: self, refresh_token: models.RefreshToken
) -> None:
"""Delete a refresh token.""" """Delete a refresh token."""
self._store.async_remove_refresh_token(refresh_token) await self._store.async_remove_refresh_token(refresh_token)
callbacks = self._revoke_callbacks.pop(refresh_token.id, ()) callbacks = self._revoke_callbacks.pop(refresh_token.id, [])
for revoke_callback in callbacks: for revoke_callback in callbacks:
revoke_callback() revoke_callback()
@callback
def async_set_expiry(
self, refresh_token: models.RefreshToken, *, enable_expiry: bool
) -> None:
"""Enable or disable expiry of a refresh token."""
self._store.async_set_expiry(refresh_token, enable_expiry=enable_expiry)
@callback
def _async_remove_expired_refresh_tokens(self, _: datetime | None = None) -> None:
"""Remove expired refresh tokens."""
now = time.time()
for token in self._store.async_get_refresh_tokens():
if (expire_at := token.expire_at) is not None and expire_at <= now:
self.async_remove_refresh_token(token)
self._async_track_next_refresh_token_expiration()
@callback
def _async_track_next_refresh_token_expiration(self) -> None:
"""Initialise all token expiration scheduled tasks."""
next_expiration = time.time() + REFRESH_TOKEN_EXPIRATION
for token in self._store.async_get_refresh_tokens():
if (
expire_at := token.expire_at
) is not None and expire_at < next_expiration:
next_expiration = expire_at
self._expire_callback = async_track_point_in_utc_time(
self.hass,
self._remove_expired_job,
dt_util.utc_from_timestamp(next_expiration),
)
@callback
def _async_cancel_expiration_schedule(self) -> None:
"""Cancel tracking of expired refresh tokens."""
if self._expire_callback:
self._expire_callback()
self._expire_callback = None
@callback
def _async_unregister(
self, callbacks: set[CALLBACK_TYPE], callback_: CALLBACK_TYPE
) -> None:
"""Unregister a callback."""
callbacks.remove(callback_)
@callback @callback
def async_register_revoke_token_callback( def async_register_revoke_token_callback(
self, refresh_token_id: str, revoke_callback: CALLBACK_TYPE self, refresh_token_id: str, revoke_callback: CALLBACK_TYPE
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Register a callback to be called when the refresh token id is revoked.""" """Register a callback to be called when the refresh token id is revoked."""
if refresh_token_id not in self._revoke_callbacks: if refresh_token_id not in self._revoke_callbacks:
self._revoke_callbacks[refresh_token_id] = set() self._revoke_callbacks[refresh_token_id] = []
callbacks = self._revoke_callbacks[refresh_token_id] callbacks = self._revoke_callbacks[refresh_token_id]
callbacks.add(revoke_callback) callbacks.append(revoke_callback)
return partial(self._async_unregister, callbacks, revoke_callback)
@callback
def unregister() -> None:
if revoke_callback in callbacks:
callbacks.remove(revoke_callback)
return unregister
@callback @callback
def async_create_access_token( def async_create_access_token(
@ -648,15 +552,16 @@ class AuthManager:
if provider := self._async_resolve_provider(refresh_token): if provider := self._async_resolve_provider(refresh_token):
provider.async_validate_refresh_token(refresh_token, remote_ip) provider.async_validate_refresh_token(refresh_token, remote_ip)
@callback async def async_validate_access_token(
def async_validate_access_token(self, token: str) -> models.RefreshToken | None: self, token: str
) -> models.RefreshToken | None:
"""Return refresh token if an access token is valid.""" """Return refresh token if an access token is valid."""
try: try:
unverif_claims = jwt_wrapper.unverified_hs256_token_decode(token) unverif_claims = jwt_wrapper.unverified_hs256_token_decode(token)
except jwt.InvalidTokenError: except jwt.InvalidTokenError:
return None return None
refresh_token = self.async_get_refresh_token( refresh_token = await self.async_get_refresh_token(
cast(str, unverif_claims.get("iss")) cast(str, unverif_claims.get("iss"))
) )

View file

@ -1,10 +1,10 @@
"""Storage for auth models.""" """Storage for auth models."""
from __future__ import annotations from __future__ import annotations
import asyncio
from collections import OrderedDict
from datetime import timedelta from datetime import timedelta
import hmac import hmac
import itertools
from logging import getLogger from logging import getLogger
from typing import Any from typing import Any
@ -19,7 +19,6 @@ from .const import (
GROUP_ID_ADMIN, GROUP_ID_ADMIN,
GROUP_ID_READ_ONLY, GROUP_ID_READ_ONLY,
GROUP_ID_USER, GROUP_ID_USER,
REFRESH_TOKEN_EXPIRATION,
) )
from .permissions import system_policies from .permissions import system_policies
from .permissions.models import PermissionLookup from .permissions.models import PermissionLookup
@ -31,17 +30,6 @@ GROUP_NAME_ADMIN = "Administrators"
GROUP_NAME_USER = "Users" GROUP_NAME_USER = "Users"
GROUP_NAME_READ_ONLY = "Read Only" GROUP_NAME_READ_ONLY = "Read Only"
# We always save the auth store after we load it since
# we may migrate data and do not want to have to do it again
# but we don't want to do it during startup so we schedule
# the first save 5 minutes out knowing something else may
# want to save the auth store before then, and since Storage
# will honor the lower of the two delays, it will save it
# faster if something else saves it.
INITIAL_LOAD_SAVE_DELAY = 300
DEFAULT_SAVE_DELAY = 1
class AuthStore: class AuthStore:
"""Stores authentication info. """Stores authentication info.
@ -55,29 +43,44 @@ class AuthStore:
def __init__(self, hass: HomeAssistant) -> None: def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the auth store.""" """Initialize the auth store."""
self.hass = hass self.hass = hass
self._loaded = False self._users: dict[str, models.User] | None = None
self._users: dict[str, models.User] = None # type: ignore[assignment] self._groups: dict[str, models.Group] | None = None
self._groups: dict[str, models.Group] = None # type: ignore[assignment] self._perm_lookup: PermissionLookup | None = None
self._perm_lookup: PermissionLookup = None # type: ignore[assignment]
self._store = Store[dict[str, list[dict[str, Any]]]]( self._store = Store[dict[str, list[dict[str, Any]]]](
hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True hass, STORAGE_VERSION, STORAGE_KEY, private=True, atomic_writes=True
) )
self._token_id_to_user_id: dict[str, str] = {} self._lock = asyncio.Lock()
async def async_get_groups(self) -> list[models.Group]: async def async_get_groups(self) -> list[models.Group]:
"""Retrieve all users.""" """Retrieve all users."""
if self._groups is None:
await self._async_load()
assert self._groups is not None
return list(self._groups.values()) return list(self._groups.values())
async def async_get_group(self, group_id: str) -> models.Group | None: async def async_get_group(self, group_id: str) -> models.Group | None:
"""Retrieve all users.""" """Retrieve all users."""
if self._groups is None:
await self._async_load()
assert self._groups is not None
return self._groups.get(group_id) return self._groups.get(group_id)
async def async_get_users(self) -> list[models.User]: async def async_get_users(self) -> list[models.User]:
"""Retrieve all users.""" """Retrieve all users."""
if self._users is None:
await self._async_load()
assert self._users is not None
return list(self._users.values()) return list(self._users.values())
async def async_get_user(self, user_id: str) -> models.User | None: async def async_get_user(self, user_id: str) -> models.User | None:
"""Retrieve a user by id.""" """Retrieve a user by id."""
if self._users is None:
await self._async_load()
assert self._users is not None
return self._users.get(user_id) return self._users.get(user_id)
async def async_create_user( async def async_create_user(
@ -91,6 +94,12 @@ class AuthStore:
local_only: bool | None = None, local_only: bool | None = None,
) -> models.User: ) -> models.User:
"""Create a new user.""" """Create a new user."""
if self._users is None:
await self._async_load()
assert self._users is not None
assert self._groups is not None
groups = [] groups = []
for group_id in group_ids or []: for group_id in group_ids or []:
if (group := self._groups.get(group_id)) is None: if (group := self._groups.get(group_id)) is None:
@ -105,18 +114,14 @@ class AuthStore:
"perm_lookup": self._perm_lookup, "perm_lookup": self._perm_lookup,
} }
kwargs.update( for attr_name, value in (
{ ("is_owner", is_owner),
attr_name: value ("is_active", is_active),
for attr_name, value in ( ("local_only", local_only),
("is_owner", is_owner), ("system_generated", system_generated),
("is_active", is_active), ):
("local_only", local_only), if value is not None:
("system_generated", system_generated), kwargs[attr_name] = value
)
if value is not None
}
)
new_user = models.User(**kwargs) new_user = models.User(**kwargs)
@ -140,10 +145,11 @@ class AuthStore:
async def async_remove_user(self, user: models.User) -> None: async def async_remove_user(self, user: models.User) -> None:
"""Remove a user.""" """Remove a user."""
user = self._users.pop(user.id) if self._users is None:
for refresh_token_id in user.refresh_tokens: await self._async_load()
del self._token_id_to_user_id[refresh_token_id] assert self._users is not None
user.refresh_tokens.clear()
self._users.pop(user.id)
self._async_schedule_save() self._async_schedule_save()
async def async_update_user( async def async_update_user(
@ -155,6 +161,8 @@ class AuthStore:
local_only: bool | None = None, local_only: bool | None = None,
) -> None: ) -> None:
"""Update a user.""" """Update a user."""
assert self._groups is not None
if group_ids is not None: if group_ids is not None:
groups = [] groups = []
for grid in group_ids: for grid in group_ids:
@ -163,6 +171,7 @@ class AuthStore:
groups.append(group) groups.append(group)
user.groups = groups user.groups = groups
user.invalidate_permission_cache()
for attr_name, value in ( for attr_name, value in (
("name", name), ("name", name),
@ -186,6 +195,10 @@ class AuthStore:
async def async_remove_credentials(self, credentials: models.Credentials) -> None: async def async_remove_credentials(self, credentials: models.Credentials) -> None:
"""Remove credentials.""" """Remove credentials."""
if self._users is None:
await self._async_load()
assert self._users is not None
for user in self._users.values(): for user in self._users.values():
found = None found = None
@ -208,7 +221,6 @@ class AuthStore:
client_icon: str | None = None, client_icon: str | None = None,
token_type: str = models.TOKEN_TYPE_NORMAL, token_type: str = models.TOKEN_TYPE_NORMAL,
access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION, access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION,
expire_at: float | None = None,
credential: models.Credentials | None = None, credential: models.Credentials | None = None,
) -> models.RefreshToken: ) -> models.RefreshToken:
"""Create a new token for a user.""" """Create a new token for a user."""
@ -217,7 +229,6 @@ class AuthStore:
"client_id": client_id, "client_id": client_id,
"token_type": token_type, "token_type": token_type,
"access_token_expiration": access_token_expiration, "access_token_expiration": access_token_expiration,
"expire_at": expire_at,
"credential": credential, "credential": credential,
} }
if client_name: if client_name:
@ -226,34 +237,47 @@ class AuthStore:
kwargs["client_icon"] = client_icon kwargs["client_icon"] = client_icon
refresh_token = models.RefreshToken(**kwargs) refresh_token = models.RefreshToken(**kwargs)
token_id = refresh_token.id user.refresh_tokens[refresh_token.id] = refresh_token
user.refresh_tokens[token_id] = refresh_token
self._token_id_to_user_id[token_id] = user.id
self._async_schedule_save() self._async_schedule_save()
return refresh_token return refresh_token
@callback async def async_remove_refresh_token(
def async_remove_refresh_token(self, refresh_token: models.RefreshToken) -> None: self, refresh_token: models.RefreshToken
) -> None:
"""Remove a refresh token.""" """Remove a refresh token."""
refresh_token_id = refresh_token.id if self._users is None:
if user_id := self._token_id_to_user_id.get(refresh_token_id): await self._async_load()
del self._users[user_id].refresh_tokens[refresh_token_id] assert self._users is not None
del self._token_id_to_user_id[refresh_token_id]
self._async_schedule_save()
@callback for user in self._users.values():
def async_get_refresh_token(self, token_id: str) -> models.RefreshToken | None: if user.refresh_tokens.pop(refresh_token.id, None):
self._async_schedule_save()
break
async def async_get_refresh_token(
self, token_id: str
) -> models.RefreshToken | None:
"""Get refresh token by id.""" """Get refresh token by id."""
if user_id := self._token_id_to_user_id.get(token_id): if self._users is None:
return self._users[user_id].refresh_tokens.get(token_id) await self._async_load()
assert self._users is not None
for user in self._users.values():
refresh_token = user.refresh_tokens.get(token_id)
if refresh_token is not None:
return refresh_token
return None return None
@callback async def async_get_refresh_token_by_token(
def async_get_refresh_token_by_token(
self, token: str self, token: str
) -> models.RefreshToken | None: ) -> models.RefreshToken | None:
"""Get refresh token by token.""" """Get refresh token by token."""
if self._users is None:
await self._async_load()
assert self._users is not None
found = None found = None
for user in self._users.values(): for user in self._users.values():
@ -263,15 +287,6 @@ class AuthStore:
return found return found
@callback
def async_get_refresh_tokens(self) -> list[models.RefreshToken]:
"""Get all refresh tokens."""
return list(
itertools.chain.from_iterable(
user.refresh_tokens.values() for user in self._users.values()
)
)
@callback @callback
def async_log_refresh_token_usage( def async_log_refresh_token_usage(
self, refresh_token: models.RefreshToken, remote_ip: str | None = None self, refresh_token: models.RefreshToken, remote_ip: str | None = None
@ -279,55 +294,35 @@ class AuthStore:
"""Update refresh token last used information.""" """Update refresh token last used information."""
refresh_token.last_used_at = dt_util.utcnow() refresh_token.last_used_at = dt_util.utcnow()
refresh_token.last_used_ip = remote_ip refresh_token.last_used_ip = remote_ip
if refresh_token.expire_at:
refresh_token.expire_at = (
refresh_token.last_used_at.timestamp() + REFRESH_TOKEN_EXPIRATION
)
self._async_schedule_save() self._async_schedule_save()
@callback async def _async_load(self) -> None:
def async_set_expiry(
self, refresh_token: models.RefreshToken, *, enable_expiry: bool
) -> None:
"""Enable or disable expiry of a refresh token."""
if enable_expiry:
if refresh_token.expire_at is None:
refresh_token.expire_at = (
refresh_token.last_used_at or dt_util.utcnow()
).timestamp() + REFRESH_TOKEN_EXPIRATION
self._async_schedule_save()
else:
refresh_token.expire_at = None
self._async_schedule_save()
@callback
def async_update_user_credentials_data(
self, credentials: models.Credentials, data: dict[str, Any]
) -> None:
"""Update credentials data."""
credentials.data = data
self._async_schedule_save()
async def async_load(self) -> None: # noqa: C901
"""Load the users.""" """Load the users."""
if self._loaded: async with self._lock:
raise RuntimeError("Auth storage is already loaded") if self._users is not None:
self._loaded = True return
await self._async_load_task()
async def _async_load_task(self) -> None:
"""Load the users."""
dev_reg = dr.async_get(self.hass) dev_reg = dr.async_get(self.hass)
ent_reg = er.async_get(self.hass) ent_reg = er.async_get(self.hass)
data = await self._store.async_load() data = await self._store.async_load()
perm_lookup = PermissionLookup(ent_reg, dev_reg) # Make sure that we're not overriding data if 2 loads happened at the
self._perm_lookup = perm_lookup # same time
if self._users is not None:
return
self._perm_lookup = perm_lookup = PermissionLookup(ent_reg, dev_reg)
if data is None or not isinstance(data, dict): if data is None or not isinstance(data, dict):
self._set_defaults() self._set_defaults()
return return
users: dict[str, models.User] = {} users: dict[str, models.User] = OrderedDict()
groups: dict[str, models.Group] = {} groups: dict[str, models.Group] = OrderedDict()
credentials: dict[str, models.Credentials] = {} credentials: dict[str, models.Credentials] = OrderedDict()
# Soft-migrating data as we load. We are going to make sure we have a # Soft-migrating data as we load. We are going to make sure we have a
# read only group and an admin group. There are two states that we can # read only group and an admin group. There are two states that we can
@ -490,7 +485,6 @@ class AuthStore:
jwt_key=rt_dict["jwt_key"], jwt_key=rt_dict["jwt_key"],
last_used_at=last_used_at, last_used_at=last_used_at,
last_used_ip=rt_dict.get("last_used_ip"), last_used_ip=rt_dict.get("last_used_ip"),
expire_at=rt_dict.get("expire_at"),
version=rt_dict.get("version"), version=rt_dict.get("version"),
) )
if "credential_id" in rt_dict: if "credential_id" in rt_dict:
@ -499,26 +493,21 @@ class AuthStore:
self._groups = groups self._groups = groups
self._users = users self._users = users
self._build_token_id_to_user_id()
self._async_schedule_save(INITIAL_LOAD_SAVE_DELAY)
@callback @callback
def _build_token_id_to_user_id(self) -> None: def _async_schedule_save(self) -> None:
"""Build a map of token id to user id."""
self._token_id_to_user_id = {
token_id: user_id
for user_id, user in self._users.items()
for token_id in user.refresh_tokens
}
@callback
def _async_schedule_save(self, delay: float = DEFAULT_SAVE_DELAY) -> None:
"""Save users.""" """Save users."""
self._store.async_delay_save(self._data_to_save, delay) if self._users is None:
return
self._store.async_delay_save(self._data_to_save, 1)
@callback @callback
def _data_to_save(self) -> dict[str, list[dict[str, Any]]]: def _data_to_save(self) -> dict[str, list[dict[str, Any]]]:
"""Return the data to store.""" """Return the data to store."""
assert self._users is not None
assert self._groups is not None
users = [ users = [
{ {
"id": user.id, "id": user.id,
@ -575,7 +564,6 @@ class AuthStore:
if refresh_token.last_used_at if refresh_token.last_used_at
else None, else None,
"last_used_ip": refresh_token.last_used_ip, "last_used_ip": refresh_token.last_used_ip,
"expire_at": refresh_token.expire_at,
"credential_id": refresh_token.credential.id "credential_id": refresh_token.credential.id
if refresh_token.credential if refresh_token.credential
else None, else None,
@ -594,9 +582,9 @@ class AuthStore:
def _set_defaults(self) -> None: def _set_defaults(self) -> None:
"""Set default values for auth store.""" """Set default values for auth store."""
self._users = {} self._users = OrderedDict()
groups: dict[str, models.Group] = {} groups: dict[str, models.Group] = OrderedDict()
admin_group = _system_admin_group() admin_group = _system_admin_group()
groups[admin_group.id] = admin_group groups[admin_group.id] = admin_group
user_group = _system_user_group() user_group = _system_user_group()
@ -604,7 +592,6 @@ class AuthStore:
read_only_group = _system_read_only_group() read_only_group = _system_read_only_group()
groups[read_only_group.id] = read_only_group groups[read_only_group.id] = read_only_group
self._groups = groups self._groups = groups
self._build_token_id_to_user_id()
def _system_admin_group() -> models.Group: def _system_admin_group() -> models.Group:

View file

@ -1,10 +1,8 @@
"""Constants for the auth module.""" """Constants for the auth module."""
from datetime import timedelta from datetime import timedelta
ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30) ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30)
MFA_SESSION_EXPIRATION = timedelta(minutes=5) MFA_SESSION_EXPIRATION = timedelta(minutes=5)
REFRESH_TOKEN_EXPIRATION = timedelta(days=90).total_seconds()
GROUP_ID_ADMIN = "system-admin" GROUP_ID_ADMIN = "system-admin"
GROUP_ID_USER = "system-users" GROUP_ID_USER = "system-users"

View file

@ -4,7 +4,6 @@ Since we decode the same tokens over and over again
we can cache the result of the decode of valid tokens we can cache the result of the decode of valid tokens
to speed up the process. to speed up the process.
""" """
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import timedelta
@ -78,7 +77,7 @@ class _PyJWTWithVerify(PyJWT):
key: str, key: str,
algorithms: list[str], algorithms: list[str],
issuer: str | None = None, issuer: str | None = None,
leeway: float | timedelta = 0, leeway: int | float | timedelta = 0,
options: dict[str, Any] | None = None, options: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Verify a JWT's signature and claims.""" """Verify a JWT's signature and claims."""

View file

@ -1,7 +1,7 @@
"""Pluggable auth modules for Home Assistant.""" """Pluggable auth modules for Home Assistant."""
from __future__ import annotations from __future__ import annotations
import importlib
import logging import logging
import types import types
from typing import Any from typing import Any
@ -14,9 +14,7 @@ from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.importlib import async_import_module
from homeassistant.util.decorator import Registry from homeassistant.util.decorator import Registry
from homeassistant.util.hass_dict import HassKey
MULTI_FACTOR_AUTH_MODULES: Registry[str, type[MultiFactorAuthModule]] = Registry() MULTI_FACTOR_AUTH_MODULES: Registry[str, type[MultiFactorAuthModule]] = Registry()
@ -30,7 +28,7 @@ MULTI_FACTOR_AUTH_MODULE_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
DATA_REQS: HassKey[set[str]] = HassKey("mfa_auth_module_reqs_processed") DATA_REQS = "mfa_auth_module_reqs_processed"
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -150,7 +148,7 @@ async def _load_mfa_module(hass: HomeAssistant, module_name: str) -> types.Modul
module_path = f"homeassistant.auth.mfa_modules.{module_name}" module_path = f"homeassistant.auth.mfa_modules.{module_name}"
try: try:
module = await async_import_module(hass, module_path) module = importlib.import_module(module_path)
except ImportError as err: except ImportError as err:
_LOGGER.error("Unable to load mfa module %s: %s", module_name, err) _LOGGER.error("Unable to load mfa module %s: %s", module_name, err)
raise HomeAssistantError( raise HomeAssistantError(

View file

@ -1,5 +1,4 @@
"""Example auth module.""" """Example auth module."""
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any

View file

@ -2,7 +2,6 @@
Sending HOTP through notify service Sending HOTP through notify service
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@ -88,7 +87,7 @@ class NotifySetting:
target: str | None = attr.ib(default=None) target: str | None = attr.ib(default=None)
type _UsersDict = dict[str, NotifySetting] _UsersDict = dict[str, NotifySetting]
@MULTI_FACTOR_AUTH_MODULES.register("notify") @MULTI_FACTOR_AUTH_MODULES.register("notify")
@ -153,7 +152,7 @@ class NotifyAuthModule(MultiFactorAuthModule):
"""Return list of notify services.""" """Return list of notify services."""
unordered_services = set() unordered_services = set()
for service in self.hass.services.async_services_for_domain("notify"): for service in self.hass.services.async_services().get("notify", {}):
if service not in self._exclude: if service not in self._exclude:
unordered_services.add(service) unordered_services.add(service)

View file

@ -1,5 +1,4 @@
"""Time-based One Time Password auth module.""" """Time-based One Time Password auth module."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio

View file

@ -1,20 +1,14 @@
"""Auth models.""" """Auth models."""
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
from ipaddress import IPv4Address, IPv6Address
import secrets import secrets
from typing import Any, NamedTuple from typing import NamedTuple
import uuid import uuid
import attr import attr
from attr import Attribute
from attr.setters import validate
from propcache import cached_property
from homeassistant.const import __version__ from homeassistant.const import __version__
from homeassistant.data_entry_flow import FlowContext, FlowResult
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from . import permissions as perm_mdl from . import permissions as perm_mdl
@ -25,17 +19,6 @@ TOKEN_TYPE_SYSTEM = "system"
TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = "long_lived_access_token" TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = "long_lived_access_token"
class AuthFlowContext(FlowContext, total=False):
"""Typed context dict for auth flow."""
credential_only: bool
ip_address: IPv4Address | IPv6Address
redirect_uri: str
AuthFlowResult = FlowResult[AuthFlowContext, tuple[str, str]]
@attr.s(slots=True) @attr.s(slots=True)
class Group: class Group:
"""A group.""" """A group."""
@ -46,27 +29,19 @@ class Group:
system_generated: bool = attr.ib(default=False) system_generated: bool = attr.ib(default=False)
def _handle_permissions_change(self: User, user_attr: Attribute, new: Any) -> Any: @attr.s(slots=True)
"""Handle a change to a permissions."""
self.invalidate_cache()
return validate(self, user_attr, new)
@attr.s(slots=False)
class User: class User:
"""A user.""" """A user."""
name: str | None = attr.ib() name: str | None = attr.ib()
perm_lookup: perm_mdl.PermissionLookup = attr.ib(eq=False, order=False) perm_lookup: perm_mdl.PermissionLookup = attr.ib(eq=False, order=False)
id: str = attr.ib(factory=lambda: uuid.uuid4().hex) id: str = attr.ib(factory=lambda: uuid.uuid4().hex)
is_owner: bool = attr.ib(default=False, on_setattr=_handle_permissions_change) is_owner: bool = attr.ib(default=False)
is_active: bool = attr.ib(default=False, on_setattr=_handle_permissions_change) is_active: bool = attr.ib(default=False)
system_generated: bool = attr.ib(default=False) system_generated: bool = attr.ib(default=False)
local_only: bool = attr.ib(default=False) local_only: bool = attr.ib(default=False)
groups: list[Group] = attr.ib( groups: list[Group] = attr.ib(factory=list, eq=False, order=False)
factory=list, eq=False, order=False, on_setattr=_handle_permissions_change
)
# List of credentials of a user. # List of credentials of a user.
credentials: list[Credentials] = attr.ib(factory=list, eq=False, order=False) credentials: list[Credentials] = attr.ib(factory=list, eq=False, order=False)
@ -76,27 +51,40 @@ class User:
factory=dict, eq=False, order=False factory=dict, eq=False, order=False
) )
@cached_property _permissions: perm_mdl.PolicyPermissions | None = attr.ib(
init=False,
eq=False,
order=False,
default=None,
)
@property
def permissions(self) -> perm_mdl.AbstractPermissions: def permissions(self) -> perm_mdl.AbstractPermissions:
"""Return permissions object for user.""" """Return permissions object for user."""
if self.is_owner: if self.is_owner:
return perm_mdl.OwnerPermissions return perm_mdl.OwnerPermissions
return perm_mdl.PolicyPermissions(
if self._permissions is not None:
return self._permissions
self._permissions = perm_mdl.PolicyPermissions(
perm_mdl.merge_policies([group.policy for group in self.groups]), perm_mdl.merge_policies([group.policy for group in self.groups]),
self.perm_lookup, self.perm_lookup,
) )
@cached_property return self._permissions
@property
def is_admin(self) -> bool: def is_admin(self) -> bool:
"""Return if user is part of the admin group.""" """Return if user is part of the admin group."""
return self.is_owner or ( if self.is_owner:
self.is_active and any(gr.id == GROUP_ID_ADMIN for gr in self.groups) return True
)
def invalidate_cache(self) -> None: return self.is_active and any(gr.id == GROUP_ID_ADMIN for gr in self.groups)
"""Invalidate permission and is_admin cache."""
for attr_to_invalidate in ("permissions", "is_admin"): def invalidate_permission_cache(self) -> None:
self.__dict__.pop(attr_to_invalidate, None) """Invalidate permission cache."""
self._permissions = None
@attr.s(slots=True) @attr.s(slots=True)
@ -122,8 +110,6 @@ class RefreshToken:
last_used_at: datetime | None = attr.ib(default=None) last_used_at: datetime | None = attr.ib(default=None)
last_used_ip: str | None = attr.ib(default=None) last_used_ip: str | None = attr.ib(default=None)
expire_at: float | None = attr.ib(default=None)
credential: Credentials | None = attr.ib(default=None) credential: Credentials | None = attr.ib(default=None)
version: str | None = attr.ib(default=__version__) version: str | None = attr.ib(default=__version__)

View file

@ -1,8 +1,8 @@
"""Permissions for Home Assistant.""" """Permissions for Home Assistant."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from typing import Any
import voluptuous as vol import voluptuous as vol
@ -63,7 +63,7 @@ class PolicyPermissions(AbstractPermissions):
"""Return a function that can test entity access.""" """Return a function that can test entity access."""
return compile_entities(self._policy.get(CAT_ENTITIES), self._perm_lookup) return compile_entities(self._policy.get(CAT_ENTITIES), self._perm_lookup)
def __eq__(self, other: object) -> bool: def __eq__(self, other: Any) -> bool:
"""Equals check.""" """Equals check."""
return isinstance(other, PolicyPermissions) and other._policy == self._policy return isinstance(other, PolicyPermissions) and other._policy == self._policy

View file

@ -1,5 +1,4 @@
"""Permission constants.""" """Permission constants."""
CAT_ENTITIES = "entities" CAT_ENTITIES = "entities"
CAT_CONFIG_ENTRIES = "config_entries" CAT_CONFIG_ENTRIES = "config_entries"
SUBCAT_ALL = "all" SUBCAT_ALL = "all"

View file

@ -1,5 +1,4 @@
"""Entity permissions.""" """Entity permissions."""
from __future__ import annotations from __future__ import annotations
from collections import OrderedDict from collections import OrderedDict

View file

@ -1,8 +1,7 @@
"""Permission for events.""" """Permission for events."""
from __future__ import annotations from __future__ import annotations
from typing import Any, Final from typing import Final
from homeassistant.const import ( from homeassistant.const import (
EVENT_COMPONENT_LOADED, EVENT_COMPONENT_LOADED,
@ -18,17 +17,13 @@ from homeassistant.const import (
EVENT_THEMES_UPDATED, EVENT_THEMES_UPDATED,
) )
from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED
from homeassistant.helpers.category_registry import EVENT_CATEGORY_REGISTRY_UPDATED
from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
from homeassistant.helpers.floor_registry import EVENT_FLOOR_REGISTRY_UPDATED
from homeassistant.helpers.issue_registry import EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED from homeassistant.helpers.issue_registry import EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED
from homeassistant.helpers.label_registry import EVENT_LABEL_REGISTRY_UPDATED
from homeassistant.util.event_type import EventType
# These are events that do not contain any sensitive data # These are events that do not contain any sensitive data
# Except for state_changed, which is handled accordingly. # Except for state_changed, which is handled accordingly.
SUBSCRIBE_ALLOWLIST: Final[set[EventType[Any] | str]] = { SUBSCRIBE_ALLOWLIST: Final[set[str]] = {
EVENT_AREA_REGISTRY_UPDATED, EVENT_AREA_REGISTRY_UPDATED,
EVENT_COMPONENT_LOADED, EVENT_COMPONENT_LOADED,
EVENT_CORE_CONFIG_UPDATE, EVENT_CORE_CONFIG_UPDATE,
@ -44,7 +39,4 @@ SUBSCRIBE_ALLOWLIST: Final[set[EventType[Any] | str]] = {
EVENT_SHOPPING_LIST_UPDATED, EVENT_SHOPPING_LIST_UPDATED,
EVENT_STATE_CHANGED, EVENT_STATE_CHANGED,
EVENT_THEMES_UPDATED, EVENT_THEMES_UPDATED,
EVENT_LABEL_REGISTRY_UPDATED,
EVENT_CATEGORY_REGISTRY_UPDATED,
EVENT_FLOOR_REGISTRY_UPDATED,
} }

View file

@ -1,5 +1,4 @@
"""Merging of policies.""" """Merging of policies."""
from __future__ import annotations from __future__ import annotations
from typing import cast from typing import cast
@ -58,7 +57,10 @@ def _merge_policies(sources: list[CategoryType]) -> CategoryType:
continue continue
seen.add(key) seen.add(key)
key_sources = [src.get(key) for src in sources if isinstance(src, dict)] key_sources = []
for src in sources:
if isinstance(src, dict):
key_sources.append(src.get(key))
policy[key] = _merge_policies(key_sources) policy[key] = _merge_policies(key_sources)

View file

@ -1,5 +1,4 @@
"""Models for permissions.""" """Models for permissions."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING

View file

@ -1,5 +1,4 @@
"""System policies.""" """System policies."""
from .const import CAT_ENTITIES, POLICY_READ, SUBCAT_ALL from .const import CAT_ENTITIES, POLICY_READ, SUBCAT_ALL
ADMIN_POLICY = {CAT_ENTITIES: True} ADMIN_POLICY = {CAT_ENTITIES: True}

View file

@ -1,20 +1,19 @@
"""Common code for permissions.""" """Common code for permissions."""
from collections.abc import Mapping from collections.abc import Mapping
# MyPy doesn't support recursion yet. So writing it out as far as we need. # MyPy doesn't support recursion yet. So writing it out as far as we need.
type ValueType = ( ValueType = (
# Example: entities.all = { read: true, control: true } # Example: entities.all = { read: true, control: true }
Mapping[str, bool] | bool | None Mapping[str, bool] | bool | None
) )
# Example: entities.domains = { light: … } # Example: entities.domains = { light: … }
type SubCategoryDict = Mapping[str, ValueType] SubCategoryDict = Mapping[str, ValueType]
type SubCategoryType = SubCategoryDict | bool | None SubCategoryType = SubCategoryDict | bool | None
type CategoryType = ( CategoryType = (
# Example: entities.domains # Example: entities.domains
Mapping[str, SubCategoryType] Mapping[str, SubCategoryType]
# Example: entities.all # Example: entities.all
@ -24,4 +23,4 @@ type CategoryType = (
) )
# Example: { entities: … } # Example: { entities: … }
type PolicyType = Mapping[str, CategoryType] PolicyType = Mapping[str, CategoryType]

View file

@ -1,5 +1,4 @@
"""Helpers to deal with permissions.""" """Helpers to deal with permissions."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
@ -10,8 +9,8 @@ from .const import SUBCAT_ALL
from .models import PermissionLookup from .models import PermissionLookup
from .types import CategoryType, SubCategoryDict, ValueType from .types import CategoryType, SubCategoryDict, ValueType
type LookupFunc = Callable[[PermissionLookup, SubCategoryDict, str], ValueType | None] LookupFunc = Callable[[PermissionLookup, SubCategoryDict, str], ValueType | None]
type SubCatLookupType = dict[str, LookupFunc] SubCatLookupType = dict[str, LookupFunc]
def lookup_all( def lookup_all(

View file

@ -1,8 +1,8 @@
"""Auth providers for Home Assistant.""" """Auth providers for Home Assistant."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
import importlib
import logging import logging
import types import types
from typing import Any from typing import Any
@ -10,29 +10,20 @@ from typing import Any
import voluptuous as vol import voluptuous as vol
from voluptuous.humanize import humanize_error from voluptuous.humanize import humanize_error
from homeassistant import requirements from homeassistant import data_entry_flow, requirements
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowHandler from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.importlib import async_import_module
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.util.decorator import Registry from homeassistant.util.decorator import Registry
from homeassistant.util.hass_dict import HassKey
from ..auth_store import AuthStore from ..auth_store import AuthStore
from ..const import MFA_SESSION_EXPIRATION from ..const import MFA_SESSION_EXPIRATION
from ..models import ( from ..models import Credentials, RefreshToken, User, UserMeta
AuthFlowContext,
AuthFlowResult,
Credentials,
RefreshToken,
User,
UserMeta,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DATA_REQS: HassKey[set[str]] = HassKey("auth_prov_reqs_processed") DATA_REQS = "auth_prov_reqs_processed"
AUTH_PROVIDERS: Registry[str, type[AuthProvider]] = Registry() AUTH_PROVIDERS: Registry[str, type[AuthProvider]] = Registry()
@ -105,7 +96,7 @@ class AuthProvider:
# Implement by extending class # Implement by extending class
async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow: async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow:
"""Return the data flow for logging in with auth provider. """Return the data flow for logging in with auth provider.
Auth provider should extend LoginFlow and return an instance. Auth provider should extend LoginFlow and return an instance.
@ -166,9 +157,7 @@ async def load_auth_provider_module(
) -> types.ModuleType: ) -> types.ModuleType:
"""Load an auth provider.""" """Load an auth provider."""
try: try:
module = await async_import_module( module = importlib.import_module(f"homeassistant.auth.providers.{provider}")
hass, f"homeassistant.auth.providers.{provider}"
)
except ImportError as err: except ImportError as err:
_LOGGER.error("Unable to load auth provider %s: %s", provider, err) _LOGGER.error("Unable to load auth provider %s: %s", provider, err)
raise HomeAssistantError( raise HomeAssistantError(
@ -192,11 +181,9 @@ async def load_auth_provider_module(
return module return module
class LoginFlow(FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]]): class LoginFlow(data_entry_flow.FlowHandler):
"""Handler for the login flow.""" """Handler for the login flow."""
_flow_result = AuthFlowResult
def __init__(self, auth_provider: AuthProvider) -> None: def __init__(self, auth_provider: AuthProvider) -> None:
"""Initialize the login flow.""" """Initialize the login flow."""
self._auth_provider = auth_provider self._auth_provider = auth_provider
@ -210,7 +197,7 @@ class LoginFlow(FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]]):
async def async_step_init( async def async_step_init(
self, user_input: dict[str, str] | None = None self, user_input: dict[str, str] | None = None
) -> AuthFlowResult: ) -> FlowResult:
"""Handle the first step of login flow. """Handle the first step of login flow.
Return self.async_show_form(step_id='init') if user_input is None. Return self.async_show_form(step_id='init') if user_input is None.
@ -220,7 +207,7 @@ class LoginFlow(FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]]):
async def async_step_select_mfa_module( async def async_step_select_mfa_module(
self, user_input: dict[str, str] | None = None self, user_input: dict[str, str] | None = None
) -> AuthFlowResult: ) -> FlowResult:
"""Handle the step of select mfa module.""" """Handle the step of select mfa module."""
errors = {} errors = {}
@ -245,7 +232,7 @@ class LoginFlow(FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]]):
async def async_step_mfa( async def async_step_mfa(
self, user_input: dict[str, str] | None = None self, user_input: dict[str, str] | None = None
) -> AuthFlowResult: ) -> FlowResult:
"""Handle the step of mfa validation.""" """Handle the step of mfa validation."""
assert self.credential assert self.credential
assert self.user assert self.user
@ -295,6 +282,6 @@ class LoginFlow(FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]]):
errors=errors, errors=errors,
) )
async def async_finish(self, flow_result: Any) -> AuthFlowResult: async def async_finish(self, flow_result: Any) -> FlowResult:
"""Handle the pass of login flow.""" """Handle the pass of login flow."""
return self.async_create_entry(data=flow_result) return self.async_create_entry(data=flow_result)

View file

@ -1,5 +1,4 @@
"""Auth provider that validates credentials via an external command.""" """Auth provider that validates credentials via an external command."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@ -11,9 +10,10 @@ from typing import Any, cast
import voluptuous as vol import voluptuous as vol
from homeassistant.const import CONF_COMMAND from homeassistant.const import CONF_COMMAND
from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from ..models import AuthFlowContext, AuthFlowResult, Credentials, UserMeta from ..models import Credentials, UserMeta
from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow
CONF_ARGS = "args" CONF_ARGS = "args"
@ -59,7 +59,7 @@ class CommandLineAuthProvider(AuthProvider):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._user_meta: dict[str, dict[str, Any]] = {} self._user_meta: dict[str, dict[str, Any]] = {}
async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow: async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow:
"""Return a flow to login.""" """Return a flow to login."""
return CommandLineLoginFlow(self) return CommandLineLoginFlow(self)
@ -138,7 +138,7 @@ class CommandLineLoginFlow(LoginFlow):
async def async_step_init( async def async_step_init(
self, user_input: dict[str, str] | None = None self, user_input: dict[str, str] | None = None
) -> AuthFlowResult: ) -> FlowResult:
"""Handle the step of the form.""" """Handle the step of the form."""
errors = {} errors = {}

View file

@ -1,5 +1,4 @@
"""Home Assistant auth provider.""" """Home Assistant auth provider."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@ -13,11 +12,11 @@ import voluptuous as vol
from homeassistant.const import CONF_ID from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from ..models import AuthFlowContext, AuthFlowResult, Credentials, UserMeta from ..models import Credentials, UserMeta
from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow
STORAGE_VERSION = 1 STORAGE_VERSION = 1
@ -55,27 +54,6 @@ class InvalidUser(HomeAssistantError):
Will not be raised when validating authentication. Will not be raised when validating authentication.
""" """
def __init__(
self,
*args: object,
translation_key: str | None = None,
translation_placeholders: dict[str, str] | None = None,
) -> None:
"""Initialize exception."""
super().__init__(
*args,
translation_domain="auth",
translation_key=translation_key,
translation_placeholders=translation_placeholders,
)
class InvalidUsername(InvalidUser):
"""Raised when invalid username is specified.
Will not be raised when validating authentication.
"""
class Data: class Data:
"""Hold the user data.""" """Hold the user data."""
@ -89,15 +67,13 @@ class Data:
self._data: dict[str, list[dict[str, str]]] | None = None self._data: dict[str, list[dict[str, str]]] | None = None
# Legacy mode will allow usernames to start/end with whitespace # Legacy mode will allow usernames to start/end with whitespace
# and will compare usernames case-insensitive. # and will compare usernames case-insensitive.
# Deprecated in June 2019 and will be removed in 2026.7 # Remove in 2020 or when we launch 1.0.
self.is_legacy = False self.is_legacy = False
@callback @callback
def normalize_username( def normalize_username(self, username: str) -> str:
self, username: str, *, force_normalize: bool = False
) -> str:
"""Normalize a username based on the mode.""" """Normalize a username based on the mode."""
if self.is_legacy and not force_normalize: if self.is_legacy:
return username return username
return username.strip().casefold() return username.strip().casefold()
@ -107,49 +83,44 @@ class Data:
if (data := await self._store.async_load()) is None: if (data := await self._store.async_load()) is None:
data = cast(dict[str, list[dict[str, str]]], {"users": []}) data = cast(dict[str, list[dict[str, str]]], {"users": []})
self._async_check_for_not_normalized_usernames(data) seen: set[str] = set()
self._data = data
@callback
def _async_check_for_not_normalized_usernames(
self, data: dict[str, list[dict[str, str]]]
) -> None:
not_normalized_usernames: set[str] = set()
for user in data["users"]: for user in data["users"]:
username = user["username"] username = user["username"]
if self.normalize_username(username, force_normalize=True) != username: # check if we have duplicates
if (folded := username.casefold()) in seen:
self.is_legacy = True
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
( (
"Home Assistant auth provider is running in legacy mode " "Home Assistant auth provider is running in legacy mode "
"because we detected usernames that are normalized (lowercase and without spaces)." "because we detected usernames that are case-insensitive"
" Please change the username: '%s'." "equivalent. Please change the username: '%s'."
), ),
username, username,
) )
not_normalized_usernames.add(username)
if not_normalized_usernames: break
self.is_legacy = True
ir.async_create_issue( seen.add(folded)
self.hass,
"auth", # check if we have unstripped usernames
"homeassistant_provider_not_normalized_usernames", if username != username.strip():
breaks_in_ha_version="2026.7.0", self.is_legacy = True
is_fixable=False,
severity=ir.IssueSeverity.WARNING, logging.getLogger(__name__).warning(
translation_key="homeassistant_provider_not_normalized_usernames", (
translation_placeholders={ "Home Assistant auth provider is running in legacy mode "
"usernames": f'- "{'"\n- "'.join(sorted(not_normalized_usernames))}"' "because we detected usernames that start or end in a "
}, "space. Please change the username: '%s'."
learn_more_url="homeassistant://config/users", ),
) username,
else: )
self.is_legacy = False
ir.async_delete_issue( break
self.hass, "auth", "homeassistant_provider_not_normalized_usernames"
) self._data = data
@property @property
def users(self) -> list[dict[str, str]]: def users(self) -> list[dict[str, str]]:
@ -191,11 +162,13 @@ class Data:
return hashed return hashed
def add_auth(self, username: str, password: str) -> None: def add_auth(self, username: str, password: str) -> None:
"""Add a new authenticated user/pass. """Add a new authenticated user/pass."""
username = self.normalize_username(username)
Raises InvalidUsername if the new username is invalid. if any(
""" self.normalize_username(user["username"]) == username for user in self.users
self._validate_new_username(username) ):
raise InvalidUser
self.users.append( self.users.append(
{ {
@ -216,7 +189,7 @@ class Data:
break break
if index is None: if index is None:
raise InvalidUser(translation_key="user_not_found") raise InvalidUser
self.users.pop(index) self.users.pop(index)
@ -232,50 +205,7 @@ class Data:
user["password"] = self.hash_password(new_password, True).decode() user["password"] = self.hash_password(new_password, True).decode()
break break
else: else:
raise InvalidUser(translation_key="user_not_found") raise InvalidUser
@callback
def _validate_new_username(self, new_username: str) -> None:
"""Validate that username is normalized and unique.
Raises InvalidUsername if the new username is invalid.
"""
normalized_username = self.normalize_username(
new_username, force_normalize=True
)
if normalized_username != new_username:
raise InvalidUsername(
translation_key="username_not_normalized",
translation_placeholders={"new_username": new_username},
)
if any(
self.normalize_username(user["username"]) == normalized_username
for user in self.users
):
raise InvalidUsername(
translation_key="username_already_exists",
translation_placeholders={"username": new_username},
)
@callback
def change_username(self, username: str, new_username: str) -> None:
"""Update the username.
Raises InvalidUser if user cannot be found.
Raises InvalidUsername if the new username is invalid.
"""
username = self.normalize_username(username)
self._validate_new_username(new_username)
for user in self.users:
if self.normalize_username(user["username"]) == username:
user["username"] = new_username
assert self._data is not None
self._async_check_for_not_normalized_usernames(self._data)
break
else:
raise InvalidUser(translation_key="user_not_found")
async def async_save(self) -> None: async def async_save(self) -> None:
"""Save data.""" """Save data."""
@ -305,7 +235,7 @@ class HassAuthProvider(AuthProvider):
await data.async_load() await data.async_load()
self.data = data self.data = data
async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow: async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow:
"""Return a flow to login.""" """Return a flow to login."""
return HassLoginFlow(self) return HassLoginFlow(self)
@ -348,20 +278,6 @@ class HassAuthProvider(AuthProvider):
) )
await self.data.async_save() await self.data.async_save()
async def async_change_username(
self, credential: Credentials, new_username: str
) -> None:
"""Validate new username and change it including updating credentials object."""
if self.data is None:
await self.async_initialize()
assert self.data is not None
self.data.change_username(credential.data["username"], new_username)
self.hass.auth.async_update_user_credentials_data(
credential, {**credential.data, "username": new_username}
)
await self.data.async_save()
async def async_get_or_create_credentials( async def async_get_or_create_credentials(
self, flow_result: Mapping[str, str] self, flow_result: Mapping[str, str]
) -> Credentials: ) -> Credentials:
@ -405,7 +321,7 @@ class HassLoginFlow(LoginFlow):
async def async_step_init( async def async_step_init(
self, user_input: dict[str, str] | None = None self, user_input: dict[str, str] | None = None
) -> AuthFlowResult: ) -> FlowResult:
"""Handle the step of the form.""" """Handle the step of the form."""
errors = {} errors = {}

View file

@ -1,17 +1,17 @@
"""Example auth provider.""" """Example auth provider."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
import hmac import hmac
from typing import cast from typing import Any, cast
import voluptuous as vol import voluptuous as vol
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from ..models import AuthFlowContext, AuthFlowResult, Credentials, UserMeta from ..models import Credentials, UserMeta
from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow
USER_SCHEMA = vol.Schema( USER_SCHEMA = vol.Schema(
@ -36,7 +36,7 @@ class InvalidAuthError(HomeAssistantError):
class ExampleAuthProvider(AuthProvider): class ExampleAuthProvider(AuthProvider):
"""Example auth provider based on hardcoded usernames and passwords.""" """Example auth provider based on hardcoded usernames and passwords."""
async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow: async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow:
"""Return a flow to login.""" """Return a flow to login."""
return ExampleLoginFlow(self) return ExampleLoginFlow(self)
@ -98,7 +98,7 @@ class ExampleLoginFlow(LoginFlow):
async def async_step_init( async def async_step_init(
self, user_input: dict[str, str] | None = None self, user_input: dict[str, str] | None = None
) -> AuthFlowResult: ) -> FlowResult:
"""Handle the step of the form.""" """Handle the step of the form."""
errors = None errors = None

View file

@ -0,0 +1,123 @@
"""Support Legacy API password auth provider.
It will be removed when auth system production ready
"""
from __future__ import annotations
from collections.abc import Mapping
import hmac
from typing import Any, cast
import voluptuous as vol
from homeassistant.core import async_get_hass, callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from ..models import Credentials, UserMeta
from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow
AUTH_PROVIDER_TYPE = "legacy_api_password"
CONF_API_PASSWORD = "api_password"
_CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend(
{vol.Required(CONF_API_PASSWORD): cv.string}, extra=vol.PREVENT_EXTRA
)
def _create_repair_and_validate(config: dict[str, Any]) -> dict[str, Any]:
async_create_issue(
async_get_hass(),
"auth",
"deprecated_legacy_api_password",
breaks_in_ha_version="2024.6.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_legacy_api_password",
)
return _CONFIG_SCHEMA(config) # type: ignore[no-any-return]
CONFIG_SCHEMA = _create_repair_and_validate
LEGACY_USER_NAME = "Legacy API password user"
class InvalidAuthError(HomeAssistantError):
"""Raised when submitting invalid authentication."""
@AUTH_PROVIDERS.register(AUTH_PROVIDER_TYPE)
class LegacyApiPasswordAuthProvider(AuthProvider):
"""An auth provider support legacy api_password."""
DEFAULT_TITLE = "Legacy API Password"
@property
def api_password(self) -> str:
"""Return api_password."""
return str(self.config[CONF_API_PASSWORD])
async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow:
"""Return a flow to login."""
return LegacyLoginFlow(self)
@callback
def async_validate_login(self, password: str) -> None:
"""Validate password."""
api_password = str(self.config[CONF_API_PASSWORD])
if not hmac.compare_digest(
api_password.encode("utf-8"), password.encode("utf-8")
):
raise InvalidAuthError
async def async_get_or_create_credentials(
self, flow_result: Mapping[str, str]
) -> Credentials:
"""Return credentials for this login."""
credentials = await self.async_credentials()
if credentials:
return credentials[0]
return self.async_create_credentials({})
async def async_user_meta_for_credentials(
self, credentials: Credentials
) -> UserMeta:
"""Return info for the user.
Will be used to populate info when creating a new user.
"""
return UserMeta(name=LEGACY_USER_NAME, is_active=True)
class LegacyLoginFlow(LoginFlow):
"""Handler for the login flow."""
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> FlowResult:
"""Handle the step of the form."""
errors = {}
if user_input is not None:
try:
cast(
LegacyApiPasswordAuthProvider, self._auth_provider
).async_validate_login(user_input["password"])
except InvalidAuthError:
errors["base"] = "invalid_auth"
if not errors:
return await self.async_finish({})
return self.async_show_form(
step_id="init",
data_schema=vol.Schema({vol.Required("password"): str}),
errors=errors,
)

View file

@ -3,7 +3,6 @@
It shows list of users if access from trusted network. It shows list of users if access from trusted network.
Abort login flow if not access from trusted network. Abort login flow if not access from trusted network.
""" """
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
@ -20,22 +19,17 @@ from typing import Any, cast
import voluptuous as vol import voluptuous as vol
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.network import is_cloud_connection from homeassistant.helpers.network import is_cloud_connection
from .. import InvalidAuthError from .. import InvalidAuthError
from ..models import ( from ..models import Credentials, RefreshToken, UserMeta
AuthFlowContext,
AuthFlowResult,
Credentials,
RefreshToken,
UserMeta,
)
from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow
type IPAddress = IPv4Address | IPv6Address IPAddress = IPv4Address | IPv6Address
type IPNetwork = IPv4Network | IPv6Network IPNetwork = IPv4Network | IPv6Network
CONF_TRUSTED_NETWORKS = "trusted_networks" CONF_TRUSTED_NETWORKS = "trusted_networks"
CONF_TRUSTED_USERS = "trusted_users" CONF_TRUSTED_USERS = "trusted_users"
@ -104,7 +98,7 @@ class TrustedNetworksAuthProvider(AuthProvider):
"""Trusted Networks auth provider does not support MFA.""" """Trusted Networks auth provider does not support MFA."""
return False return False
async def async_login_flow(self, context: AuthFlowContext | None) -> LoginFlow: async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow:
"""Return a flow to login.""" """Return a flow to login."""
assert context is not None assert context is not None
ip_addr = cast(IPAddress, context.get("ip_address")) ip_addr = cast(IPAddress, context.get("ip_address"))
@ -232,7 +226,7 @@ class TrustedNetworksLoginFlow(LoginFlow):
async def async_step_init( async def async_step_init(
self, user_input: dict[str, str] | None = None self, user_input: dict[str, str] | None = None
) -> AuthFlowResult: ) -> FlowResult:
"""Handle the step of the form.""" """Handle the step of the form."""
try: try:
cast( cast(

View file

@ -6,24 +6,10 @@ Since we have dropped support for Python 3.10, we can remove this backport.
This file is kept for now to avoid breaking custom components that might This file is kept for now to avoid breaking custom components that might
import it. import it.
""" """
from __future__ import annotations from __future__ import annotations
from enum import StrEnum as _StrEnum from enum import StrEnum
from functools import partial
from homeassistant.helpers.deprecation import ( __all__ = [
DeprecatedAlias, "StrEnum",
all_with_deprecated_constants, ]
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
# StrEnum deprecated as of 2024.5 use enum.StrEnum instead.
_DEPRECATED_StrEnum = DeprecatedAlias(_StrEnum, "enum.StrEnum", "2025.5")
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())

View file

@ -1,31 +1,81 @@
"""Functools backports from standard lib. """Functools backports from standard lib."""
This file contained the backport of the cached_property implementation of Python 3.12. # This file contains parts of Python's module wrapper
# for the _functools C module
Since we have dropped support for Python 3.11, we can remove this backport. # to allow utilities written in Python to be added
This file is kept for now to avoid breaking custom components that might # to the functools module.
import it. # Written by Nick Coghlan <ncoghlan at gmail.com>,
""" # Raymond Hettinger <python at rcn.com>,
# and Łukasz Langa <lukasz at langa.pl>.
# Copyright © 2001-2023 Python Software Foundation; All Rights Reserved
from __future__ import annotations from __future__ import annotations
# pylint: disable-next=hass-deprecated-import from collections.abc import Callable
from functools import cached_property as _cached_property, partial from types import GenericAlias
from typing import Any, Generic, Self, TypeVar, overload
from homeassistant.helpers.deprecation import ( _T = TypeVar("_T")
DeprecatedAlias,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
)
# cached_property deprecated as of 2024.5 use functools.cached_property instead.
_DEPRECATED_cached_property = DeprecatedAlias(
_cached_property, "functools.cached_property", "2025.5"
)
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) class cached_property(Generic[_T]):
__dir__ = partial( """Backport of Python 3.12's cached_property.
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
) Includes https://github.com/python/cpython/pull/101890/files
__all__ = all_with_deprecated_constants(globals()) """
def __init__(self, func: Callable[[Any], _T]) -> None:
"""Initialize."""
self.func: Callable[[Any], _T] = func
self.attrname: str | None = None
self.__doc__ = func.__doc__
def __set_name__(self, owner: type[Any], name: str) -> None:
"""Set name."""
if self.attrname is None:
self.attrname = name
elif name != self.attrname:
raise TypeError(
"Cannot assign the same cached_property to two different names "
f"({self.attrname!r} and {name!r})."
)
@overload
def __get__(self, instance: None, owner: type[Any] | None = None) -> Self:
...
@overload
def __get__(self, instance: Any, owner: type[Any] | None = None) -> _T:
...
def __get__(
self, instance: Any | None, owner: type[Any] | None = None
) -> _T | Self:
"""Get."""
if instance is None:
return self
if self.attrname is None:
raise TypeError(
"Cannot use cached_property instance without calling __set_name__ on it."
)
try:
cache = instance.__dict__
# not all objects have __dict__ (e.g. class defines slots)
except AttributeError:
msg = (
f"No '__dict__' attribute on {type(instance).__name__!r} "
f"instance to cache {self.attrname!r} property."
)
raise TypeError(msg) from None
val = self.func(instance)
try:
cache[self.attrname] = val
except TypeError:
msg = (
f"The '__dict__' attribute on {type(instance).__name__!r} instance "
f"does not support item assignment for caching {self.attrname!r} property."
)
raise TypeError(msg) from None
return val
__class_getitem__ = classmethod(GenericAlias) # type: ignore[var-annotated]

View file

@ -1,126 +0,0 @@
"""Home Assistant module to handle restoring backups."""
from dataclasses import dataclass
import json
import logging
from pathlib import Path
import shutil
import sys
from tempfile import TemporaryDirectory
from awesomeversion import AwesomeVersion
import securetar
from .const import __version__ as HA_VERSION
RESTORE_BACKUP_FILE = ".HA_RESTORE"
KEEP_PATHS = ("backups",)
_LOGGER = logging.getLogger(__name__)
@dataclass
class RestoreBackupFileContent:
"""Definition for restore backup file content."""
backup_file_path: Path
def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | None:
"""Return the contents of the restore backup file."""
instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE)
try:
instruction_content = json.loads(instruction_path.read_text(encoding="utf-8"))
return RestoreBackupFileContent(
backup_file_path=Path(instruction_content["path"])
)
except (FileNotFoundError, json.JSONDecodeError):
return None
def _clear_configuration_directory(config_dir: Path) -> None:
"""Delete all files and directories in the config directory except for the backups directory."""
keep_paths = [config_dir.joinpath(path) for path in KEEP_PATHS]
config_contents = sorted(
[entry for entry in config_dir.iterdir() if entry not in keep_paths]
)
for entry in config_contents:
entrypath = config_dir.joinpath(entry)
if entrypath.is_file():
entrypath.unlink()
elif entrypath.is_dir():
shutil.rmtree(entrypath)
def _extract_backup(config_dir: Path, backup_file_path: Path) -> None:
"""Extract the backup file to the config directory."""
with (
TemporaryDirectory() as tempdir,
securetar.SecureTarFile(
backup_file_path,
gzip=False,
mode="r",
) as ostf,
):
ostf.extractall(
path=Path(tempdir, "extracted"),
members=securetar.secure_path(ostf),
filter="fully_trusted",
)
backup_meta_file = Path(tempdir, "extracted", "backup.json")
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
if (
backup_meta_version := AwesomeVersion(
backup_meta["homeassistant"]["version"]
)
) > HA_VERSION:
raise ValueError(
f"You need at least Home Assistant version {backup_meta_version} to restore this backup"
)
with securetar.SecureTarFile(
Path(
tempdir,
"extracted",
f"homeassistant.tar{'.gz' if backup_meta["compressed"] else ''}",
),
gzip=backup_meta["compressed"],
mode="r",
) as istf:
for member in istf.getmembers():
if member.name == "data":
continue
member.name = member.name.replace("data/", "")
_clear_configuration_directory(config_dir)
istf.extractall(
path=config_dir,
members=[
member
for member in securetar.secure_path(istf)
if member.name != "data"
],
filter="fully_trusted",
)
def restore_backup(config_dir_path: str) -> bool:
"""Restore the backup file if any.
Returns True if a restore backup file was found and restored, False otherwise.
"""
config_dir = Path(config_dir_path)
if not (restore_content := restore_backup_file_content(config_dir)):
return False
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
backup_file_path = restore_content.backup_file_path
_LOGGER.info("Restoring %s", backup_file_path)
try:
_extract_backup(config_dir, backup_file_path)
except FileNotFoundError as err:
raise ValueError(f"Backup file {backup_file_path} does not exist") from err
_LOGGER.info("Restore complete, restarting")
return True

View file

@ -1,252 +1,20 @@
"""Block blocking calls being done in asyncio.""" """Block blocking calls being done in asyncio."""
import builtins
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
import glob
from http.client import HTTPConnection from http.client import HTTPConnection
import importlib
import os
from pathlib import Path
from ssl import SSLContext
import sys
import threading
import time import time
from typing import Any
from .helpers.frame import get_current_frame from .util.async_ import protect_loop
from .util.loop import protect_loop
_IN_TESTS = "unittest" in sys.modules
ALLOWED_FILE_PREFIXES = ("/proc",)
def _check_import_call_allowed(mapped_args: dict[str, Any]) -> bool:
# If the module is already imported, we can ignore it.
return bool((args := mapped_args.get("args")) and args[0] in sys.modules)
def _check_file_allowed(mapped_args: dict[str, Any]) -> bool:
# If the file is in /proc we can ignore it.
args = mapped_args["args"]
path = args[0] if type(args[0]) is str else str(args[0]) # noqa: E721
return path.startswith(ALLOWED_FILE_PREFIXES)
def _check_sleep_call_allowed(mapped_args: dict[str, Any]) -> bool:
#
# Avoid extracting the stack unless we need to since it
# will have to access the linecache which can do blocking
# I/O and we are trying to avoid blocking calls.
#
# frame[0] is us
# frame[1] is raise_for_blocking_call
# frame[2] is protected_loop_func
# frame[3] is the offender
with suppress(ValueError):
return get_current_frame(4).f_code.co_filename.endswith("pydevd.py")
return False
@dataclass(slots=True, frozen=True)
class BlockingCall:
"""Class to hold information about a blocking call."""
original_func: Callable
object: object
function: str
check_allowed: Callable[[dict[str, Any]], bool] | None
strict: bool
strict_core: bool
skip_for_tests: bool
_BLOCKING_CALLS: tuple[BlockingCall, ...] = (
BlockingCall(
original_func=HTTPConnection.putrequest,
object=HTTPConnection,
function="putrequest",
check_allowed=None,
strict=True,
strict_core=True,
skip_for_tests=False,
),
BlockingCall(
original_func=time.sleep,
object=time,
function="sleep",
check_allowed=_check_sleep_call_allowed,
strict=True,
strict_core=True,
skip_for_tests=False,
),
BlockingCall(
original_func=glob.glob,
object=glob,
function="glob",
check_allowed=None,
strict=False,
strict_core=False,
skip_for_tests=False,
),
BlockingCall(
original_func=glob.iglob,
object=glob,
function="iglob",
check_allowed=None,
strict=False,
strict_core=False,
skip_for_tests=False,
),
BlockingCall(
original_func=os.walk,
object=os,
function="walk",
check_allowed=None,
strict=False,
strict_core=False,
skip_for_tests=False,
),
BlockingCall(
original_func=os.listdir,
object=os,
function="listdir",
check_allowed=None,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=os.scandir,
object=os,
function="scandir",
check_allowed=None,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=builtins.open,
object=builtins,
function="open",
check_allowed=_check_file_allowed,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=importlib.import_module,
object=importlib,
function="import_module",
check_allowed=_check_import_call_allowed,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=SSLContext.load_default_certs,
object=SSLContext,
function="load_default_certs",
check_allowed=None,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=SSLContext.load_verify_locations,
object=SSLContext,
function="load_verify_locations",
check_allowed=None,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=SSLContext.load_cert_chain,
object=SSLContext,
function="load_cert_chain",
check_allowed=None,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=Path.open,
object=Path,
function="open",
check_allowed=_check_file_allowed,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=Path.read_text,
object=Path,
function="read_text",
check_allowed=_check_file_allowed,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=Path.read_bytes,
object=Path,
function="read_bytes",
check_allowed=_check_file_allowed,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=Path.write_text,
object=Path,
function="write_text",
check_allowed=_check_file_allowed,
strict=False,
strict_core=False,
skip_for_tests=True,
),
BlockingCall(
original_func=Path.write_bytes,
object=Path,
function="write_bytes",
check_allowed=_check_file_allowed,
strict=False,
strict_core=False,
skip_for_tests=True,
),
)
@dataclass(slots=True)
class BlockedCalls:
"""Class to track which calls are blocked."""
calls: set[BlockingCall]
_BLOCKED_CALLS = BlockedCalls(set())
def enable() -> None: def enable() -> None:
"""Enable the detection of blocking calls in the event loop.""" """Enable the detection of blocking calls in the event loop."""
calls = _BLOCKED_CALLS.calls # Prevent urllib3 and requests doing I/O in event loop
if calls: HTTPConnection.putrequest = protect_loop( # type: ignore[method-assign]
raise RuntimeError("Blocking call detection is already enabled") HTTPConnection.putrequest
)
loop_thread_id = threading.get_ident() # Prevent sleeping in event loop. Non-strict since 2022.02
for blocking_call in _BLOCKING_CALLS: time.sleep = protect_loop(time.sleep, strict=False)
if _IN_TESTS and blocking_call.skip_for_tests:
continue
protected_function = protect_loop( # Currently disabled. pytz doing I/O when getting timezone.
blocking_call.original_func, # Prevent files being opened inside the event loop
strict=blocking_call.strict, # builtins.open = protect_loop(builtins.open)
strict_core=blocking_call.strict_core,
check_allowed=blocking_call.check_allowed,
loop_thread_id=loop_thread_id,
)
setattr(blocking_call.object, blocking_call.function, protected_function)
calls.add(blocking_call)

File diff suppressed because it is too large Load diff

View file

@ -1,5 +0,0 @@
{
"domain": "ambient_weather",
"name": "Ambient Weather",
"integrations": ["ambient_network", "ambient_station"]
}

View file

@ -1,5 +0,0 @@
{
"domain": "aqara",
"name": "Aqara",
"iot_standards": ["matter", "zigbee"]
}

View file

@ -0,0 +1,5 @@
{
"domain": "asterisk",
"name": "Asterisk",
"integrations": ["asterisk_cdr", "asterisk_mbox"]
}

View file

@ -0,0 +1,5 @@
{
"domain": "epson",
"name": "Epson",
"integrations": ["epson", "epsonworkforce"]
}

View file

@ -1,5 +1,5 @@
{ {
"domain": "eq3", "domain": "eq3",
"name": "eQ-3", "name": "eQ-3",
"integrations": ["maxcube", "eq3btsmart"] "integrations": ["maxcube"]
} }

View file

@ -1,5 +0,0 @@
{
"domain": "flexit",
"name": "Flexit",
"integrations": ["flexit", "flexit_bacnet"]
}

View file

@ -1,5 +0,0 @@
{
"domain": "fujitsu",
"name": "Fujitsu",
"integrations": ["fujitsu_anywair", "fujitsu_fglair"]
}

View file

@ -5,10 +5,10 @@
"google_assistant", "google_assistant",
"google_assistant_sdk", "google_assistant_sdk",
"google_cloud", "google_cloud",
"google_domains",
"google_generative_ai_conversation", "google_generative_ai_conversation",
"google_mail", "google_mail",
"google_maps", "google_maps",
"google_photos",
"google_pubsub", "google_pubsub",
"google_sheets", "google_sheets",
"google_tasks", "google_tasks",

View file

@ -1,5 +0,0 @@
{
"domain": "govee",
"name": "Govee",
"integrations": ["govee_ble", "govee_light_local"]
}

View file

@ -1,5 +0,0 @@
{
"domain": "husqvarna",
"name": "Husqvarna",
"integrations": ["husqvarna_automower", "husqvarna_automower_ble"]
}

View file

@ -1,5 +1,5 @@
{ {
"domain": "lg", "domain": "lg",
"name": "LG", "name": "LG",
"integrations": ["lg_netcast", "lg_soundbar", "lg_thinq", "webostv"] "integrations": ["lg_netcast", "lg_soundbar", "webostv"]
} }

View file

@ -1,5 +1,5 @@
{ {
"domain": "logitech", "domain": "logitech",
"name": "Logitech", "name": "Logitech",
"integrations": ["harmony", "squeezebox"] "integrations": ["harmony", "ue_smart_radio", "squeezebox"]
} }

View file

@ -1,5 +0,0 @@
{
"domain": "motionblinds",
"name": "Motionblinds",
"integrations": ["motion_blinds", "motionblinds_ble"]
}

View file

@ -1,5 +0,0 @@
{
"domain": "rainforest_automation",
"name": "Rainforest Automation",
"integrations": ["rainforest_eagle", "rainforest_raven"]
}

View file

@ -1,5 +0,0 @@
{
"domain": "roth",
"name": "Roth",
"integrations": ["touchline", "touchline_sl"]
}

View file

@ -1,5 +0,0 @@
{
"domain": "ruuvi",
"name": "Ruuvi",
"integrations": ["ruuvi_gateway", "ruuvitag_ble"]
}

View file

@ -1,5 +0,0 @@
{
"domain": "sky",
"name": "Sky",
"integrations": ["sky_hub", "sky_remote"]
}

View file

@ -1,5 +1,5 @@
{ {
"domain": "tesla", "domain": "tesla",
"name": "Tesla", "name": "Tesla",
"integrations": ["powerwall", "tesla_wall_connector", "tesla_fleet"] "integrations": ["powerwall", "tesla_wall_connector"]
} }

View file

@ -1,6 +1,6 @@
{ {
"domain": "tplink", "domain": "tplink",
"name": "TP-Link", "name": "TP-Link",
"integrations": ["tplink", "tplink_omada", "tplink_lte", "tplink_tapo"], "integrations": ["tplink", "tplink_omada", "tplink_lte"],
"iot_standards": ["matter"] "iot_standards": ["matter"]
} }

View file

@ -1,5 +0,0 @@
{
"domain": "traccar",
"name": "Traccar",
"integrations": ["traccar", "traccar_server"]
}

View file

@ -1,5 +0,0 @@
{
"domain": "weatherflow",
"name": "WeatherFlow",
"integrations": ["weatherflow", "weatherflow_cloud"]
}

View file

@ -1,11 +1,5 @@
{ {
"domain": "yale", "domain": "yale",
"name": "Yale", "name": "Yale",
"integrations": [ "integrations": ["august", "yale_smart_alarm", "yalexs_ble", "yale_home"]
"august",
"yale_smart_alarm",
"yalexs_ble",
"yale_home",
"yale"
]
} }

View file

@ -6,3 +6,40 @@ Component design guidelines:
format "<DOMAIN>.<OBJECT_ID>". format "<DOMAIN>.<OBJECT_ID>".
- Each component should publish services only under its own domain. - Each component should publish services only under its own domain.
""" """
from __future__ import annotations
import logging
from homeassistant.core import HomeAssistant, split_entity_id
_LOGGER = logging.getLogger(__name__)
def is_on(hass: HomeAssistant, entity_id: str | None = None) -> bool:
"""Load up the module to call the is_on method.
If there is no entity id given we will check all.
"""
if entity_id:
entity_ids = hass.components.group.expand_entity_ids([entity_id])
else:
entity_ids = hass.states.entity_ids()
for ent_id in entity_ids:
domain = split_entity_id(ent_id)[0]
try:
component = getattr(hass.components, domain)
except ImportError:
_LOGGER.error("Failed to call %s.is_on: component not found", domain)
continue
if not hasattr(component, "is_on"):
_LOGGER.warning("Integration %s has no is_on method", domain)
continue
if component.is_on(ent_id):
return True
return False

View file

@ -1,13 +1,12 @@
"""Support for the Abode Security System.""" """Support for the Abode Security System."""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import partial from functools import partial
from pathlib import Path
from jaraco.abode.automation import Automation as AbodeAuto
from jaraco.abode.client import Client as Abode from jaraco.abode.client import Client as Abode
import jaraco.abode.config from jaraco.abode.devices.base import Device as AbodeDev
from jaraco.abode.exceptions import ( from jaraco.abode.exceptions import (
AuthenticationException as AbodeAuthenticationException, AuthenticationException as AbodeAuthenticationException,
Exception as AbodeException, Exception as AbodeException,
@ -29,11 +28,11 @@ from homeassistant.const import (
) )
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, ServiceCall from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv, entity
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.typing import ConfigType
from .const import CONF_POLLING, DOMAIN, LOGGER from .const import ATTRIBUTION, CONF_POLLING, DOMAIN, LOGGER
SERVICE_SETTINGS = "change_setting" SERVICE_SETTINGS = "change_setting"
SERVICE_CAPTURE_IMAGE = "capture_image" SERVICE_CAPTURE_IMAGE = "capture_image"
@ -64,12 +63,12 @@ AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
PLATFORMS = [ PLATFORMS = [
Platform.ALARM_CONTROL_PANEL, Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.CAMERA,
Platform.COVER,
Platform.LIGHT,
Platform.LOCK, Platform.LOCK,
Platform.SENSOR,
Platform.SWITCH, Platform.SWITCH,
Platform.COVER,
Platform.CAMERA,
Platform.LIGHT,
Platform.SENSOR,
] ]
@ -83,21 +82,12 @@ class AbodeSystem:
logout_listener: CALLBACK_TYPE | None = None logout_listener: CALLBACK_TYPE | None = None
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Abode component."""
setup_hass_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Abode integration from a config entry.""" """Set up Abode integration from a config entry."""
username = entry.data[CONF_USERNAME] username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD] password = entry.data[CONF_PASSWORD]
polling = entry.data[CONF_POLLING] polling = entry.data[CONF_POLLING]
# Configure abode library to use config directory for storing data
jaraco.abode.config.paths.override(user_data=Path(hass.config.path("Abode")))
# For previous config entries where unique_id is None # For previous config entries where unique_id is None
if entry.unique_id is None: if entry.unique_id is None:
hass.config_entries.async_update_entry( hass.config_entries.async_update_entry(
@ -120,6 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await setup_hass_events(hass) await setup_hass_events(hass)
await hass.async_add_executor_job(setup_hass_services, hass)
await hass.async_add_executor_job(setup_abode_events, hass) await hass.async_add_executor_job(setup_abode_events, hass)
return True return True
@ -127,6 +118,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
hass.services.async_remove(DOMAIN, SERVICE_SETTINGS)
hass.services.async_remove(DOMAIN, SERVICE_CAPTURE_IMAGE)
hass.services.async_remove(DOMAIN, SERVICE_TRIGGER_AUTOMATION)
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.stop) await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.stop)
@ -179,15 +174,15 @@ def setup_hass_services(hass: HomeAssistant) -> None:
signal = f"abode_trigger_automation_{entity_id}" signal = f"abode_trigger_automation_{entity_id}"
dispatcher_send(hass, signal) dispatcher_send(hass, signal)
hass.services.async_register( hass.services.register(
DOMAIN, SERVICE_SETTINGS, change_setting, schema=CHANGE_SETTING_SCHEMA DOMAIN, SERVICE_SETTINGS, change_setting, schema=CHANGE_SETTING_SCHEMA
) )
hass.services.async_register( hass.services.register(
DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image, schema=CAPTURE_IMAGE_SCHEMA DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image, schema=CAPTURE_IMAGE_SCHEMA
) )
hass.services.async_register( hass.services.register(
DOMAIN, SERVICE_TRIGGER_AUTOMATION, trigger_automation, schema=AUTOMATION_SCHEMA DOMAIN, SERVICE_TRIGGER_AUTOMATION, trigger_automation, schema=AUTOMATION_SCHEMA
) )
@ -251,3 +246,108 @@ def setup_abode_events(hass: HomeAssistant) -> None:
hass.data[DOMAIN].abode.events.add_event_callback( hass.data[DOMAIN].abode.events.add_event_callback(
event, partial(event_callback, event) event, partial(event_callback, event)
) )
class AbodeEntity(entity.Entity):
"""Representation of an Abode entity."""
_attr_attribution = ATTRIBUTION
_attr_has_entity_name = True
def __init__(self, data: AbodeSystem) -> None:
"""Initialize Abode entity."""
self._data = data
self._attr_should_poll = data.polling
async def async_added_to_hass(self) -> None:
"""Subscribe to Abode connection status updates."""
await self.hass.async_add_executor_job(
self._data.abode.events.add_connection_status_callback,
self.unique_id,
self._update_connection_status,
)
self.hass.data[DOMAIN].entity_ids.add(self.entity_id)
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from Abode connection status updates."""
await self.hass.async_add_executor_job(
self._data.abode.events.remove_connection_status_callback, self.unique_id
)
def _update_connection_status(self) -> None:
"""Update the entity available property."""
self._attr_available = self._data.abode.events.connected
self.schedule_update_ha_state()
class AbodeDevice(AbodeEntity):
"""Representation of an Abode device."""
def __init__(self, data: AbodeSystem, device: AbodeDev) -> None:
"""Initialize Abode device."""
super().__init__(data)
self._device = device
self._attr_unique_id = device.uuid
async def async_added_to_hass(self) -> None:
"""Subscribe to device events."""
await super().async_added_to_hass()
await self.hass.async_add_executor_job(
self._data.abode.events.add_device_callback,
self._device.id,
self._update_callback,
)
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from device events."""
await super().async_will_remove_from_hass()
await self.hass.async_add_executor_job(
self._data.abode.events.remove_all_device_callbacks, self._device.id
)
def update(self) -> None:
"""Update device state."""
self._device.refresh()
@property
def extra_state_attributes(self) -> dict[str, str]:
"""Return the state attributes."""
return {
"device_id": self._device.id,
"battery_low": self._device.battery_low,
"no_response": self._device.no_response,
"device_type": self._device.type,
}
@property
def device_info(self) -> DeviceInfo:
"""Return device registry information for this entity."""
return DeviceInfo(
identifiers={(DOMAIN, self._device.id)},
manufacturer="Abode",
model=self._device.type,
name=self._device.name,
)
def _update_callback(self, device: AbodeDev) -> None:
"""Update the device state."""
self.schedule_update_ha_state()
class AbodeAutomation(AbodeEntity):
"""Representation of an Abode automation."""
def __init__(self, data: AbodeSystem, automation: AbodeAuto) -> None:
"""Initialize for Abode automation."""
super().__init__(data)
self._automation = automation
self._attr_name = automation.name
self._attr_unique_id = automation.automation_id
self._attr_extra_state_attributes = {
"type": "CUE automation",
}
def update(self) -> None:
"""Update automation state."""
self._automation.refresh()

View file

@ -1,21 +1,23 @@
"""Support for Abode Security System alarm control panels.""" """Support for Abode Security System alarm control panels."""
from __future__ import annotations from __future__ import annotations
from jaraco.abode.devices.alarm import Alarm from jaraco.abode.devices.alarm import Alarm as AbodeAl
from homeassistant.components.alarm_control_panel import ( import homeassistant.components.alarm_control_panel as alarm
AlarmControlPanelEntity, from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_HOME,
STATE_ALARM_DISARMED,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AbodeSystem from . import AbodeDevice, AbodeSystem
from .const import DOMAIN from .const import DOMAIN
from .entity import AbodeDevice
ICON = "mdi:security"
async def async_setup_entry( async def async_setup_entry(
@ -28,26 +30,27 @@ async def async_setup_entry(
) )
class AbodeAlarm(AbodeDevice, AlarmControlPanelEntity): class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanelEntity):
"""An alarm_control_panel implementation for Abode.""" """An alarm_control_panel implementation for Abode."""
_attr_icon = ICON
_attr_name = None _attr_name = None
_attr_code_arm_required = False _attr_code_arm_required = False
_attr_supported_features = ( _attr_supported_features = (
AlarmControlPanelEntityFeature.ARM_HOME AlarmControlPanelEntityFeature.ARM_HOME
| AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_AWAY
) )
_device: Alarm _device: AbodeAl
@property @property
def alarm_state(self) -> AlarmControlPanelState | None: def state(self) -> str | None:
"""Return the state of the device.""" """Return the state of the device."""
if self._device.is_standby: if self._device.is_standby:
return AlarmControlPanelState.DISARMED return STATE_ALARM_DISARMED
if self._device.is_away: if self._device.is_away:
return AlarmControlPanelState.ARMED_AWAY return STATE_ALARM_ARMED_AWAY
if self._device.is_home: if self._device.is_home:
return AlarmControlPanelState.ARMED_HOME return STATE_ALARM_ARMED_HOME
return None return None
def alarm_disarm(self, code: str | None = None) -> None: def alarm_disarm(self, code: str | None = None) -> None:

View file

@ -1,10 +1,10 @@
"""Support for Abode Security System binary sensors.""" """Support for Abode Security System binary sensors."""
from __future__ import annotations from __future__ import annotations
from typing import cast from typing import cast
from jaraco.abode.devices.binary_sensor import BinarySensor from jaraco.abode.devices.sensor import BinarySensor as ABBinarySensor
from jaraco.abode.helpers import constants as CONST
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass, BinarySensorDeviceClass,
@ -15,9 +15,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.enum import try_parse_enum from homeassistant.util.enum import try_parse_enum
from . import AbodeSystem from . import AbodeDevice, AbodeSystem
from .const import DOMAIN from .const import DOMAIN
from .entity import AbodeDevice
async def async_setup_entry( async def async_setup_entry(
@ -27,11 +26,11 @@ async def async_setup_entry(
data: AbodeSystem = hass.data[DOMAIN] data: AbodeSystem = hass.data[DOMAIN]
device_types = [ device_types = [
"connectivity", CONST.TYPE_CONNECTIVITY,
"moisture", CONST.TYPE_MOISTURE,
"motion", CONST.TYPE_MOTION,
"occupancy", CONST.TYPE_OCCUPANCY,
"door", CONST.TYPE_OPENING,
] ]
async_add_entities( async_add_entities(
@ -44,7 +43,7 @@ class AbodeBinarySensor(AbodeDevice, BinarySensorEntity):
"""A binary sensor implementation for Abode device.""" """A binary sensor implementation for Abode device."""
_attr_name = None _attr_name = None
_device: BinarySensor _device: ABBinarySensor
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:

View file

@ -1,13 +1,12 @@
"""Support for Abode Security System cameras.""" """Support for Abode Security System cameras."""
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from typing import Any, cast from typing import Any, cast
from jaraco.abode.devices.base import Device from jaraco.abode.devices.base import Device as AbodeDev
from jaraco.abode.devices.camera import Camera as AbodeCam from jaraco.abode.devices.camera import Camera as AbodeCam
from jaraco.abode.helpers import timeline from jaraco.abode.helpers import constants as CONST, timeline as TIMELINE
import requests import requests
from requests.models import Response from requests.models import Response
@ -18,9 +17,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import Throttle from homeassistant.util import Throttle
from . import AbodeSystem from . import AbodeDevice, AbodeSystem
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
from .entity import AbodeDevice
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
@ -32,8 +30,8 @@ async def async_setup_entry(
data: AbodeSystem = hass.data[DOMAIN] data: AbodeSystem = hass.data[DOMAIN]
async_add_entities( async_add_entities(
AbodeCamera(data, device, timeline.CAPTURE_IMAGE) AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE)
for device in data.abode.get_devices(generic_type="camera") for device in data.abode.get_devices(generic_type=CONST.TYPE_CAMERA)
) )
@ -43,7 +41,7 @@ class AbodeCamera(AbodeDevice, Camera):
_device: AbodeCam _device: AbodeCam
_attr_name = None _attr_name = None
def __init__(self, data: AbodeSystem, device: Device, event: Event) -> None: def __init__(self, data: AbodeSystem, device: AbodeDev, event: Event) -> None:
"""Initialize the Abode device.""" """Initialize the Abode device."""
AbodeDevice.__init__(self, data, device) AbodeDevice.__init__(self, data, device)
Camera.__init__(self) Camera.__init__(self)

View file

@ -1,5 +1,4 @@
"""Config flow for the Abode Security System component.""" """Config flow for the Abode Security System component."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
@ -15,15 +14,16 @@ from jaraco.abode.helpers.errors import MFA_CODE_REQUIRED
from requests.exceptions import ConnectTimeout, HTTPError from requests.exceptions import ConnectTimeout, HTTPError
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResult
from .const import CONF_POLLING, DOMAIN, LOGGER from .const import CONF_POLLING, DOMAIN, LOGGER
CONF_MFA = "mfa_code" CONF_MFA = "mfa_code"
class AbodeFlowHandler(ConfigFlow, domain=DOMAIN): class AbodeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for Abode.""" """Config flow for Abode."""
VERSION = 1 VERSION = 1
@ -43,7 +43,7 @@ class AbodeFlowHandler(ConfigFlow, domain=DOMAIN):
self._polling: bool = False self._polling: bool = False
self._username: str | None = None self._username: str | None = None
async def _async_abode_login(self, step_id: str) -> ConfigFlowResult: async def _async_abode_login(self, step_id: str) -> FlowResult:
"""Handle login with Abode.""" """Handle login with Abode."""
errors = {} errors = {}
@ -74,7 +74,7 @@ class AbodeFlowHandler(ConfigFlow, domain=DOMAIN):
return await self._async_create_entry() return await self._async_create_entry()
async def _async_abode_mfa_login(self) -> ConfigFlowResult: async def _async_abode_mfa_login(self) -> FlowResult:
"""Handle multi-factor authentication (MFA) login with Abode.""" """Handle multi-factor authentication (MFA) login with Abode."""
try: try:
# Create instance to access login method for passing MFA code # Create instance to access login method for passing MFA code
@ -92,7 +92,7 @@ class AbodeFlowHandler(ConfigFlow, domain=DOMAIN):
return await self._async_create_entry() return await self._async_create_entry()
async def _async_create_entry(self) -> ConfigFlowResult: async def _async_create_entry(self) -> FlowResult:
"""Create the config entry.""" """Create the config entry."""
config_data = { config_data = {
CONF_USERNAME: self._username, CONF_USERNAME: self._username,
@ -102,7 +102,15 @@ class AbodeFlowHandler(ConfigFlow, domain=DOMAIN):
existing_entry = await self.async_set_unique_id(self._username) existing_entry = await self.async_set_unique_id(self._username)
if existing_entry: if existing_entry:
return self.async_update_reload_and_abort(existing_entry, data=config_data) self.hass.config_entries.async_update_entry(
existing_entry, data=config_data
)
# Reload the Abode config entry otherwise devices will remain unavailable
self.hass.async_create_task(
self.hass.config_entries.async_reload(existing_entry.entry_id)
)
return self.async_abort(reason="reauth_successful")
return self.async_create_entry( return self.async_create_entry(
title=cast(str, self._username), data=config_data title=cast(str, self._username), data=config_data
@ -110,7 +118,7 @@ class AbodeFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> FlowResult:
"""Handle a flow initialized by the user.""" """Handle a flow initialized by the user."""
if self._async_current_entries(): if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed") return self.async_abort(reason="single_instance_allowed")
@ -127,7 +135,7 @@ class AbodeFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_mfa( async def async_step_mfa(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> FlowResult:
"""Handle a multi-factor authentication (MFA) flow.""" """Handle a multi-factor authentication (MFA) flow."""
if user_input is None: if user_input is None:
return self.async_show_form( return self.async_show_form(
@ -138,9 +146,7 @@ class AbodeFlowHandler(ConfigFlow, domain=DOMAIN):
return await self._async_abode_mfa_login() return await self._async_abode_mfa_login()
async def async_step_reauth( async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthorization request from Abode.""" """Handle reauthorization request from Abode."""
self._username = entry_data[CONF_USERNAME] self._username = entry_data[CONF_USERNAME]
@ -148,7 +154,7 @@ class AbodeFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_reauth_confirm( async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> FlowResult:
"""Handle reauthorization flow.""" """Handle reauthorization flow."""
if user_input is None: if user_input is None:
return self.async_show_form( return self.async_show_form(

View file

@ -1,5 +1,4 @@
"""Constants for the Abode Security System component.""" """Constants for the Abode Security System component."""
import logging import logging
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)

View file

@ -1,17 +1,16 @@
"""Support for Abode Security System covers.""" """Support for Abode Security System covers."""
from typing import Any from typing import Any
from jaraco.abode.devices.cover import Cover from jaraco.abode.devices.cover import Cover as AbodeCV
from jaraco.abode.helpers import constants as CONST
from homeassistant.components.cover import CoverEntity from homeassistant.components.cover import CoverEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AbodeSystem from . import AbodeDevice, AbodeSystem
from .const import DOMAIN from .const import DOMAIN
from .entity import AbodeDevice
async def async_setup_entry( async def async_setup_entry(
@ -22,14 +21,14 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
AbodeCover(data, device) AbodeCover(data, device)
for device in data.abode.get_devices(generic_type="cover") for device in data.abode.get_devices(generic_type=CONST.TYPE_COVER)
) )
class AbodeCover(AbodeDevice, CoverEntity): class AbodeCover(AbodeDevice, CoverEntity):
"""Representation of an Abode cover.""" """Representation of an Abode cover."""
_device: Cover _device: AbodeCV
_attr_name = None _attr_name = None
@property @property

View file

@ -1,115 +0,0 @@
"""Support for Abode Security System entities."""
from jaraco.abode.automation import Automation as AbodeAuto
from jaraco.abode.devices.base import Device as AbodeDev
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from . import AbodeSystem
from .const import ATTRIBUTION, DOMAIN
class AbodeEntity(Entity):
"""Representation of an Abode entity."""
_attr_attribution = ATTRIBUTION
_attr_has_entity_name = True
def __init__(self, data: AbodeSystem) -> None:
"""Initialize Abode entity."""
self._data = data
self._attr_should_poll = data.polling
async def async_added_to_hass(self) -> None:
"""Subscribe to Abode connection status updates."""
await self.hass.async_add_executor_job(
self._data.abode.events.add_connection_status_callback,
self.unique_id,
self._update_connection_status,
)
self.hass.data[DOMAIN].entity_ids.add(self.entity_id)
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from Abode connection status updates."""
await self.hass.async_add_executor_job(
self._data.abode.events.remove_connection_status_callback, self.unique_id
)
def _update_connection_status(self) -> None:
"""Update the entity available property."""
self._attr_available = self._data.abode.events.connected
self.schedule_update_ha_state()
class AbodeDevice(AbodeEntity):
"""Representation of an Abode device."""
def __init__(self, data: AbodeSystem, device: AbodeDev) -> None:
"""Initialize Abode device."""
super().__init__(data)
self._device = device
self._attr_unique_id = device.uuid
async def async_added_to_hass(self) -> None:
"""Subscribe to device events."""
await super().async_added_to_hass()
await self.hass.async_add_executor_job(
self._data.abode.events.add_device_callback,
self._device.id,
self._update_callback,
)
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from device events."""
await super().async_will_remove_from_hass()
await self.hass.async_add_executor_job(
self._data.abode.events.remove_all_device_callbacks, self._device.id
)
def update(self) -> None:
"""Update device state."""
self._device.refresh()
@property
def extra_state_attributes(self) -> dict[str, str]:
"""Return the state attributes."""
return {
"device_id": self._device.id,
"battery_low": self._device.battery_low,
"no_response": self._device.no_response,
"device_type": self._device.type,
}
@property
def device_info(self) -> DeviceInfo:
"""Return device registry information for this entity."""
return DeviceInfo(
identifiers={(DOMAIN, self._device.id)},
manufacturer="Abode",
model=self._device.type,
name=self._device.name,
)
def _update_callback(self, device: AbodeDev) -> None:
"""Update the device state."""
self.schedule_update_ha_state()
class AbodeAutomation(AbodeEntity):
"""Representation of an Abode automation."""
def __init__(self, data: AbodeSystem, automation: AbodeAuto) -> None:
"""Initialize for Abode automation."""
super().__init__(data)
self._automation = automation
self._attr_name = automation.name
self._attr_unique_id = automation.id
self._attr_extra_state_attributes = {
"type": "CUE automation",
}
def update(self) -> None:
"""Update automation state."""
self._automation.refresh()

View file

@ -1,20 +0,0 @@
{
"entity": {
"switch": {
"automation": {
"default": "mdi:robot"
}
}
},
"services": {
"capture_image": {
"service": "mdi:camera"
},
"change_setting": {
"service": "mdi:cog"
},
"trigger_automation": {
"service": "mdi:play"
}
}
}

View file

@ -1,11 +1,11 @@
"""Support for Abode Security System lights.""" """Support for Abode Security System lights."""
from __future__ import annotations from __future__ import annotations
from math import ceil from math import ceil
from typing import Any from typing import Any
from jaraco.abode.devices.light import Light from jaraco.abode.devices.light import Light as AbodeLT
from jaraco.abode.helpers import constants as CONST
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
@ -22,9 +22,8 @@ from homeassistant.util.color import (
color_temperature_mired_to_kelvin, color_temperature_mired_to_kelvin,
) )
from . import AbodeSystem from . import AbodeDevice, AbodeSystem
from .const import DOMAIN from .const import DOMAIN
from .entity import AbodeDevice
async def async_setup_entry( async def async_setup_entry(
@ -35,14 +34,14 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
AbodeLight(data, device) AbodeLight(data, device)
for device in data.abode.get_devices(generic_type="light") for device in data.abode.get_devices(generic_type=CONST.TYPE_LIGHT)
) )
class AbodeLight(AbodeDevice, LightEntity): class AbodeLight(AbodeDevice, LightEntity):
"""Representation of an Abode light.""" """Representation of an Abode light."""
_device: Light _device: AbodeLT
_attr_name = None _attr_name = None
def turn_on(self, **kwargs: Any) -> None: def turn_on(self, **kwargs: Any) -> None:

View file

@ -1,17 +1,16 @@
"""Support for the Abode Security System locks.""" """Support for the Abode Security System locks."""
from typing import Any from typing import Any
from jaraco.abode.devices.lock import Lock from jaraco.abode.devices.lock import Lock as AbodeLK
from jaraco.abode.helpers import constants as CONST
from homeassistant.components.lock import LockEntity from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AbodeSystem from . import AbodeDevice, AbodeSystem
from .const import DOMAIN from .const import DOMAIN
from .entity import AbodeDevice
async def async_setup_entry( async def async_setup_entry(
@ -22,14 +21,14 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
AbodeLock(data, device) AbodeLock(data, device)
for device in data.abode.get_devices(generic_type="lock") for device in data.abode.get_devices(generic_type=CONST.TYPE_LOCK)
) )
class AbodeLock(AbodeDevice, LockEntity): class AbodeLock(AbodeDevice, LockEntity):
"""Representation of an Abode lock.""" """Representation of an Abode lock."""
_device: Lock _device: AbodeLK
_attr_name = None _attr_name = None
def lock(self, **kwargs: Any) -> None: def lock(self, **kwargs: Any) -> None:

View file

@ -9,5 +9,5 @@
}, },
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["jaraco.abode", "lomond"], "loggers": ["jaraco.abode", "lomond"],
"requirements": ["jaraco.abode==6.2.1"] "requirements": ["jaraco.abode==3.3.0", "jaraco.functools==3.9.0"]
} }

View file

@ -1,12 +1,12 @@
"""Support for Abode Security System sensors.""" """Support for Abode Security System sensors."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import cast from typing import cast
from jaraco.abode.devices.sensor import Sensor from jaraco.abode.devices.sensor import Sensor as AbodeSense
from jaraco.abode.helpers import constants as CONST
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -18,27 +18,31 @@ from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AbodeSystem from . import AbodeDevice, AbodeSystem
from .const import DOMAIN from .const import DOMAIN
from .entity import AbodeDevice
ABODE_TEMPERATURE_UNIT_HA_UNIT = { ABODE_TEMPERATURE_UNIT_HA_UNIT = {
"°F": UnitOfTemperature.FAHRENHEIT, CONST.UNIT_FAHRENHEIT: UnitOfTemperature.FAHRENHEIT,
"°C": UnitOfTemperature.CELSIUS, CONST.UNIT_CELSIUS: UnitOfTemperature.CELSIUS,
} }
@dataclass(frozen=True, kw_only=True) @dataclass
class AbodeSensorDescription(SensorEntityDescription): class AbodeSensorDescriptionMixin:
"""Class describing Abode sensor entities.""" """Mixin for Abode sensor."""
value_fn: Callable[[Sensor], float] value_fn: Callable[[AbodeSense], float]
native_unit_of_measurement_fn: Callable[[Sensor], str] native_unit_of_measurement_fn: Callable[[AbodeSense], str]
@dataclass
class AbodeSensorDescription(SensorEntityDescription, AbodeSensorDescriptionMixin):
"""Class describing Abode sensor entities."""
SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = ( SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
AbodeSensorDescription( AbodeSensorDescription(
key="temperature", key=CONST.TEMP_STATUS_KEY,
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement_fn=lambda device: ABODE_TEMPERATURE_UNIT_HA_UNIT[ native_unit_of_measurement_fn=lambda device: ABODE_TEMPERATURE_UNIT_HA_UNIT[
device.temp_unit device.temp_unit
@ -46,13 +50,13 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
value_fn=lambda device: cast(float, device.temp), value_fn=lambda device: cast(float, device.temp),
), ),
AbodeSensorDescription( AbodeSensorDescription(
key="humidity", key=CONST.HUMI_STATUS_KEY,
device_class=SensorDeviceClass.HUMIDITY, device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement_fn=lambda _: PERCENTAGE, native_unit_of_measurement_fn=lambda _: PERCENTAGE,
value_fn=lambda device: cast(float, device.humidity), value_fn=lambda device: cast(float, device.humidity),
), ),
AbodeSensorDescription( AbodeSensorDescription(
key="lux", key=CONST.LUX_STATUS_KEY,
device_class=SensorDeviceClass.ILLUMINANCE, device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement_fn=lambda _: LIGHT_LUX, native_unit_of_measurement_fn=lambda _: LIGHT_LUX,
value_fn=lambda device: cast(float, device.lux), value_fn=lambda device: cast(float, device.lux),
@ -69,8 +73,8 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
AbodeSensor(data, device, description) AbodeSensor(data, device, description)
for description in SENSOR_TYPES for description in SENSOR_TYPES
for device in data.abode.get_devices(generic_type="sensor") for device in data.abode.get_devices(generic_type=CONST.TYPE_SENSOR)
if description.key in device.get_value("statuses") if description.key in device.get_value(CONST.STATUSES_KEY)
) )
@ -78,12 +82,12 @@ class AbodeSensor(AbodeDevice, SensorEntity):
"""A sensor implementation for Abode devices.""" """A sensor implementation for Abode devices."""
entity_description: AbodeSensorDescription entity_description: AbodeSensorDescription
_device: Sensor _device: AbodeSense
def __init__( def __init__(
self, self,
data: AbodeSystem, data: AbodeSystem,
device: Sensor, device: AbodeSense,
description: AbodeSensorDescription, description: AbodeSensorDescription,
) -> None: ) -> None:
"""Initialize a sensor for an Abode device.""" """Initialize a sensor for an Abode device."""

View file

@ -1,10 +1,10 @@
"""Support for Abode Security System switches.""" """Support for Abode Security System switches."""
from __future__ import annotations from __future__ import annotations
from typing import Any, cast from typing import Any, cast
from jaraco.abode.devices.switch import Switch from jaraco.abode.devices.switch import Switch as AbodeSW
from jaraco.abode.helpers import constants as CONST
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -12,11 +12,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import AbodeSystem from . import AbodeAutomation, AbodeDevice, AbodeSystem
from .const import DOMAIN from .const import DOMAIN
from .entity import AbodeAutomation, AbodeDevice
DEVICE_TYPES = ["switch", "valve"] DEVICE_TYPES = [CONST.TYPE_SWITCH, CONST.TYPE_VALVE]
ICON = "mdi:robot"
async def async_setup_entry( async def async_setup_entry(
@ -42,7 +43,7 @@ async def async_setup_entry(
class AbodeSwitch(AbodeDevice, SwitchEntity): class AbodeSwitch(AbodeDevice, SwitchEntity):
"""Representation of an Abode switch.""" """Representation of an Abode switch."""
_device: Switch _device: AbodeSW
_attr_name = None _attr_name = None
def turn_on(self, **kwargs: Any) -> None: def turn_on(self, **kwargs: Any) -> None:
@ -62,7 +63,7 @@ class AbodeSwitch(AbodeDevice, SwitchEntity):
class AbodeAutomationSwitch(AbodeAutomation, SwitchEntity): class AbodeAutomationSwitch(AbodeAutomation, SwitchEntity):
"""A switch implementation for Abode automations.""" """A switch implementation for Abode automations."""
_attr_translation_key = "automation" _attr_icon = ICON
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Set up trigger automation service.""" """Set up trigger automation service."""
@ -88,4 +89,4 @@ class AbodeAutomationSwitch(AbodeAutomation, SwitchEntity):
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return True if the automation is enabled.""" """Return True if the automation is enabled."""
return bool(self._automation.enabled) return bool(self._automation.is_enabled)

View file

@ -1,29 +0,0 @@
"""Initialize the Acaia component."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AcaiaConfigEntry, AcaiaCoordinator
PLATFORMS = [
Platform.BUTTON,
]
async def async_setup_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool:
"""Set up acaia as config entry."""
coordinator = AcaiaCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View file

@ -1,61 +0,0 @@
"""Button entities for Acaia scales."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from aioacaia.acaiascale import AcaiaScale
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import AcaiaConfigEntry
from .entity import AcaiaEntity
@dataclass(kw_only=True, frozen=True)
class AcaiaButtonEntityDescription(ButtonEntityDescription):
"""Description for acaia button entities."""
press_fn: Callable[[AcaiaScale], Coroutine[Any, Any, None]]
BUTTONS: tuple[AcaiaButtonEntityDescription, ...] = (
AcaiaButtonEntityDescription(
key="tare",
translation_key="tare",
press_fn=lambda scale: scale.tare(),
),
AcaiaButtonEntityDescription(
key="reset_timer",
translation_key="reset_timer",
press_fn=lambda scale: scale.reset_timer(),
),
AcaiaButtonEntityDescription(
key="start_stop",
translation_key="start_stop",
press_fn=lambda scale: scale.start_stop_timer(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AcaiaConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up button entities and services."""
coordinator = entry.runtime_data
async_add_entities(AcaiaButton(coordinator, description) for description in BUTTONS)
class AcaiaButton(AcaiaEntity, ButtonEntity):
"""Representation of an Acaia button."""
entity_description: AcaiaButtonEntityDescription
async def async_press(self) -> None:
"""Handle the button press."""
await self.entity_description.press_fn(self._scale)

View file

@ -1,149 +0,0 @@
"""Config flow for Acaia integration."""
import logging
from typing import Any
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError, AcaiaUnknownDevice
from aioacaia.helpers import is_new_scale
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_IS_NEW_STYLE_SCALE, DOMAIN
_LOGGER = logging.getLogger(__name__)
class AcaiaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for acaia."""
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered: dict[str, Any] = {}
self._discovered_devices: dict[str, str] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input is not None:
mac = format_mac(user_input[CONF_ADDRESS])
try:
is_new_style_scale = await is_new_scale(mac)
except AcaiaDeviceNotFound:
errors["base"] = "device_not_found"
except AcaiaError:
_LOGGER.exception("Error occurred while connecting to the scale")
errors["base"] = "unknown"
except AcaiaUnknownDevice:
return self.async_abort(reason="unsupported_device")
else:
await self.async_set_unique_id(mac)
self._abort_if_unique_id_configured()
if not errors:
return self.async_create_entry(
title=self._discovered_devices[user_input[CONF_ADDRESS]],
data={
CONF_ADDRESS: mac,
CONF_IS_NEW_STYLE_SCALE: is_new_style_scale,
},
)
for device in async_discovered_service_info(self.hass):
self._discovered_devices[device.address] = device.name
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
options = [
SelectOptionDict(
value=device_mac,
label=f"{device_name} ({device_mac})",
)
for device_mac, device_name in self._discovered_devices.items()
]
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.DROPDOWN,
)
)
}
),
errors=errors,
)
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle a discovered Bluetooth device."""
self._discovered[CONF_ADDRESS] = mac = format_mac(discovery_info.address)
self._discovered[CONF_NAME] = discovery_info.name
await self.async_set_unique_id(mac)
self._abort_if_unique_id_configured()
try:
self._discovered[CONF_IS_NEW_STYLE_SCALE] = await is_new_scale(
discovery_info.address
)
except AcaiaDeviceNotFound:
_LOGGER.debug("Device not found during discovery")
return self.async_abort(reason="device_not_found")
except AcaiaError:
_LOGGER.debug(
"Error occurred while connecting to the scale during discovery",
exc_info=True,
)
return self.async_abort(reason="unknown")
except AcaiaUnknownDevice:
_LOGGER.debug("Unsupported device during discovery")
return self.async_abort(reason="unsupported_device")
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle confirmation of Bluetooth discovery."""
if user_input is not None:
return self.async_create_entry(
title=self._discovered[CONF_NAME],
data={
CONF_ADDRESS: self._discovered[CONF_ADDRESS],
CONF_IS_NEW_STYLE_SCALE: self._discovered[CONF_IS_NEW_STYLE_SCALE],
},
)
self.context["title_placeholders"] = placeholders = {
CONF_NAME: self._discovered[CONF_NAME]
}
self._set_confirm_only()
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders=placeholders,
)

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