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
18257 changed files with 443864 additions and 1742588 deletions

View File

@@ -6,7 +6,6 @@ core: &core
- homeassistant/helpers/** - homeassistant/helpers/**
- homeassistant/package_constraints.txt - homeassistant/package_constraints.txt
- homeassistant/util/** - homeassistant/util/**
- mypy.ini
- pyproject.toml - pyproject.toml
- requirements.txt - requirements.txt
- setup.cfg - setup.cfg
@@ -15,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/**
@@ -51,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/**
@@ -63,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/**
@@ -80,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/**
@@ -113,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/**
@@ -126,22 +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/light/common.py
- 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/**
@@ -155,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": "${containerWorkspaceFolder}/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

11
.gitattributes vendored
View File

@@ -11,14 +11,3 @@
*.pcm binary *.pcm binary
Dockerfile.dev linguist-language=Dockerfile Dockerfile.dev linguist-language=Dockerfile
# Generated files
CODEOWNERS linguist-generated=true
Dockerfile linguist-generated=true
homeassistant/generated/*.py linguist-generated=true
mypy.ini linguist-generated=true
requirements.txt linguist-generated=true
requirements_all.txt linguist-generated=true
requirements_test_all.txt linguist-generated=true
requirements_test_pre_commit.txt linguist-generated=true
script/hassfest/docker/Dockerfile linguist-generated=true

3
.github/FUNDING.yml vendored
View File

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

View File

@@ -1,6 +1,5 @@
name: Report an issue with Home Assistant Core name: Report an issue with Home Assistant Core
description: Report an issue with Home Assistant Core. description: Report an issue with Home Assistant Core.
type: Bug
body: body:
- type: markdown - type: markdown
attributes: attributes:

View File

@@ -46,8 +46,6 @@
- This PR fixes or closes issue: fixes # - This PR fixes or closes issue: fixes #
- This PR is related to issue: - This PR is related to issue:
- Link to documentation pull request: - Link to documentation pull request:
- Link to developer documentation pull request:
- Link to frontend pull request:
## Checklist ## Checklist
<!-- <!--
@@ -76,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: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -1,100 +0,0 @@
# Instructions for GitHub Copilot
This repository holds the core of Home Assistant, a Python 3 based home
automation application.
- Python code must be compatible with Python 3.13
- Use the newest Python language features if possible:
- Pattern matching
- Type hints
- f-strings for string formatting over `%` or `.format()`
- Dataclasses
- Walrus operator
- Code quality tools:
- Formatting: Ruff
- Linting: PyLint and Ruff
- Type checking: MyPy
- Testing: pytest with plain functions and fixtures
- Inline code documentation:
- File headers should be short and concise:
```python
"""Integration for Peblar EV chargers."""
```
- Every method and function needs a docstring:
```python
async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool:
"""Set up Peblar from a config entry."""
...
```
- All code and comments and other text are written in American English
- Follow existing code style patterns as much as possible
- Core locations:
- Shared constants: `homeassistant/const.py`, use them instead of hardcoding
strings or creating duplicate integration constants.
- Integration files:
- Constants: `homeassistant/components/{domain}/const.py`
- Models: `homeassistant/components/{domain}/models.py`
- Coordinator: `homeassistant/components/{domain}/coordinator.py`
- Config flow: `homeassistant/components/{domain}/config_flow.py`
- Platform code: `homeassistant/components/{domain}/{platform}.py`
- All external I/O operations must be async
- Async patterns:
- Avoid sleeping in loops
- Avoid awaiting in loops, gather instead
- No blocking calls
- Polling:
- Follow update coordinator pattern, when possible
- Polling interval may not be configurable by the user
- For local network polling, the minimum interval is 5 seconds
- For cloud polling, the minimum interval is 60 seconds
- Error handling:
- Use specific exceptions from `homeassistant.exceptions`
- Setup failures:
- Temporary: Raise `ConfigEntryNotReady`
- Permanent: Use `ConfigEntryError`
- Logging:
- Message format:
- No periods at end
- No integration names or domains (added automatically)
- No sensitive data (keys, tokens, passwords), even when those are incorrect.
- Be very restrictive on the use of logging info messages, use debug for
anything which is not targeting the user.
- Use lazy logging (no f-strings):
```python
_LOGGER.debug("This is a log message with %s", variable)
```
- Entities:
- Ensure unique IDs for state persistence:
- Unique IDs should not contain values that are subject to user or network change.
- An ID needs to be unique per platform, not per integration.
- The ID does not have to contain the integration domain or platform.
- Acceptable examples:
- Serial number of a device
- MAC address of a device formatted using `homeassistant.helpers.device_registry.format_mac`
Do not obtain the MAC address through arp cache of local network access,
only use the MAC address provided by discovery or the device itself.
- Unique identifier that is physically printed on the device or burned into an EEPROM
- Not acceptable examples:
- IP Address
- Device name
- Hostname
- URL
- Email address
- Username
- For entities that are setup by a config entry, the config entry ID
can be used as a last resort if no other Unique ID is available.
For example: `f"{entry.entry_id}-battery"`
- If the state value is unknown, use `None`
- Do not use the `unavailable` string as a state value,
implement the `available()` property method instead
- Do not use the `unknown` string as a state value, use `None` instead
- Extra entity state attributes:
- The keys of all state attributes should always be present
- If the value is unknown, use `None`
- Provide descriptive state attributes
- Testing:
- Test location: `tests/components/{domain}/`
- Use pytest fixtures from `tests.common`
- Mock external dependencies
- Use snapshots for complex data
- Follow existing test patterns

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.6.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.6.2 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@v9 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@v9 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.6.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.3.0 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.4.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@2025.03.0 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.4.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@2025.03.0 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.8.2 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.4.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.4.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -351,7 +355,9 @@ 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 }}
for registry in "ghcr.io/home-assistant" "docker.io/homeassistant"
do
docker manifest create "${registry}/home-assistant:${tag_l}" \ docker manifest create "${registry}/home-assistant:${tag_l}" \
"${registry}/amd64-homeassistant:${tag_r}" \ "${registry}/amd64-homeassistant:${tag_r}" \
@@ -382,6 +388,8 @@ jobs:
docker manifest push --purge "${registry}/home-assistant:${tag_l}" docker manifest push --purge "${registry}/home-assistant:${tag_l}"
cosign sign --yes "${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
permissions:
contents: read
id-token: write
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.6.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
uses: actions/download-artifact@v4.3.0
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 build
python -m build
- name: Upload package to PyPI
uses: pypa/gh-action-pypi-publish@v1.12.4
with:
skip-existing: true
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@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.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 }}:/github/workspace ${{ env.HASSFEST_IMAGE_TAG }} --core-path=/github/workspace
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.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@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0
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.28.18 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.28.18 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.1.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.1.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.1.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.13" 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.6.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.13"
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.6.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"
@@ -76,52 +60,19 @@ jobs:
# Use C-Extension for SQLAlchemy # Use C-Extension for SQLAlchemy
echo "REQUIRE_SQLALCHEMY_CEXT=1" echo "REQUIRE_SQLALCHEMY_CEXT=1"
# Add additional pip wheel build constraints
echo "PIP_CONSTRAINT=build_constraints.txt"
) > .env_file ) > .env_file
- name: Write pip wheel build constraints
run: |
(
# ninja 1.11.1.2 + 1.11.1.3 seem to be broken on at least armhf
# this caused the numpy builds to fail
# https://github.com/scikit-build/ninja-python-distributions/issues/274
echo "ninja==1.11.1.1"
) > build_constraints.txt
- name: Upload env_file - name: Upload env_file
uses: actions/upload-artifact@v4.6.2 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 build_constraints
uses: actions/upload-artifact@v4.6.2
with:
name: build_constraints
path: ./build_constraints.txt
overwrite: true
- name: Upload requirements_diff - name: Upload requirements_diff
uses: actions/upload-artifact@v4.6.2 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.6.2
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)
@@ -131,43 +82,32 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
abi: ["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.3.0 uses: actions/download-artifact@v3
with: with:
name: env_file name: env_file
- name: Download build_constraints
uses: actions/download-artifact@v4.3.0
with:
name: build_constraints
- name: Download requirements_diff - name: Download requirements_diff
uses: actions/download-artifact@v4.3.0 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@2025.03.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-ng-dev" apk: "libffi-dev;openssl-dev;yaml-dev"
skip-binary: aiohttp;multidict;propcache;yarl;SQLAlchemy 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"
@@ -180,31 +120,67 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
abi: ["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.3.0 uses: actions/download-artifact@v3
with: with:
name: env_file name: env_file
- name: Download build_constraints
uses: actions/download-artifact@v4.3.0
with:
name: build_constraints
- name: Download requirements_diff - name: Download requirements_diff
uses: actions/download-artifact@v4.3.0 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.3.0 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: |
@@ -214,20 +190,60 @@ 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: Build wheels - name: Build wheels (old cython)
uses: home-assistant/wheels@2025.03.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-ng-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.txt" requirements: "requirements_old-cython.txt"
pip: "'cython<3'"
- name: Build wheels (part 1)
uses: home-assistant/wheels@2023.10.5
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
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"
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtaa"
- name: Build wheels (part 2)
uses: home-assistant/wheels@2023.10.5
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
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"
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtab"
- name: Build wheels (part 3)
uses: home-assistant/wheels@2023.10.5
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
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"
skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtac"

6
.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
@@ -69,7 +68,6 @@ test-reports/
test-results.xml test-results.xml
test-output.xml test-output.xml
pytest-*.txt pytest-*.txt
junit.xml
# Translations # Translations
*.mo *.mo
@@ -80,7 +78,6 @@ junit.xml
.pydevproject .pydevproject
.python-version .python-version
.tool-versions
# emacs auto backups # emacs auto backups
*~ *~
@@ -135,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,24 +1,24 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.0 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.4.1 rev: v2.2.2
hooks: hooks:
- id: codespell - id: codespell
args: args:
- --ignore-words-list=aiport,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: v5.0.0 rev: v4.4.0
hooks: hooks:
- id: check-executables-have-shebangs - id: check-executables-have-shebangs
stages: [manual] stages: [manual]
@@ -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,16 +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: [python]
require_serial: true require_serial: true
types_or: [python, pyi] files: ^(homeassistant|pylint)/.+\.py$
files: ^(homeassistant|pylint)/.+\.(py|pyi)$
- id: pylint - id: pylint
name: pylint name: pylint
entry: script/run-in-env.sh pylint --ignore-missing-annotations=y entry: script/run-in-env.sh pylint -j 0 --ignore-missing-annotations=y
language: script language: script
require_serial: true types: [python]
types_or: [python, pyi] files: ^homeassistant/.+\.py$
files: ^(homeassistant|tests)/.+\.(py|pyi)$
- 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
@@ -84,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/.+/(quality_scale)\.yaml|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
@@ -41,116 +40,61 @@ homeassistant.util.unit_system
# --- Add components below this line --- # --- Add components below this line ---
homeassistant.components homeassistant.components
homeassistant.components.abode.* homeassistant.components.abode.*
homeassistant.components.acaia.*
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.azure_storage.*
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.bosch_alarm.*
homeassistant.components.braviatv.* homeassistant.components.braviatv.*
homeassistant.components.bring.*
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.comelit.*
homeassistant.components.command_line.*
homeassistant.components.config.*
homeassistant.components.configurator.* homeassistant.components.configurator.*
homeassistant.components.cookidoo.*
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.*
@@ -161,37 +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.eheimdigital.*
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.*
@@ -199,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.*
@@ -207,44 +134,29 @@ 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_drive.*
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.*
homeassistant.components.guardian.* homeassistant.components.guardian.*
homeassistant.components.habitica.*
homeassistant.components.hardkernel.* homeassistant.components.hardkernel.*
homeassistant.components.hardware.* homeassistant.components.hardware.*
homeassistant.components.heos.*
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.home_connect.*
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.*
homeassistant.components.homeassistant_sky_connect.* homeassistant.components.homeassistant_sky_connect.*
homeassistant.components.homeassistant_yellow.* homeassistant.components.homeassistant_yellow.*
homeassistant.components.homee.*
homeassistant.components.homekit.* homeassistant.components.homekit.*
homeassistant.components.homekit_controller homeassistant.components.homekit_controller
homeassistant.components.homekit_controller.alarm_control_panel homeassistant.components.homekit_controller.alarm_control_panel
@@ -256,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.*
@@ -269,46 +178,31 @@ 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.immich.*
homeassistant.components.incomfort.*
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.iron_os.*
homeassistant.components.islamic_prayer_times.* homeassistant.components.islamic_prayer_times.*
homeassistant.components.isy994.* homeassistant.components.isy994.*
homeassistant.components.jellyfin.* 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.kulersky.*
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.letpot.*
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.*
@@ -318,203 +212,128 @@ homeassistant.components.logbook.*
homeassistant.components.logger.* homeassistant.components.logger.*
homeassistant.components.london_underground.* homeassistant.components.london_underground.*
homeassistant.components.lookin.* homeassistant.components.lookin.*
homeassistant.components.lovelace.*
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.mcp.*
homeassistant.components.mcp_server.*
homeassistant.components.mealie.*
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.miele.*
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.ntfy.*
homeassistant.components.number.* homeassistant.components.number.*
homeassistant.components.nut.* homeassistant.components.nut.*
homeassistant.components.ohme.*
homeassistant.components.onboarding.*
homeassistant.components.oncue.* homeassistant.components.oncue.*
homeassistant.components.onedrive.*
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.overseerr.*
homeassistant.components.p1_monitor.*
homeassistant.components.pandora.*
homeassistant.components.panel_custom.*
homeassistant.components.peblar.*
homeassistant.components.peco.* homeassistant.components.peco.*
homeassistant.components.pegel_online.*
homeassistant.components.persistent_notification.* homeassistant.components.persistent_notification.*
homeassistant.components.person.*
homeassistant.components.pi_hole.* homeassistant.components.pi_hole.*
homeassistant.components.ping.* homeassistant.components.ping.*
homeassistant.components.plugwise.* homeassistant.components.plugwise.*
homeassistant.components.powerfox.* 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.pyload.*
homeassistant.components.python_script.*
homeassistant.components.qbus.*
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.*
homeassistant.components.recollect_waste.* homeassistant.components.recollect_waste.*
homeassistant.components.recorder.* homeassistant.components.recorder.*
homeassistant.components.remember_the_milk.*
homeassistant.components.remote.* homeassistant.components.remote.*
homeassistant.components.remote_calendar.*
homeassistant.components.renault.* homeassistant.components.renault.*
homeassistant.components.reolink.*
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.russound_rio.* homeassistant.components.rtsp_to_webrtc.*
homeassistant.components.ruuvi_gateway.* homeassistant.components.ruuvi_gateway.*
homeassistant.components.ruuvitag_ble.* homeassistant.components.ruuvitag_ble.*
homeassistant.components.samsungtv.* homeassistant.components.samsungtv.*
homeassistant.components.scene.* homeassistant.components.scene.*
homeassistant.components.schedule.* homeassistant.components.schedule.*
homeassistant.components.schlage.*
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.sensorpush_cloud.*
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.smtp.*
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.stookwijzer.* 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.*
@@ -532,21 +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.vodafone_station.*
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.*
@@ -554,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.*

43
.vscode/launch.json vendored
View File

@@ -6,59 +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"],
"--picked"
],
},
{
"name": "Home Assistant: Debug Current Test File",
"type": "debugpy",
"request": "launch",
"module": "pytest",
"console": "integratedTerminal",
"args": ["-vv", "${file}"]
}, },
{ {
// 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}",
@@ -70,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}",

View File

@@ -1,19 +1,8 @@
{ {
// Please keep this file (mostly!) in sync with settings in home-assistant/.devcontainer/devcontainer.json // Please keep this file in sync with settings in home-assistant/.devcontainer/devcontainer.json
// Added --no-cov to work around TypeError: message must be set // Added --no-cov to work around TypeError: message must be set
// 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"
],
// This value differs between working with devcontainer and locally, therefor this value should NOT be in sync!
"url": "./script/json_schemas/manifest_schema.json"
}
]
} }

63
.vscode/tasks.json vendored
View File

@@ -4,7 +4,7 @@
{ {
"label": "Run Home Assistant Core", "label": "Run Home Assistant Core",
"type": "shell", "type": "shell",
"command": "${command:python.interpreterPath} -m homeassistant -c ./config", "command": "hass -c ./config",
"group": "test", "group": "test",
"presentation": { "presentation": {
"reveal": "always", "reveal": "always",
@@ -16,7 +16,7 @@
{ {
"label": "Pytest", "label": "Pytest",
"type": "shell", "type": "shell",
"command": "${command:python.interpreterPath} -m pytest --timeout=10 tests", "command": "python3 -m pytest --timeout=10 tests",
"dependsOn": ["Install all Test Requirements"], "dependsOn": ["Install all Test Requirements"],
"group": { "group": {
"kind": "test", "kind": "test",
@@ -31,7 +31,7 @@
{ {
"label": "Pytest (changed tests only)", "label": "Pytest (changed tests only)",
"type": "shell", "type": "shell",
"command": "${command:python.interpreterPath} -m pytest --timeout=10 --picked", "command": "python3 -m pytest --timeout=10 --picked",
"group": { "group": {
"kind": "test", "kind": "test",
"isDefault": true "isDefault": true
@@ -56,20 +56,6 @@
}, },
"problemMatcher": [] "problemMatcher": []
}, },
{
"label": "Pre-commit",
"type": "shell",
"command": "pre-commit run --show-diff-on-failure",
"group": {
"kind": "test",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{ {
"label": "Pylint", "label": "Pylint",
"type": "shell", "type": "shell",
@@ -89,24 +75,7 @@
"label": "Code Coverage", "label": "Code Coverage",
"detail": "Generate code coverage report for a given integration.", "detail": "Generate code coverage report for a given integration.",
"type": "shell", "type": "shell",
"command": "${command:python.interpreterPath} -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": {
"kind": "test",
"isDefault": true
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
},
{
"label": "Update syrupy snapshots",
"detail": "Update syrupy snapshots for a given integration.",
"type": "shell",
"command": "${command:python.interpreterPath} -m pytest ./tests/components/${input:integrationName} --snapshot-update",
"dependsOn": ["Compile English translations"],
"group": { "group": {
"kind": "test", "kind": "test",
"isDefault": true "isDefault": true
@@ -134,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
@@ -148,7 +117,7 @@
{ {
"label": "Install all Test Requirements", "label": "Install all Test Requirements",
"type": "shell", "type": "shell",
"command": "uv pip install -r requirements.txt -r requirements_test_all.txt", "command": "pip3 install -r requirements_test_all.txt",
"group": { "group": {
"kind": "build", "kind": "build",
"isDefault": true "isDefault": true
@@ -163,7 +132,7 @@
"label": "Compile English translations", "label": "Compile English translations",
"detail": "In order to test changes to translation files, the translation strings must be compiled into Home Assistant's translation directories.", "detail": "In order to test changes to translation files, the translation strings must be compiled into Home Assistant's translation directories.",
"type": "shell", "type": "shell",
"command": "${command:python.interpreterPath} -m script.translations develop --all", "command": "python3 -m script.translations develop --all",
"group": { "group": {
"kind": "build", "kind": "build",
"isDefault": true "isDefault": true
@@ -173,7 +142,7 @@
"label": "Run scaffold", "label": "Run scaffold",
"detail": "Add new functionality to a integration using a scaffold.", "detail": "Add new functionality to a integration using a scaffold.",
"type": "shell", "type": "shell",
"command": "${command:python.interpreterPath} -m script.scaffold ${input:scaffoldName} --integration ${input:integrationName}", "command": "python3 -m script.scaffold ${input:scaffoldName} --integration ${input:integrationName}",
"group": { "group": {
"kind": "build", "kind": "build",
"isDefault": true "isDefault": true
@@ -183,25 +152,11 @@
"label": "Create new integration", "label": "Create new integration",
"detail": "Use the scaffold to create a new integration.", "detail": "Use the scaffold to create a new integration.",
"type": "shell", "type": "shell",
"command": "${command:python.interpreterPath} -m script.scaffold integration", "command": "python3 -m script.scaffold integration",
"group": { "group": {
"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,63 +1,47 @@
# 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
# Home Assistant S6-Overlay
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.9/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
# Install uv
RUN pip3 install uv==0.7.1
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; \
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 -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
# Home Assistant S6-Overlay
COPY rootfs /
WORKDIR /config WORKDIR /config

View File

@@ -1,4 +1,4 @@
FROM mcr.microsoft.com/devcontainers/python:1-3.13 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:2025.05.0 aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.10.1
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.05.0 armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.10.1
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.05.0 armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.10.1
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.05.0 amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.10.1
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.05.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
@@ -19,4 +19,4 @@ labels:
org.opencontainers.image.authors: The Home Assistant Authors org.opencontainers.image.authors: The Home Assistant Authors
org.opencontainers.image.url: https://www.home-assistant.io/ org.opencontainers.image.url: https://www.home-assistant.io/
org.opencontainers.image.documentation: https://www.home-assistant.io/docs/ org.opencontainers.image.documentation: https://www.home-assistant.io/docs/
org.opencontainers.image.licenses: Apache-2.0 org.opencontainers.image.licenses: Apache License 2.0

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,8 +209,6 @@ 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
with suppress(FileNotFoundError):
if os.path.getsize(fault_file_name) == 0: if os.path.getsize(fault_file_name) == 0:
os.remove(fault_file_name) os.remove(fault_file_name)

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[Any]: ) -> 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(
{
attr_name: value
for attr_name, value in ( for attr_name, value in (
("is_owner", is_owner), ("is_owner", is_owner),
("is_active", is_active), ("is_active", is_active),
("local_only", local_only), ("local_only", local_only),
("system_generated", system_generated), ("system_generated", system_generated),
) ):
if value is not None if value is not None:
} kwargs[attr_name] = value
)
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:
"""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
@@ -18,7 +17,7 @@ from homeassistant.util.json import json_loads
JWT_TOKEN_CACHE_SIZE = 16 JWT_TOKEN_CACHE_SIZE = 16
MAX_TOKEN_SIZE = 8192 MAX_TOKEN_SIZE = 8192
_VERIFY_KEYS = ("signature", "exp", "nbf", "iat", "aud", "iss", "sub", "jti") _VERIFY_KEYS = ("signature", "exp", "nbf", "iat", "aud", "iss")
_VERIFY_OPTIONS: dict[str, Any] = {f"verify_{key}": True for key in _VERIFY_KEYS} | { _VERIFY_OPTIONS: dict[str, Any] = {f"verify_{key}": True for key in _VERIFY_KEYS} | {
"require": [] "require": []
@@ -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__)
@@ -71,7 +69,7 @@ class MultiFactorAuthModule:
"""Return a voluptuous schema to define mfa auth module's input.""" """Return a voluptuous schema to define mfa auth module's input."""
raise NotImplementedError raise NotImplementedError
async def async_setup_flow(self, user_id: str) -> SetupFlow[Any]: async def async_setup_flow(self, user_id: str) -> SetupFlow:
"""Return a data entry flow handler for setup module. """Return a data entry flow handler for setup module.
Mfa module should extend SetupFlow Mfa module should extend SetupFlow
@@ -95,16 +93,11 @@ class MultiFactorAuthModule:
raise NotImplementedError raise NotImplementedError
class SetupFlow[_MultiFactorAuthModuleT: MultiFactorAuthModule = MultiFactorAuthModule]( class SetupFlow(data_entry_flow.FlowHandler):
data_entry_flow.FlowHandler
):
"""Handler for the setup flow.""" """Handler for the setup flow."""
def __init__( def __init__(
self, self, auth_module: MultiFactorAuthModule, setup_schema: vol.Schema, user_id: str
auth_module: _MultiFactorAuthModuleT,
setup_schema: vol.Schema,
user_id: str,
) -> None: ) -> None:
"""Initialize the setup flow.""" """Initialize the setup flow."""
self._auth_module = auth_module self._auth_module = auth_module
@@ -155,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)
@@ -162,7 +161,7 @@ class NotifyAuthModule(MultiFactorAuthModule):
return sorted(unordered_services) return sorted(unordered_services)
async def async_setup_flow(self, user_id: str) -> NotifySetupFlow: async def async_setup_flow(self, user_id: str) -> SetupFlow:
"""Return a data entry flow handler for setup module. """Return a data entry flow handler for setup module.
Mfa module should extend SetupFlow Mfa module should extend SetupFlow
@@ -268,7 +267,7 @@ class NotifyAuthModule(MultiFactorAuthModule):
await self.hass.services.async_call("notify", notify_service, data) await self.hass.services.async_call("notify", notify_service, data)
class NotifySetupFlow(SetupFlow[NotifyAuthModule]): class NotifySetupFlow(SetupFlow):
"""Handler for the setup flow.""" """Handler for the setup flow."""
def __init__( def __init__(
@@ -280,6 +279,8 @@ class NotifySetupFlow(SetupFlow[NotifyAuthModule]):
) -> None: ) -> None:
"""Initialize the setup flow.""" """Initialize the setup flow."""
super().__init__(auth_module, setup_schema, user_id) super().__init__(auth_module, setup_schema, user_id)
# to fix typing complaint
self._auth_module: NotifyAuthModule = auth_module
self._available_notify_services = available_notify_services self._available_notify_services = available_notify_services
self._secret: str | None = None self._secret: str | None = None
self._count: int | None = None self._count: int | None = None

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
@@ -114,7 +113,7 @@ class TotpAuthModule(MultiFactorAuthModule):
self._users[user_id] = ota_secret # type: ignore[index] self._users[user_id] = ota_secret # type: ignore[index]
return ota_secret return ota_secret
async def async_setup_flow(self, user_id: str) -> TotpSetupFlow: async def async_setup_flow(self, user_id: str) -> SetupFlow:
"""Return a data entry flow handler for setup module. """Return a data entry flow handler for setup module.
Mfa module should extend SetupFlow Mfa module should extend SetupFlow
@@ -174,19 +173,20 @@ class TotpAuthModule(MultiFactorAuthModule):
return bool(pyotp.TOTP(ota_secret).verify(code, valid_window=1)) return bool(pyotp.TOTP(ota_secret).verify(code, valid_window=1))
class TotpSetupFlow(SetupFlow[TotpAuthModule]): class TotpSetupFlow(SetupFlow):
"""Handler for the setup flow.""" """Handler for the setup flow."""
_ota_secret: str
_url: str
_image: str
def __init__( def __init__(
self, auth_module: TotpAuthModule, setup_schema: vol.Schema, user: User self, auth_module: TotpAuthModule, setup_schema: vol.Schema, user: User
) -> None: ) -> None:
"""Initialize the setup flow.""" """Initialize the setup flow."""
super().__init__(auth_module, setup_schema, user.id) super().__init__(auth_module, setup_schema, user.id)
# to fix typing complaint
self._auth_module: TotpAuthModule = auth_module
self._user = user self._user = user
self._ota_secret: str = ""
self._url: str | None = None
self._image: str | None = None
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
@@ -213,11 +213,12 @@ class TotpSetupFlow(SetupFlow[TotpAuthModule]):
errors["base"] = "invalid_code" errors["base"] = "invalid_code"
else: else:
hass = self._auth_module.hass
( (
self._ota_secret, self._ota_secret,
self._url, self._url,
self._image, self._image,
) = await self._auth_module.hass.async_add_executor_job( ) = await hass.async_add_executor_job(
_generate_secret_and_qr_code, _generate_secret_and_qr_code,
str(self._user.name), str(self._user.name),
) )

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.api 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
@@ -17,12 +17,12 @@ POLICY_SCHEMA = vol.Schema({vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA})
__all__ = [ __all__ = [
"POLICY_SCHEMA", "POLICY_SCHEMA",
"AbstractPermissions",
"OwnerPermissions",
"PermissionLookup",
"PolicyPermissions",
"PolicyType",
"merge_policies", "merge_policies",
"PermissionLookup",
"PolicyType",
"AbstractPermissions",
"PolicyPermissions",
"OwnerPermissions",
] ]
@@ -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[Any]: 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,14 +181,10 @@ async def load_auth_provider_module(
return module return module
class LoginFlow[_AuthProviderT: AuthProvider = AuthProvider]( class LoginFlow(data_entry_flow.FlowHandler):
FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]],
):
"""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: _AuthProviderT) -> None:
"""Initialize the login flow.""" """Initialize the login flow."""
self._auth_provider = auth_provider self._auth_provider = auth_provider
self._auth_module_id: str | None = None self._auth_module_id: str | None = None
@@ -212,7 +197,7 @@ class LoginFlow[_AuthProviderT: AuthProvider = AuthProvider](
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.
@@ -222,7 +207,7 @@ class LoginFlow[_AuthProviderT: AuthProvider = AuthProvider](
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 = {}
@@ -247,7 +232,7 @@ class LoginFlow[_AuthProviderT: AuthProvider = AuthProvider](
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
@@ -297,6 +282,6 @@ class LoginFlow[_AuthProviderT: AuthProvider = AuthProvider](
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,19 +1,19 @@
"""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
from collections.abc import Mapping from collections.abc import Mapping
import logging import logging
import os import os
from typing import Any 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,9 +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( async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow:
self, context: AuthFlowContext | None
) -> CommandLineLoginFlow:
"""Return a flow to login.""" """Return a flow to login."""
return CommandLineLoginFlow(self) return CommandLineLoginFlow(self)
@@ -135,21 +133,21 @@ class CommandLineAuthProvider(AuthProvider):
) )
class CommandLineLoginFlow(LoginFlow[CommandLineAuthProvider]): class CommandLineLoginFlow(LoginFlow):
"""Handler for the login flow.""" """Handler for the login flow."""
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 = {}
if user_input is not None: if user_input is not None:
user_input["username"] = user_input["username"].strip() user_input["username"] = user_input["username"].strip()
try: try:
await self._auth_provider.async_validate_login( await cast(
user_input["username"], user_input["password"] CommandLineAuthProvider, self._auth_provider
) ).async_validate_login(user_input["username"], user_input["password"])
except InvalidAuthError: except InvalidAuthError:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"

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,50 +83,45 @@ 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
seen.add(folded)
# check if we have unstripped usernames
if username != username.strip():
self.is_legacy = True self.is_legacy = True
ir.async_create_issue(
self.hass, logging.getLogger(__name__).warning(
"auth", (
"homeassistant_provider_not_normalized_usernames", "Home Assistant auth provider is running in legacy mode "
breaks_in_ha_version="2026.7.0", "because we detected usernames that start or end in a "
is_fixable=False, "space. Please change the username: '%s'."
severity=ir.IssueSeverity.WARNING, ),
translation_key="homeassistant_provider_not_normalized_usernames", username,
translation_placeholders={
"usernames": f'- "{'"\n- "'.join(sorted(not_normalized_usernames))}"'
},
learn_more_url="homeassistant://config/users",
)
else:
self.is_legacy = False
ir.async_delete_issue(
self.hass, "auth", "homeassistant_provider_not_normalized_usernames"
) )
break
self._data = data
@property @property
def users(self) -> list[dict[str, str]]: def users(self) -> list[dict[str, str]]:
"""Return users.""" """Return users."""
@@ -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) -> HassLoginFlow: 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:
@@ -400,18 +316,18 @@ class HassAuthProvider(AuthProvider):
pass pass
class HassLoginFlow(LoginFlow[HassAuthProvider]): class HassLoginFlow(LoginFlow):
"""Handler for the login flow.""" """Handler for the login flow."""
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 = {}
if user_input is not None: if user_input is not None:
try: try:
await self._auth_provider.async_validate_login( await cast(HassAuthProvider, self._auth_provider).async_validate_login(
user_input["username"], user_input["password"] user_input["username"], user_input["password"]
) )
except InvalidAuth: except InvalidAuth:

View File

@@ -1,16 +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 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(
@@ -35,9 +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( async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow:
self, context: AuthFlowContext | None
) -> ExampleLoginFlow:
"""Return a flow to login.""" """Return a flow to login."""
return ExampleLoginFlow(self) return ExampleLoginFlow(self)
@@ -94,18 +93,18 @@ class ExampleAuthProvider(AuthProvider):
return UserMeta(name=name, is_active=True) return UserMeta(name=name, is_active=True)
class ExampleLoginFlow(LoginFlow[ExampleAuthProvider]): class ExampleLoginFlow(LoginFlow):
"""Handler for the login flow.""" """Handler for the login flow."""
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
if user_input is not None: if user_input is not None:
try: try:
self._auth_provider.async_validate_login( cast(ExampleAuthProvider, self._auth_provider).async_validate_login(
user_input["username"], user_input["password"] user_input["username"], user_input["password"]
) )
except InvalidAuthError: except InvalidAuthError:

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
from homeassistant.helpers import 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,9 +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( async def async_login_flow(self, context: dict[str, Any] | None) -> LoginFlow:
self, context: AuthFlowContext | None
) -> TrustedNetworksLoginFlow:
"""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"))
@@ -216,7 +208,7 @@ class TrustedNetworksAuthProvider(AuthProvider):
self.async_validate_access(ip_address(remote_ip)) self.async_validate_access(ip_address(remote_ip))
class TrustedNetworksLoginFlow(LoginFlow[TrustedNetworksAuthProvider]): class TrustedNetworksLoginFlow(LoginFlow):
"""Handler for the login flow.""" """Handler for the login flow."""
def __init__( def __init__(
@@ -234,10 +226,12 @@ class TrustedNetworksLoginFlow(LoginFlow[TrustedNetworksAuthProvider]):
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:
self._auth_provider.async_validate_access(self._ip_address) cast(
TrustedNetworksAuthProvider, self._auth_provider
).async_validate_access(self._ip_address)
except InvalidAuthError: except InvalidAuthError:
return self.async_abort(reason="not_allowed") return self.async_abort(reason="not_allowed")

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, class cached_property(Generic[_T]):
dir_with_deprecated_constants, """Backport of Python 3.12's cached_property.
Includes https://github.com/python/cpython/pull/101890/files
"""
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})."
) )
# cached_property deprecated as of 2024.5 use functools.cached_property instead. @overload
_DEPRECATED_cached_property = DeprecatedAlias( def __get__(self, instance: None, owner: type[Any] | None = None) -> Self:
_cached_property, "functools.cached_property", "2025.5" ...
)
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) @overload
__dir__ = partial( def __get__(self, instance: Any, owner: type[Any] | None = None) -> _T:
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] ...
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."
) )
__all__ = all_with_deprecated_constants(globals()) 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,213 +0,0 @@
"""Home Assistant module to handle restoring backups."""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
import hashlib
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"
RESTORE_BACKUP_RESULT_FILE = ".HA_RESTORE_RESULT"
KEEP_BACKUPS = ("backups",)
KEEP_DATABASE = (
"home-assistant_v2.db",
"home-assistant_v2.db-wal",
)
_LOGGER = logging.getLogger(__name__)
@dataclass
class RestoreBackupFileContent:
"""Definition for restore backup file content."""
backup_file_path: Path
password: str | None
remove_after_restore: bool
restore_database: bool
restore_homeassistant: bool
def password_to_key(password: str) -> bytes:
"""Generate a AES Key from password.
Matches the implementation in supervisor.backups.utils.password_to_key.
"""
key: bytes = password.encode()
for _ in range(100):
key = hashlib.sha256(key).digest()
return key[:16]
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"]),
password=instruction_content["password"],
remove_after_restore=instruction_content["remove_after_restore"],
restore_database=instruction_content["restore_database"],
restore_homeassistant=instruction_content["restore_homeassistant"],
)
except FileNotFoundError:
return None
except (KeyError, json.JSONDecodeError) as err:
_write_restore_result_file(config_dir, False, err)
return None
finally:
# Always remove the backup instruction file to prevent a boot loop
instruction_path.unlink(missing_ok=True)
def _clear_configuration_directory(config_dir: Path, keep: Iterable[str]) -> None:
"""Delete all files and directories in the config directory except entries in the keep list."""
keep_paths = [config_dir.joinpath(path) for path in keep]
entries_to_remove = sorted(
entry for entry in config_dir.iterdir() if entry not in keep_paths
)
for entry in entries_to_remove:
entrypath = config_dir.joinpath(entry)
if entrypath.is_file():
entrypath.unlink()
elif entrypath.is_dir():
shutil.rmtree(entrypath)
def _extract_backup(
config_dir: Path,
restore_content: RestoreBackupFileContent,
) -> None:
"""Extract the backup file to the config directory."""
with (
TemporaryDirectory() as tempdir,
securetar.SecureTarFile(
restore_content.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"],
key=password_to_key(restore_content.password)
if restore_content.password is not None
else None,
mode="r",
) as istf:
istf.extractall(
path=Path(tempdir, "homeassistant"),
members=securetar.secure_path(istf),
filter="fully_trusted",
)
if restore_content.restore_homeassistant:
keep = list(KEEP_BACKUPS)
if not restore_content.restore_database:
keep.extend(KEEP_DATABASE)
_clear_configuration_directory(config_dir, keep)
shutil.copytree(
Path(tempdir, "homeassistant", "data"),
config_dir,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns(*(keep)),
ignore_dangling_symlinks=True,
)
elif restore_content.restore_database:
for entry in KEEP_DATABASE:
entrypath = config_dir / entry
if entrypath.is_file():
entrypath.unlink()
elif entrypath.is_dir():
shutil.rmtree(entrypath)
for entry in KEEP_DATABASE:
shutil.copy(
Path(tempdir, "homeassistant", "data", entry),
config_dir,
)
def _write_restore_result_file(
config_dir: Path, success: bool, error: Exception | None
) -> None:
"""Write the restore result file."""
result_path = config_dir.joinpath(RESTORE_BACKUP_RESULT_FILE)
result_path.write_text(
json.dumps(
{
"success": success,
"error": str(error) if error else None,
"error_type": str(type(error).__name__) if error else None,
}
),
encoding="utf-8",
)
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=config_dir,
restore_content=restore_content,
)
except FileNotFoundError as err:
file_not_found = ValueError(f"Backup file {backup_file_path} does not exist")
_write_restore_result_file(config_dir, False, file_not_found)
raise file_not_found from err
except Exception as err:
_write_restore_result_file(config_dir, False, err)
raise
else:
_write_restore_result_file(config_dir, True, None)
if restore_content.remove_after_restore:
backup_file_path.unlink(missing_ok=True)
_LOGGER.info("Restore complete, restarting")
return True

View File

@@ -1,267 +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])
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
def _check_load_verify_locations_call_allowed(mapped_args: dict[str, Any]) -> bool:
# If only cadata is passed, we can ignore it
kwargs = mapped_args.get("kwargs")
return bool(kwargs and len(kwargs) == 1 and "cadata" in kwargs)
@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=_check_load_verify_locations_call_allowed,
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=SSLContext.set_default_verify_paths,
object=SSLContext,
function="set_default_verify_paths",
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()
for blocking_call in _BLOCKING_CALLS:
if _IN_TESTS and blocking_call.skip_for_tests:
continue
protected_function = protect_loop(
blocking_call.original_func,
strict=blocking_call.strict,
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) # Prevent sleeping in event loop. Non-strict since 2022.02
time.sleep = protect_loop(time.sleep, strict=False)
# Currently disabled. pytz doing I/O when getting timezone.
# Prevent files being opened inside the event loop
# builtins.open = protect_loop(builtins.open)

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,5 @@
{ {
"domain": "amazon", "domain": "amazon",
"name": "Amazon", "name": "Amazon",
"integrations": [ "integrations": ["alexa", "amazon_polly", "aws", "fire_tv", "route53"]
"alexa",
"amazon_polly",
"aws",
"aws_s3",
"fire_tv",
"route53"
]
} }

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

@@ -1,5 +0,0 @@
{
"domain": "bosch",
"name": "Bosch",
"integrations": ["bosch_alarm", "bosch_shc", "home_connect"]
}

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": "eve",
"name": "Eve",
"iot_standards": ["matter"]
}

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,12 +5,10 @@
"google_assistant", "google_assistant",
"google_assistant_sdk", "google_assistant_sdk",
"google_cloud", "google_cloud",
"google_drive", "google_domains",
"google_gemini",
"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

@@ -2,17 +2,14 @@
"domain": "microsoft", "domain": "microsoft",
"name": "Microsoft", "name": "Microsoft",
"integrations": [ "integrations": [
"azure_data_explorer",
"azure_devops", "azure_devops",
"azure_event_hub", "azure_event_hub",
"azure_service_bus", "azure_service_bus",
"azure_storage",
"microsoft_face_detect", "microsoft_face_detect",
"microsoft_face_identify", "microsoft_face_identify",
"microsoft_face", "microsoft_face",
"microsoft", "microsoft",
"msteams", "msteams",
"onedrive",
"xbox" "xbox"
] ]
} }

View File

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

View File

@@ -1,6 +0,0 @@
{
"domain": "nuki",
"name": "Nuki",
"integrations": ["nuki"],
"iot_standards": ["matter"]
}

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": "sensorpush",
"name": "SensorPush",
"integrations": ["sensorpush", "sensorpush_cloud"]
}

View File

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

View File

@@ -1,5 +0,0 @@
{
"domain": "slide",
"name": "Slide",
"integrations": ["slide", "slide_local"]
}

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,27 +1,27 @@
"""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 AddConfigEntryEntitiesCallback 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(
hass: HomeAssistant, hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Abode alarm control panel device.""" """Set up Abode alarm control panel device."""
data: AbodeSystem = hass.data[DOMAIN] data: AbodeSystem = hass.data[DOMAIN]
@@ -30,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,
@@ -12,28 +12,25 @@ from homeassistant.components.binary_sensor import (
) )
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 AddConfigEntryEntitiesCallback 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(
hass: HomeAssistant, hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Abode binary sensor devices.""" """Set up Abode binary sensor devices."""
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(
@@ -46,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
@@ -15,27 +14,24 @@ from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Event, HomeAssistant from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 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)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Abode camera devices.""" """Set up Abode camera devices."""
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)
) )
@@ -45,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,8 +118,11 @@ 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():
return self.async_abort(reason="single_instance_allowed")
if user_input is None: if user_input is None:
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=vol.Schema(self.data_schema) step_id="user", data_schema=vol.Schema(self.data_schema)
@@ -124,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(
@@ -135,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]
@@ -145,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,37 +1,34 @@
"""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 AddConfigEntryEntitiesCallback 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(
hass: HomeAssistant, hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Abode cover devices.""" """Set up Abode cover devices."""
data: AbodeSystem = hass.data[DOMAIN] data: AbodeSystem = hass.data[DOMAIN]
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

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