mirror of
https://github.com/home-assistant/core.git
synced 2025-09-23 11:59:37 +00:00
Compare commits
1 Commits
hassio-spl
...
bump-openc
Author | SHA1 | Date | |
---|---|---|---|
![]() |
628cc5dccb |
@@ -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
|
||||||
|
1744
.coveragerc
Normal file
1744
.coveragerc
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,22 +2,12 @@
|
|||||||
"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"
|
|
||||||
},
|
|
||||||
"features": {
|
|
||||||
"ghcr.io/devcontainers/features/github-cli:1": {}
|
|
||||||
},
|
|
||||||
// Port 5683 udp is used by Shelly integration
|
// Port 5683 udp is used by Shelly integration
|
||||||
"appPort": ["8123:8123", "5683:5683/udp"],
|
"appPort": ["8123:8123", "5683:5683/udp"],
|
||||||
"runArgs": [
|
"runArgs": ["-e", "GIT_EDITOR=code --wait"],
|
||||||
"-e",
|
|
||||||
"GIT_EDITOR=code --wait",
|
|
||||||
"--security-opt",
|
|
||||||
"label=disable"
|
|
||||||
],
|
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
@@ -27,17 +17,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 +43,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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -7,7 +7,6 @@ docs
|
|||||||
# Development
|
# Development
|
||||||
.devcontainer
|
.devcontainer
|
||||||
.vscode
|
.vscode
|
||||||
.tool-versions
|
|
||||||
|
|
||||||
# Test related files
|
# Test related files
|
||||||
tests
|
tests
|
||||||
|
@@ -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
11
.gitattributes
vendored
@@ -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
3
.github/FUNDING.yml
vendored
@@ -1 +1,2 @@
|
|||||||
custom: https://www.openhomefoundation.org
|
custom: https://www.nabucasa.com
|
||||||
|
github: balloob
|
||||||
|
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -6,9 +6,9 @@ body:
|
|||||||
value: |
|
value: |
|
||||||
This issue form is for reporting bugs only!
|
This issue form is for reporting bugs only!
|
||||||
|
|
||||||
If you have a feature or enhancement request, please [request them here instead][fr].
|
If you have a feature or enhancement request, please use the [feature request][fr] section of our [Community Forum][fr].
|
||||||
|
|
||||||
[fr]: https://github.com/orgs/home-assistant/discussions
|
[fr]: https://community.home-assistant.io/c/feature-requests
|
||||||
- type: textarea
|
- type: textarea
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -10,8 +10,8 @@ contact_links:
|
|||||||
url: https://www.home-assistant.io/help
|
url: https://www.home-assistant.io/help
|
||||||
about: We use GitHub for tracking bugs, check our website for resources on getting help.
|
about: We use GitHub for tracking bugs, check our website for resources on getting help.
|
||||||
- name: Feature Request
|
- name: Feature Request
|
||||||
url: https://github.com/orgs/home-assistant/discussions
|
url: https://community.home-assistant.io/c/feature-requests
|
||||||
about: Please use this link to request new features or enhancements to existing features.
|
about: Please use our Community Forum for making feature requests.
|
||||||
- name: I'm unsure where to go
|
- name: I'm unsure where to go
|
||||||
url: https://www.home-assistant.io/join-chat
|
url: https://www.home-assistant.io/join-chat
|
||||||
about: If you are unsure where to go, then joining our chat is recommended; Just ask!
|
about: If you are unsure where to go, then joining our chat is recommended; Just ask!
|
||||||
|
3
.github/PULL_REQUEST_TEMPLATE.md
vendored
3
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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.
|
||||||
|
BIN
.github/assets/screenshot-integrations.png
vendored
BIN
.github/assets/screenshot-integrations.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 65 KiB |
100
.github/copilot-instructions.md
vendored
100
.github/copilot-instructions.md
vendored
@@ -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
|
|
293
.github/workflows/builder.yml
vendored
293
.github/workflows/builder.yml
vendored
@@ -10,10 +10,8 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
BUILD_TYPE: core
|
BUILD_TYPE: core
|
||||||
DEFAULT_PYTHON: "3.13"
|
DEFAULT_PYTHON: "3.12"
|
||||||
PIP_TIMEOUT: 60
|
PIP_TIMEOUT: 60
|
||||||
UV_HTTP_TIMEOUT: 60
|
|
||||||
UV_SYSTEM_PYTHON: "true"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
init:
|
init:
|
||||||
@@ -27,12 +25,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.2
|
||||||
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@v5.0.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
|
|
||||||
@@ -51,29 +49,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.2
|
||||||
|
|
||||||
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
|
uses: actions/setup-python@v5.0.0
|
||||||
|
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 +95,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.2
|
||||||
|
|
||||||
- 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@v11
|
uses: dawidd6/action-download-artifact@v3.1.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||||
repo: home-assistant/frontend
|
repo: home-assistant/frontend
|
||||||
@@ -105,10 +114,10 @@ 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@v11
|
uses: dawidd6/action-download-artifact@v3.1.2
|
||||||
with:
|
with:
|
||||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||||
repo: OHF-Voice/intents-package
|
repo: home-assistant/intents-package
|
||||||
branch: main
|
branch: main
|
||||||
workflow: nightly.yaml
|
workflow: nightly.yaml
|
||||||
workflow_conclusion: success
|
workflow_conclusion: success
|
||||||
@@ -116,20 +125,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@v5.0.0
|
||||||
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 +147,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 +165,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 +180,19 @@ 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: Adjustments for 64-bit
|
||||||
uses: actions/download-artifact@v4.3.0
|
if: matrix.arch == 'amd64' || matrix.arch == 'aarch64'
|
||||||
with:
|
|
||||||
name: translations
|
|
||||||
|
|
||||||
- name: Extract translations
|
|
||||||
run: |
|
run: |
|
||||||
tar xvf translations.tar.gz
|
# Some speedups are only available on 64-bit, and since
|
||||||
rm translations.tar.gz
|
# we build 32bit images on 64bit hosts, we only enable
|
||||||
|
# the speed ups on 64bit since the wheels for 32bit
|
||||||
|
# are not available.
|
||||||
|
sed -i "s|aiohttp-zlib-ng|aiohttp-zlib-ng\[isal\]|g" requirements_all.txt
|
||||||
|
|
||||||
|
- name: Download Translations
|
||||||
|
run: python3 -m script.translations download
|
||||||
|
env:
|
||||||
|
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||||
|
|
||||||
- name: Write meta info file
|
- name: Write meta info file
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -190,14 +200,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@2024.01.0
|
||||||
with:
|
with:
|
||||||
args: |
|
args: |
|
||||||
$BUILD_ARGS \
|
$BUILD_ARGS \
|
||||||
@@ -206,6 +216,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'
|
||||||
@@ -242,7 +263,7 @@ jobs:
|
|||||||
- green
|
- green
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the repository
|
- name: Checkout the repository
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@v4.1.2
|
||||||
|
|
||||||
- name: Set build additional args
|
- name: Set build additional args
|
||||||
run: |
|
run: |
|
||||||
@@ -256,14 +277,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@2024.01.0
|
||||||
with:
|
with:
|
||||||
args: |
|
args: |
|
||||||
$BUILD_ARGS \
|
$BUILD_ARGS \
|
||||||
@@ -279,7 +300,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.2
|
||||||
|
|
||||||
- name: Initialize git
|
- name: Initialize git
|
||||||
uses: home-assistant/actions/helpers/git-init@master
|
uses: home-assistant/actions/helpers/git-init@master
|
||||||
@@ -315,29 +336,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.2
|
||||||
|
|
||||||
- name: Install Cosign
|
- name: Install Cosign
|
||||||
uses: sigstore/cosign-installer@v3.9.1
|
uses: sigstore/cosign-installer@v3.4.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,37 +366,41 @@ jobs:
|
|||||||
function create_manifest() {
|
function create_manifest() {
|
||||||
local tag_l=${1}
|
local tag_l=${1}
|
||||||
local tag_r=${2}
|
local tag_r=${2}
|
||||||
local registry=${{ matrix.registry }}
|
|
||||||
|
|
||||||
docker manifest create "${registry}/home-assistant:${tag_l}" \
|
for registry in "ghcr.io/home-assistant" "docker.io/homeassistant"
|
||||||
"${registry}/amd64-homeassistant:${tag_r}" \
|
do
|
||||||
"${registry}/i386-homeassistant:${tag_r}" \
|
|
||||||
"${registry}/armhf-homeassistant:${tag_r}" \
|
|
||||||
"${registry}/armv7-homeassistant:${tag_r}" \
|
|
||||||
"${registry}/aarch64-homeassistant:${tag_r}"
|
|
||||||
|
|
||||||
docker manifest annotate "${registry}/home-assistant:${tag_l}" \
|
docker manifest create "${registry}/home-assistant:${tag_l}" \
|
||||||
"${registry}/amd64-homeassistant:${tag_r}" \
|
"${registry}/amd64-homeassistant:${tag_r}" \
|
||||||
--os linux --arch amd64
|
"${registry}/i386-homeassistant:${tag_r}" \
|
||||||
|
"${registry}/armhf-homeassistant:${tag_r}" \
|
||||||
|
"${registry}/armv7-homeassistant:${tag_r}" \
|
||||||
|
"${registry}/aarch64-homeassistant:${tag_r}"
|
||||||
|
|
||||||
docker manifest annotate "${registry}/home-assistant:${tag_l}" \
|
docker manifest annotate "${registry}/home-assistant:${tag_l}" \
|
||||||
"${registry}/i386-homeassistant:${tag_r}" \
|
"${registry}/amd64-homeassistant:${tag_r}" \
|
||||||
--os linux --arch 386
|
--os linux --arch amd64
|
||||||
|
|
||||||
docker manifest annotate "${registry}/home-assistant:${tag_l}" \
|
docker manifest annotate "${registry}/home-assistant:${tag_l}" \
|
||||||
"${registry}/armhf-homeassistant:${tag_r}" \
|
"${registry}/i386-homeassistant:${tag_r}" \
|
||||||
--os linux --arch arm --variant=v6
|
--os linux --arch 386
|
||||||
|
|
||||||
docker manifest annotate "${registry}/home-assistant:${tag_l}" \
|
docker manifest annotate "${registry}/home-assistant:${tag_l}" \
|
||||||
"${registry}/armv7-homeassistant:${tag_r}" \
|
"${registry}/armhf-homeassistant:${tag_r}" \
|
||||||
--os linux --arch arm --variant=v7
|
--os linux --arch arm --variant=v6
|
||||||
|
|
||||||
docker manifest annotate "${registry}/home-assistant:${tag_l}" \
|
docker manifest annotate "${registry}/home-assistant:${tag_l}" \
|
||||||
"${registry}/aarch64-homeassistant:${tag_r}" \
|
"${registry}/armv7-homeassistant:${tag_r}" \
|
||||||
--os linux --arch arm64 --variant=v8
|
--os linux --arch arm --variant=v7
|
||||||
|
|
||||||
docker manifest push --purge "${registry}/home-assistant:${tag_l}"
|
docker manifest annotate "${registry}/home-assistant:${tag_l}" \
|
||||||
cosign sign --yes "${registry}/home-assistant:${tag_l}"
|
"${registry}/aarch64-homeassistant:${tag_r}" \
|
||||||
|
--os linux --arch arm64 --variant=v8
|
||||||
|
|
||||||
|
docker manifest push --purge "${registry}/home-assistant:${tag_l}"
|
||||||
|
cosign sign --yes "${registry}/home-assistant:${tag_l}"
|
||||||
|
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
function validate_image() {
|
function validate_image() {
|
||||||
@@ -414,14 +433,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 +459,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@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.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@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.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@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
|
|
||||||
with:
|
|
||||||
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
|
|
||||||
subject-digest: ${{ steps.push.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
|
815
.github/workflows/ci.yaml
vendored
815
.github/workflows/ci.yaml
vendored
File diff suppressed because it is too large
Load Diff
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -21,14 +21,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.2
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3.29.0
|
uses: github/codeql-action/init@v3.24.7
|
||||||
with:
|
with:
|
||||||
languages: python
|
languages: python
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3.29.0
|
uses: github/codeql-action/analyze@v3.24.7
|
||||||
with:
|
with:
|
||||||
category: "/language:python"
|
category: "/language:python"
|
||||||
|
385
.github/workflows/detect-duplicate-issues.yml
vendored
385
.github/workflows/detect-duplicate-issues.yml
vendored
@@ -1,385 +0,0 @@
|
|||||||
name: Auto-detect duplicate issues
|
|
||||||
|
|
||||||
# yamllint disable-line rule:truthy
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: [labeled]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
models: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
detect-duplicates:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check if integration label was added and extract details
|
|
||||||
id: extract
|
|
||||||
uses: actions/github-script@v7.0.1
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
// Debug: Log the event payload
|
|
||||||
console.log('Event name:', context.eventName);
|
|
||||||
console.log('Event action:', context.payload.action);
|
|
||||||
console.log('Event payload keys:', Object.keys(context.payload));
|
|
||||||
|
|
||||||
// Check the specific label that was added
|
|
||||||
const addedLabel = context.payload.label;
|
|
||||||
if (!addedLabel) {
|
|
||||||
console.log('No label found in labeled event payload');
|
|
||||||
core.setOutput('should_continue', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Label added: ${addedLabel.name}`);
|
|
||||||
|
|
||||||
if (!addedLabel.name.startsWith('integration:')) {
|
|
||||||
console.log('Added label is not an integration label, skipping duplicate detection');
|
|
||||||
core.setOutput('should_continue', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Integration label added: ${addedLabel.name}`);
|
|
||||||
|
|
||||||
let currentIssue;
|
|
||||||
let integrationLabels = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const issue = await github.rest.issues.get({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.payload.issue.number
|
|
||||||
});
|
|
||||||
|
|
||||||
currentIssue = issue.data;
|
|
||||||
|
|
||||||
// Check if potential-duplicate label already exists
|
|
||||||
const hasPotentialDuplicateLabel = currentIssue.labels
|
|
||||||
.some(label => label.name === 'potential-duplicate');
|
|
||||||
|
|
||||||
if (hasPotentialDuplicateLabel) {
|
|
||||||
console.log('Issue already has potential-duplicate label, skipping duplicate detection');
|
|
||||||
core.setOutput('should_continue', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
integrationLabels = currentIssue.labels
|
|
||||||
.filter(label => label.name.startsWith('integration:'))
|
|
||||||
.map(label => label.name);
|
|
||||||
} catch (error) {
|
|
||||||
core.error(`Failed to fetch issue #${context.payload.issue.number}:`, error.message);
|
|
||||||
core.setOutput('should_continue', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we've already posted a duplicate detection comment recently
|
|
||||||
let comments;
|
|
||||||
try {
|
|
||||||
comments = await github.rest.issues.listComments({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.payload.issue.number,
|
|
||||||
per_page: 10
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
core.error('Failed to fetch comments:', error.message);
|
|
||||||
// Continue anyway, worst case we might post a duplicate comment
|
|
||||||
comments = { data: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we've already posted a duplicate detection comment
|
|
||||||
const recentDuplicateComment = comments.data.find(comment =>
|
|
||||||
comment.user && comment.user.login === 'github-actions[bot]' &&
|
|
||||||
comment.body.includes('<!-- workflow: detect-duplicate-issues -->')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (recentDuplicateComment) {
|
|
||||||
console.log('Already posted duplicate detection comment, skipping');
|
|
||||||
core.setOutput('should_continue', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
core.setOutput('should_continue', 'true');
|
|
||||||
core.setOutput('current_number', currentIssue.number);
|
|
||||||
core.setOutput('current_title', currentIssue.title);
|
|
||||||
core.setOutput('current_body', currentIssue.body);
|
|
||||||
core.setOutput('current_url', currentIssue.html_url);
|
|
||||||
core.setOutput('integration_labels', JSON.stringify(integrationLabels));
|
|
||||||
|
|
||||||
console.log(`Current issue: #${currentIssue.number}`);
|
|
||||||
console.log(`Integration labels: ${integrationLabels.join(', ')}`);
|
|
||||||
|
|
||||||
- name: Fetch similar issues
|
|
||||||
id: fetch_similar
|
|
||||||
if: steps.extract.outputs.should_continue == 'true'
|
|
||||||
uses: actions/github-script@v7.0.1
|
|
||||||
env:
|
|
||||||
INTEGRATION_LABELS: ${{ steps.extract.outputs.integration_labels }}
|
|
||||||
CURRENT_NUMBER: ${{ steps.extract.outputs.current_number }}
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const integrationLabels = JSON.parse(process.env.INTEGRATION_LABELS);
|
|
||||||
const currentNumber = parseInt(process.env.CURRENT_NUMBER);
|
|
||||||
|
|
||||||
if (integrationLabels.length === 0) {
|
|
||||||
console.log('No integration labels found, skipping duplicate detection');
|
|
||||||
core.setOutput('has_similar', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use GitHub search API to find issues with matching integration labels
|
|
||||||
console.log(`Searching for issues with integration labels: ${integrationLabels.join(', ')}`);
|
|
||||||
|
|
||||||
// Build search query for issues with any of the current integration labels
|
|
||||||
const labelQueries = integrationLabels.map(label => `label:"${label}"`);
|
|
||||||
|
|
||||||
// Calculate date 6 months ago
|
|
||||||
const sixMonthsAgo = new Date();
|
|
||||||
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
|
||||||
const dateFilter = `created:>=${sixMonthsAgo.toISOString().split('T')[0]}`;
|
|
||||||
|
|
||||||
let searchQuery;
|
|
||||||
|
|
||||||
if (labelQueries.length === 1) {
|
|
||||||
searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue ${labelQueries[0]} ${dateFilter}`;
|
|
||||||
} else {
|
|
||||||
searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue (${labelQueries.join(' OR ')}) ${dateFilter}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Search query: ${searchQuery}`);
|
|
||||||
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
result = await github.rest.search.issuesAndPullRequests({
|
|
||||||
q: searchQuery,
|
|
||||||
per_page: 15,
|
|
||||||
sort: 'updated',
|
|
||||||
order: 'desc'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
core.error('Failed to search for similar issues:', error.message);
|
|
||||||
if (error.status === 403 && error.message.includes('rate limit')) {
|
|
||||||
core.error('GitHub API rate limit exceeded');
|
|
||||||
}
|
|
||||||
core.setOutput('has_similar', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out the current issue, pull requests, and newer issues (higher numbers)
|
|
||||||
const similarIssues = result.data.items
|
|
||||||
.filter(item =>
|
|
||||||
item.number !== currentNumber &&
|
|
||||||
!item.pull_request &&
|
|
||||||
item.number < currentNumber // Only include older issues (lower numbers)
|
|
||||||
)
|
|
||||||
.map(item => ({
|
|
||||||
number: item.number,
|
|
||||||
title: item.title,
|
|
||||||
body: item.body,
|
|
||||||
url: item.html_url,
|
|
||||||
state: item.state,
|
|
||||||
createdAt: item.created_at,
|
|
||||||
updatedAt: item.updated_at,
|
|
||||||
comments: item.comments,
|
|
||||||
labels: item.labels.map(l => l.name)
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log(`Found ${similarIssues.length} issues with matching integration labels`);
|
|
||||||
console.log('Raw similar issues:', JSON.stringify(similarIssues.slice(0, 3), null, 2));
|
|
||||||
|
|
||||||
if (similarIssues.length === 0) {
|
|
||||||
console.log('No similar issues found, setting has_similar to false');
|
|
||||||
core.setOutput('has_similar', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Similar issues found, setting has_similar to true');
|
|
||||||
core.setOutput('has_similar', 'true');
|
|
||||||
|
|
||||||
// Clean the issue data to prevent JSON parsing issues
|
|
||||||
const cleanedIssues = similarIssues.slice(0, 15).map(item => {
|
|
||||||
// Handle body with improved truncation and null handling
|
|
||||||
let cleanBody = '';
|
|
||||||
if (item.body && typeof item.body === 'string') {
|
|
||||||
// Remove control characters
|
|
||||||
const cleaned = item.body.replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
|
|
||||||
// Truncate to 1000 characters and add ellipsis if needed
|
|
||||||
cleanBody = cleaned.length > 1000
|
|
||||||
? cleaned.substring(0, 1000) + '...'
|
|
||||||
: cleaned;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
number: item.number,
|
|
||||||
title: item.title.replace(/[\u0000-\u001F\u007F-\u009F]/g, ''), // Remove control characters
|
|
||||||
body: cleanBody,
|
|
||||||
url: item.url,
|
|
||||||
state: item.state,
|
|
||||||
createdAt: item.createdAt,
|
|
||||||
updatedAt: item.updatedAt,
|
|
||||||
comments: item.comments,
|
|
||||||
labels: item.labels
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Cleaned issues count: ${cleanedIssues.length}`);
|
|
||||||
console.log('First cleaned issue:', JSON.stringify(cleanedIssues[0], null, 2));
|
|
||||||
|
|
||||||
core.setOutput('similar_issues', JSON.stringify(cleanedIssues));
|
|
||||||
|
|
||||||
- name: Detect duplicates using AI
|
|
||||||
id: ai_detection
|
|
||||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
|
||||||
uses: actions/ai-inference@v1.1.0
|
|
||||||
with:
|
|
||||||
model: openai/gpt-4o
|
|
||||||
system-prompt: |
|
|
||||||
You are a Home Assistant issue duplicate detector. Your task is to identify TRUE DUPLICATES - issues that report the EXACT SAME problem, not just similar or related issues.
|
|
||||||
|
|
||||||
CRITICAL: An issue is ONLY a duplicate if:
|
|
||||||
- It describes the SAME problem with the SAME root cause
|
|
||||||
- Issues about the same integration but different problems are NOT duplicates
|
|
||||||
- Issues with similar symptoms but different causes are NOT duplicates
|
|
||||||
|
|
||||||
Important considerations:
|
|
||||||
- Open issues are more relevant than closed ones for duplicate detection
|
|
||||||
- Recently updated issues may indicate ongoing work or discussion
|
|
||||||
- Issues with more comments are generally more relevant and active
|
|
||||||
- Older closed issues might be resolved differently than newer approaches
|
|
||||||
- Consider the time between issues - very old issues may have different contexts
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
1. ONLY mark as duplicate if the issues describe IDENTICAL problems
|
|
||||||
2. Look for issues that report the same problem or request the same functionality
|
|
||||||
3. Different error messages = NOT a duplicate (even if same integration)
|
|
||||||
4. For CLOSED issues, only mark as duplicate if they describe the EXACT same problem
|
|
||||||
5. For OPEN issues, use a lower threshold (90%+ similarity)
|
|
||||||
6. Prioritize issues with higher comment counts as they indicate more activity/relevance
|
|
||||||
7. When in doubt, do NOT mark as duplicate
|
|
||||||
8. Return ONLY a JSON array of issue numbers that are duplicates
|
|
||||||
9. If no duplicates are found, return an empty array: []
|
|
||||||
10. Maximum 5 potential duplicates, prioritize open issues with comments
|
|
||||||
11. Consider the age of issues - prefer recent duplicates over very old ones
|
|
||||||
|
|
||||||
Example response format:
|
|
||||||
[1234, 5678, 9012]
|
|
||||||
|
|
||||||
prompt: |
|
|
||||||
Current issue (just created):
|
|
||||||
Title: ${{ steps.extract.outputs.current_title }}
|
|
||||||
Body: ${{ steps.extract.outputs.current_body }}
|
|
||||||
|
|
||||||
Other issues to compare against (each includes state, creation date, last update, and comment count):
|
|
||||||
${{ steps.fetch_similar.outputs.similar_issues }}
|
|
||||||
|
|
||||||
Analyze these issues and identify which ones describe IDENTICAL problems and thus are duplicates of the current issue. When sorting them, consider their state (open/closed), how recently they were updated, and their comment count (higher = more relevant).
|
|
||||||
|
|
||||||
max-tokens: 100
|
|
||||||
|
|
||||||
- name: Post duplicate detection results
|
|
||||||
id: post_results
|
|
||||||
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
|
|
||||||
uses: actions/github-script@v7.0.1
|
|
||||||
env:
|
|
||||||
AI_RESPONSE: ${{ steps.ai_detection.outputs.response }}
|
|
||||||
SIMILAR_ISSUES: ${{ steps.fetch_similar.outputs.similar_issues }}
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const aiResponse = process.env.AI_RESPONSE;
|
|
||||||
|
|
||||||
console.log('Raw AI response:', JSON.stringify(aiResponse));
|
|
||||||
|
|
||||||
let duplicateNumbers = [];
|
|
||||||
try {
|
|
||||||
// Clean the response of any potential control characters
|
|
||||||
const cleanResponse = aiResponse.trim().replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
|
|
||||||
console.log('Cleaned AI response:', cleanResponse);
|
|
||||||
|
|
||||||
duplicateNumbers = JSON.parse(cleanResponse);
|
|
||||||
|
|
||||||
// Ensure it's an array and contains only numbers
|
|
||||||
if (!Array.isArray(duplicateNumbers)) {
|
|
||||||
console.log('AI response is not an array, trying to extract numbers');
|
|
||||||
const numberMatches = cleanResponse.match(/\d+/g);
|
|
||||||
duplicateNumbers = numberMatches ? numberMatches.map(n => parseInt(n)) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter to only valid numbers
|
|
||||||
duplicateNumbers = duplicateNumbers.filter(n => typeof n === 'number' && !isNaN(n));
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Failed to parse AI response as JSON:', error.message);
|
|
||||||
console.log('Raw response:', aiResponse);
|
|
||||||
|
|
||||||
// Fallback: try to extract numbers from the response
|
|
||||||
const numberMatches = aiResponse.match(/\d+/g);
|
|
||||||
duplicateNumbers = numberMatches ? numberMatches.map(n => parseInt(n)) : [];
|
|
||||||
console.log('Extracted numbers as fallback:', duplicateNumbers);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(duplicateNumbers) || duplicateNumbers.length === 0) {
|
|
||||||
console.log('No duplicates detected by AI');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`AI detected ${duplicateNumbers.length} potential duplicates: ${duplicateNumbers.join(', ')}`);
|
|
||||||
|
|
||||||
// Get details of detected duplicates
|
|
||||||
const similarIssues = JSON.parse(process.env.SIMILAR_ISSUES);
|
|
||||||
const duplicates = similarIssues.filter(issue => duplicateNumbers.includes(issue.number));
|
|
||||||
|
|
||||||
if (duplicates.length === 0) {
|
|
||||||
console.log('No matching issues found for detected numbers');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create comment with duplicate detection results
|
|
||||||
const duplicateLinks = duplicates.map(issue => `- [#${issue.number}: ${issue.title}](${issue.url})`).join('\n');
|
|
||||||
|
|
||||||
const commentBody = [
|
|
||||||
'<!-- workflow: detect-duplicate-issues -->',
|
|
||||||
'### 🔍 **Potential duplicate detection**',
|
|
||||||
'',
|
|
||||||
'I\'ve analyzed similar issues and found the following potential duplicates:',
|
|
||||||
'',
|
|
||||||
duplicateLinks,
|
|
||||||
'',
|
|
||||||
'**What to do next:**',
|
|
||||||
'1. Please review these issues to see if they match your issue',
|
|
||||||
'2. If you find an existing issue that covers your problem:',
|
|
||||||
' - Consider closing this issue',
|
|
||||||
' - Add your findings or 👍 on the existing issue instead',
|
|
||||||
'3. If your issue is different or adds new aspects, please clarify how it differs',
|
|
||||||
'',
|
|
||||||
'This helps keep our issues organized and ensures similar issues are consolidated for better visibility.',
|
|
||||||
'',
|
|
||||||
'*This message was generated automatically by our duplicate detection system.*'
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.payload.issue.number,
|
|
||||||
body: commentBody
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Posted duplicate detection comment with ${duplicates.length} potential duplicates`);
|
|
||||||
|
|
||||||
// Add the potential-duplicate label
|
|
||||||
await github.rest.issues.addLabels({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.payload.issue.number,
|
|
||||||
labels: ['potential-duplicate']
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Added potential-duplicate label to the issue');
|
|
||||||
} catch (error) {
|
|
||||||
core.error('Failed to post duplicate detection comment or add label:', error.message);
|
|
||||||
if (error.status === 403) {
|
|
||||||
core.error('Permission denied or rate limit exceeded');
|
|
||||||
}
|
|
||||||
// Don't throw - we've done the analysis, just couldn't post the result
|
|
||||||
}
|
|
193
.github/workflows/detect-non-english-issues.yml
vendored
193
.github/workflows/detect-non-english-issues.yml
vendored
@@ -1,193 +0,0 @@
|
|||||||
name: Auto-detect non-English issues
|
|
||||||
|
|
||||||
# yamllint disable-line rule:truthy
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: [opened]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
models: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
detect-language:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check issue language
|
|
||||||
id: detect_language
|
|
||||||
uses: actions/github-script@v7.0.1
|
|
||||||
env:
|
|
||||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
|
||||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
|
||||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
|
||||||
ISSUE_USER_TYPE: ${{ github.event.issue.user.type }}
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
// Get the issue details from environment variables
|
|
||||||
const issueNumber = process.env.ISSUE_NUMBER;
|
|
||||||
const issueTitle = process.env.ISSUE_TITLE || '';
|
|
||||||
const issueBody = process.env.ISSUE_BODY || '';
|
|
||||||
const userType = process.env.ISSUE_USER_TYPE;
|
|
||||||
|
|
||||||
// Skip language detection for bot users
|
|
||||||
if (userType === 'Bot') {
|
|
||||||
console.log('Skipping language detection for bot user');
|
|
||||||
core.setOutput('should_continue', 'false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Checking language for issue #${issueNumber}`);
|
|
||||||
console.log(`Title: ${issueTitle}`);
|
|
||||||
|
|
||||||
// Combine title and body for language detection
|
|
||||||
const fullText = `${issueTitle}\n\n${issueBody}`;
|
|
||||||
|
|
||||||
// Check if the text is too short to reliably detect language
|
|
||||||
if (fullText.trim().length < 20) {
|
|
||||||
console.log('Text too short for reliable language detection');
|
|
||||||
core.setOutput('should_continue', 'false'); // Skip processing for very short text
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
core.setOutput('issue_number', issueNumber);
|
|
||||||
core.setOutput('issue_text', fullText);
|
|
||||||
core.setOutput('should_continue', 'true');
|
|
||||||
|
|
||||||
- name: Detect language using AI
|
|
||||||
id: ai_language_detection
|
|
||||||
if: steps.detect_language.outputs.should_continue == 'true'
|
|
||||||
uses: actions/ai-inference@v1.1.0
|
|
||||||
with:
|
|
||||||
model: openai/gpt-4o-mini
|
|
||||||
system-prompt: |
|
|
||||||
You are a language detection system. Your task is to determine if the provided text is written in English or another language.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
1. Analyze the text and determine the primary language of the USER'S DESCRIPTION only
|
|
||||||
2. IGNORE markdown headers (lines starting with #, ##, ###, etc.) as these are from issue templates, not user input
|
|
||||||
3. IGNORE all code blocks (text between ``` or ` markers) as they may contain system-generated error messages in other languages
|
|
||||||
4. IGNORE error messages, logs, and system output even if not in code blocks - these often appear in the user's system language
|
|
||||||
5. Consider technical terms, code snippets, URLs, and file paths as neutral (they don't indicate non-English)
|
|
||||||
6. Focus ONLY on the actual sentences and descriptions written by the user explaining their issue
|
|
||||||
7. If the user's explanation/description is in English but includes non-English error messages or logs, consider it ENGLISH
|
|
||||||
8. Return ONLY a JSON object with two fields:
|
|
||||||
- "is_english": boolean (true if the user's description is primarily in English, false otherwise)
|
|
||||||
- "detected_language": string (the name of the detected language, e.g., "English", "Spanish", "Chinese", etc.)
|
|
||||||
9. Be lenient - if the user's explanation is in English with non-English system output, it's still English
|
|
||||||
10. Common programming terms, error messages, and technical jargon should not be considered as non-English
|
|
||||||
11. If you cannot reliably determine the language, set detected_language to "undefined"
|
|
||||||
|
|
||||||
Example response:
|
|
||||||
{"is_english": false, "detected_language": "Spanish"}
|
|
||||||
|
|
||||||
prompt: |
|
|
||||||
Please analyze the following issue text and determine if it is written in English:
|
|
||||||
|
|
||||||
${{ steps.detect_language.outputs.issue_text }}
|
|
||||||
|
|
||||||
max-tokens: 50
|
|
||||||
|
|
||||||
- name: Process non-English issues
|
|
||||||
if: steps.detect_language.outputs.should_continue == 'true'
|
|
||||||
uses: actions/github-script@v7.0.1
|
|
||||||
env:
|
|
||||||
AI_RESPONSE: ${{ steps.ai_language_detection.outputs.response }}
|
|
||||||
ISSUE_NUMBER: ${{ steps.detect_language.outputs.issue_number }}
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const issueNumber = parseInt(process.env.ISSUE_NUMBER);
|
|
||||||
const aiResponse = process.env.AI_RESPONSE;
|
|
||||||
|
|
||||||
console.log('AI language detection response:', aiResponse);
|
|
||||||
|
|
||||||
let languageResult;
|
|
||||||
try {
|
|
||||||
languageResult = JSON.parse(aiResponse.trim());
|
|
||||||
|
|
||||||
// Validate the response structure
|
|
||||||
if (!languageResult || typeof languageResult.is_english !== 'boolean') {
|
|
||||||
throw new Error('Invalid response structure');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
core.error(`Failed to parse AI response: ${error.message}`);
|
|
||||||
console.log('Raw AI response:', aiResponse);
|
|
||||||
|
|
||||||
// Log more details for debugging
|
|
||||||
core.warning('Defaulting to English due to parsing error');
|
|
||||||
|
|
||||||
// Default to English if we can't parse the response
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (languageResult.is_english) {
|
|
||||||
console.log('Issue is in English, no action needed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If language is undefined or not detected, skip processing
|
|
||||||
if (!languageResult.detected_language || languageResult.detected_language === 'undefined') {
|
|
||||||
console.log('Language could not be determined, skipping processing');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Issue detected as non-English: ${languageResult.detected_language}`);
|
|
||||||
|
|
||||||
// Post comment explaining the language requirement
|
|
||||||
const commentBody = [
|
|
||||||
'<!-- workflow: detect-non-english-issues -->',
|
|
||||||
'### 🌐 Non-English issue detected',
|
|
||||||
'',
|
|
||||||
`This issue appears to be written in **${languageResult.detected_language}** rather than English.`,
|
|
||||||
'',
|
|
||||||
'The Home Assistant project uses English as the primary language for issues to ensure that everyone in our international community can participate and help resolve issues. This allows any of our thousands of contributors to jump in and provide assistance.',
|
|
||||||
'',
|
|
||||||
'**What to do:**',
|
|
||||||
'1. Re-create the issue using the English language',
|
|
||||||
'2. If you need help with translation, consider using:',
|
|
||||||
' - Translation tools like Google Translate',
|
|
||||||
' - AI assistants like ChatGPT or Claude',
|
|
||||||
'',
|
|
||||||
'This helps our community provide the best possible support and ensures your issue gets the attention it deserves from our global contributor base.',
|
|
||||||
'',
|
|
||||||
'Thank you for your understanding! 🙏'
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Add comment
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: issueNumber,
|
|
||||||
body: commentBody
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Posted language requirement comment');
|
|
||||||
|
|
||||||
// Add non-english label
|
|
||||||
await github.rest.issues.addLabels({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: issueNumber,
|
|
||||||
labels: ['non-english']
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Added non-english label');
|
|
||||||
|
|
||||||
// Close the issue
|
|
||||||
await github.rest.issues.update({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: issueNumber,
|
|
||||||
state: 'closed',
|
|
||||||
state_reason: 'not_planned'
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Closed the issue');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
core.error('Failed to process non-English issue:', error.message);
|
|
||||||
if (error.status === 403) {
|
|
||||||
core.error('Permission denied or rate limit exceeded');
|
|
||||||
}
|
|
||||||
}
|
|
6
.github/workflows/stale.yml
vendored
6
.github/workflows/stale.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
|||||||
# - 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: 60 days stale PRs policy
|
||||||
uses: actions/stale@v9.1.0
|
uses: actions/stale@v9.0.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
days-before-stale: 60
|
days-before-stale: 60
|
||||||
@@ -57,7 +57,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@v9.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 +87,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@v9.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"
|
||||||
|
6
.github/workflows/translations.yml
vendored
6
.github/workflows/translations.yml
vendored
@@ -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.2
|
||||||
|
|
||||||
- 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@v5.0.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
|
|
||||||
|
208
.github/workflows/wheels.yml
vendored
208
.github/workflows/wheels.yml
vendored
@@ -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.2
|
||||||
|
|
||||||
- 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,53 +60,22 @@ 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@v4.3.1
|
||||||
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
|
overwrite: true
|
||||||
|
|
||||||
- name: Upload requirements_diff
|
- name: Upload requirements_diff
|
||||||
uses: actions/upload-artifact@v4.6.2
|
uses: actions/upload-artifact@v4.3.1
|
||||||
with:
|
with:
|
||||||
name: requirements_diff
|
name: requirements_diff
|
||||||
path: ./requirements_diff.txt
|
path: ./requirements_diff.txt
|
||||||
overwrite: true
|
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)
|
||||||
if: github.repository_owner == 'home-assistant'
|
if: github.repository_owner == 'home-assistant'
|
||||||
@@ -131,43 +84,32 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
abi: ["cp313"]
|
abi: ["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.2
|
||||||
|
|
||||||
- name: Download env_file
|
- name: Download env_file
|
||||||
uses: actions/download-artifact@v4.3.0
|
uses: actions/download-artifact@v4.1.4
|
||||||
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@v4.1.4
|
||||||
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@2024.01.0
|
||||||
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;nasm"
|
||||||
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 +122,73 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
abi: ["cp313"]
|
abi: ["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.2
|
||||||
|
|
||||||
- name: Download env_file
|
- name: Download env_file
|
||||||
uses: actions/download-artifact@v4.3.0
|
uses: actions/download-artifact@v4.1.4
|
||||||
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@v4.1.4
|
||||||
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
|
||||||
|
|
||||||
|
# Some speedups are only for 64-bit
|
||||||
|
if [ "${{ matrix.arch }}" = "amd64" ] || [ "${{ matrix.arch }}" = "aarch64" ]; then
|
||||||
|
sed -i "s|aiohttp-zlib-ng|aiohttp-zlib-ng\[isal\]|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 +198,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@2024.01.0
|
||||||
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@2024.01.0
|
||||||
|
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;nasm"
|
||||||
|
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@2024.01.0
|
||||||
|
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;nasm"
|
||||||
|
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@2024.01.0
|
||||||
|
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;nasm"
|
||||||
|
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
6
.gitignore
vendored
@@ -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
|
|
@@ -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.12.0
|
rev: v0.2.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- 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.37.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/.+/(icons|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
|
||||||
|
104
.strict-typing
104
.strict-typing
@@ -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,7 +40,6 @@ 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.acmeda.*
|
||||||
@@ -50,7 +48,6 @@ 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.airnow.*
|
||||||
homeassistant.components.airq.*
|
homeassistant.components.airq.*
|
||||||
@@ -65,12 +62,10 @@ 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.alexa_devices.*
|
|
||||||
homeassistant.components.alpha_vantage.*
|
homeassistant.components.alpha_vantage.*
|
||||||
homeassistant.components.altruist.*
|
|
||||||
homeassistant.components.amazon_polly.*
|
homeassistant.components.amazon_polly.*
|
||||||
homeassistant.components.amberelectric.*
|
homeassistant.components.amberelectric.*
|
||||||
homeassistant.components.ambient_network.*
|
homeassistant.components.ambiclimate.*
|
||||||
homeassistant.components.ambient_station.*
|
homeassistant.components.ambient_station.*
|
||||||
homeassistant.components.amcrest.*
|
homeassistant.components.amcrest.*
|
||||||
homeassistant.components.ampio.*
|
homeassistant.components.ampio.*
|
||||||
@@ -88,7 +83,6 @@ homeassistant.components.api.*
|
|||||||
homeassistant.components.apple_tv.*
|
homeassistant.components.apple_tv.*
|
||||||
homeassistant.components.apprise.*
|
homeassistant.components.apprise.*
|
||||||
homeassistant.components.aprs.*
|
homeassistant.components.aprs.*
|
||||||
homeassistant.components.apsystems.*
|
|
||||||
homeassistant.components.aqualogic.*
|
homeassistant.components.aqualogic.*
|
||||||
homeassistant.components.aquostv.*
|
homeassistant.components.aquostv.*
|
||||||
homeassistant.components.aranet.*
|
homeassistant.components.aranet.*
|
||||||
@@ -98,14 +92,13 @@ homeassistant.components.aruba.*
|
|||||||
homeassistant.components.arwn.*
|
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.asterisk_cdr.*
|
||||||
|
homeassistant.components.asterisk_mbox.*
|
||||||
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.axis.*
|
||||||
homeassistant.components.azure_storage.*
|
|
||||||
homeassistant.components.backup.*
|
homeassistant.components.backup.*
|
||||||
homeassistant.components.baf.*
|
homeassistant.components.baf.*
|
||||||
homeassistant.components.bang_olufsen.*
|
homeassistant.components.bang_olufsen.*
|
||||||
@@ -115,22 +108,17 @@ homeassistant.components.bitcoin.*
|
|||||||
homeassistant.components.blockchain.*
|
homeassistant.components.blockchain.*
|
||||||
homeassistant.components.blue_current.*
|
homeassistant.components.blue_current.*
|
||||||
homeassistant.components.blueprint.*
|
homeassistant.components.blueprint.*
|
||||||
homeassistant.components.bluesound.*
|
|
||||||
homeassistant.components.bluetooth.*
|
homeassistant.components.bluetooth.*
|
||||||
homeassistant.components.bluetooth_adapters.*
|
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.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.cert_expiry.*
|
||||||
@@ -139,18 +127,15 @@ homeassistant.components.clicksend.*
|
|||||||
homeassistant.components.climate.*
|
homeassistant.components.climate.*
|
||||||
homeassistant.components.cloud.*
|
homeassistant.components.cloud.*
|
||||||
homeassistant.components.co2signal.*
|
homeassistant.components.co2signal.*
|
||||||
homeassistant.components.comelit.*
|
|
||||||
homeassistant.components.command_line.*
|
homeassistant.components.command_line.*
|
||||||
homeassistant.components.config.*
|
homeassistant.components.config.*
|
||||||
homeassistant.components.configurator.*
|
homeassistant.components.configurator.*
|
||||||
homeassistant.components.cookidoo.*
|
|
||||||
homeassistant.components.counter.*
|
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.date.*
|
||||||
homeassistant.components.datetime.*
|
homeassistant.components.datetime.*
|
||||||
homeassistant.components.deako.*
|
|
||||||
homeassistant.components.deconz.*
|
homeassistant.components.deconz.*
|
||||||
homeassistant.components.default_config.*
|
homeassistant.components.default_config.*
|
||||||
homeassistant.components.demo.*
|
homeassistant.components.demo.*
|
||||||
@@ -176,19 +161,15 @@ homeassistant.components.easyenergy.*
|
|||||||
homeassistant.components.ecovacs.*
|
homeassistant.components.ecovacs.*
|
||||||
homeassistant.components.ecowitt.*
|
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.energyzero.*
|
||||||
homeassistant.components.enigma2.*
|
homeassistant.components.enigma2.*
|
||||||
homeassistant.components.enphase_envoy.*
|
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.*
|
||||||
@@ -209,44 +190,33 @@ 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_hygrostat.*
|
||||||
homeassistant.components.generic_thermostat.*
|
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_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.history_stats.*
|
||||||
homeassistant.components.holiday.*
|
homeassistant.components.holiday.*
|
||||||
homeassistant.components.home_connect.*
|
|
||||||
homeassistant.components.homeassistant.*
|
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
|
||||||
@@ -262,7 +232,6 @@ homeassistant.components.homeworks.*
|
|||||||
homeassistant.components.http.*
|
homeassistant.components.http.*
|
||||||
homeassistant.components.huawei_lte.*
|
homeassistant.components.huawei_lte.*
|
||||||
homeassistant.components.humidifier.*
|
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.*
|
||||||
@@ -271,9 +240,6 @@ 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.*
|
||||||
@@ -281,20 +247,16 @@ homeassistant.components.integration.*
|
|||||||
homeassistant.components.intent.*
|
homeassistant.components.intent.*
|
||||||
homeassistant.components.intent_script.*
|
homeassistant.components.intent_script.*
|
||||||
homeassistant.components.ios.*
|
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.lamarzocco.*
|
||||||
@@ -304,13 +266,10 @@ homeassistant.components.lawn_mower.*
|
|||||||
homeassistant.components.lcn.*
|
homeassistant.components.lcn.*
|
||||||
homeassistant.components.ld2410_ble.*
|
homeassistant.components.ld2410_ble.*
|
||||||
homeassistant.components.led_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.*
|
||||||
@@ -320,41 +279,32 @@ 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.map.*
|
||||||
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.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.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.motionmount.*
|
||||||
homeassistant.components.mqtt.*
|
homeassistant.components.mqtt.*
|
||||||
homeassistant.components.music_assistant.*
|
|
||||||
homeassistant.components.my.*
|
homeassistant.components.my.*
|
||||||
homeassistant.components.mysensors.*
|
homeassistant.components.mysensors.*
|
||||||
homeassistant.components.myuplink.*
|
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.*
|
||||||
@@ -364,40 +314,27 @@ homeassistant.components.nfandroidtv.*
|
|||||||
homeassistant.components.nightscout.*
|
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.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.oralb.*
|
||||||
homeassistant.components.otbr.*
|
homeassistant.components.otbr.*
|
||||||
homeassistant.components.overkiz.*
|
homeassistant.components.overkiz.*
|
||||||
homeassistant.components.overseerr.*
|
|
||||||
homeassistant.components.p1_monitor.*
|
homeassistant.components.p1_monitor.*
|
||||||
homeassistant.components.pandora.*
|
|
||||||
homeassistant.components.panel_custom.*
|
|
||||||
homeassistant.components.paperless_ngx.*
|
|
||||||
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.prometheus.*
|
||||||
@@ -407,56 +344,42 @@ homeassistant.components.pure_energie.*
|
|||||||
homeassistant.components.purpleair.*
|
homeassistant.components.purpleair.*
|
||||||
homeassistant.components.pushbullet.*
|
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.rabbitair.*
|
||||||
homeassistant.components.radarr.*
|
homeassistant.components.radarr.*
|
||||||
homeassistant.components.radio_browser.*
|
|
||||||
homeassistant.components.rainforest_raven.*
|
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.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.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.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.shopping_list.*
|
||||||
homeassistant.components.simplepush.*
|
homeassistant.components.simplepush.*
|
||||||
@@ -466,20 +389,15 @@ 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.streamlabswater.*
|
||||||
homeassistant.components.stt.*
|
homeassistant.components.stt.*
|
||||||
@@ -487,7 +405,6 @@ 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.*
|
||||||
@@ -503,9 +420,7 @@ homeassistant.components.tautulli.*
|
|||||||
homeassistant.components.tcp.*
|
homeassistant.components.tcp.*
|
||||||
homeassistant.components.technove.*
|
homeassistant.components.technove.*
|
||||||
homeassistant.components.tedee.*
|
homeassistant.components.tedee.*
|
||||||
homeassistant.components.telegram_bot.*
|
|
||||||
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.*
|
||||||
@@ -536,13 +451,11 @@ 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.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.wake_word.*
|
||||||
homeassistant.components.wallbox.*
|
homeassistant.components.wallbox.*
|
||||||
@@ -558,7 +471,6 @@ 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.xiaomi_ble.*
|
||||||
homeassistant.components.yale_smart_alarm.*
|
homeassistant.components.yale_smart_alarm.*
|
||||||
|
47
.vscode/launch.json
vendored
47
.vscode/launch.json
vendored
@@ -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}",
|
||||||
|
15
.vscode/settings.default.json
vendored
15
.vscode/settings.default.json
vendored
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
51
.vscode/tasks.json
vendored
51
.vscode/tasks.json
vendored
@@ -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
|
||||||
@@ -45,21 +45,7 @@
|
|||||||
{
|
{
|
||||||
"label": "Ruff",
|
"label": "Ruff",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "pre-commit run ruff-check --all-files",
|
"command": "pre-commit run ruff --all-files",
|
||||||
"group": {
|
|
||||||
"kind": "test",
|
|
||||||
"isDefault": true
|
|
||||||
},
|
|
||||||
"presentation": {
|
|
||||||
"reveal": "always",
|
|
||||||
"panel": "new"
|
|
||||||
},
|
|
||||||
"problemMatcher": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Pre-commit",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "pre-commit run --show-diff-on-failure",
|
|
||||||
"group": {
|
"group": {
|
||||||
"kind": "test",
|
"kind": "test",
|
||||||
"isDefault": true
|
"isDefault": true
|
||||||
@@ -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,7 +152,7 @@
|
|||||||
"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
|
||||||
|
560
CODEOWNERS
560
CODEOWNERS
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
|
|
||||||
|
58
Dockerfile
58
Dockerfile
@@ -6,58 +6,52 @@ 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=240000
|
||||||
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; \
|
||||||
-r homeassistant/requirements_all.txt
|
fi \
|
||||||
|
&& if [ "${BUILD_ARCH}" = "i386" ]; then \
|
||||||
|
LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \
|
||||||
|
MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \
|
||||||
|
linux32 pip3 install \
|
||||||
|
--only-binary=:all: \
|
||||||
|
-r homeassistant/requirements_all.txt; \
|
||||||
|
else \
|
||||||
|
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; \
|
||||||
|
fi
|
||||||
|
|
||||||
## 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
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
FROM mcr.microsoft.com/devcontainers/python:1-3.13
|
FROM mcr.microsoft.com/devcontainers/python:1-3.12
|
||||||
|
|
||||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||||
|
|
||||||
@@ -22,7 +22,6 @@ RUN \
|
|||||||
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 +34,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
|
||||||
|
@@ -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/dev/.github/assets/screenshot-states.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/.github/assets/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/
|
|
12
build.yaml
12
build.yaml
@@ -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:2024.02.1
|
||||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.05.0
|
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.02.1
|
||||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.05.0
|
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.02.1
|
||||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.05.0
|
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.02.1
|
||||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.05.0
|
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.02.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
|
||||||
|
@@ -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
|
||||||
|
@@ -3,13 +3,11 @@
|
|||||||
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"
|
||||||
@@ -38,7 +36,8 @@ def validate_python() -> None:
|
|||||||
|
|
||||||
def ensure_config_path(config_dir: str) -> None:
|
def ensure_config_path(config_dir: str) -> None:
|
||||||
"""Validate the configuration directory."""
|
"""Validate the configuration directory."""
|
||||||
from . import config as config_util # noqa: PLC0415
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
from . import config as config_util
|
||||||
|
|
||||||
lib_dir = os.path.join(config_dir, "deps")
|
lib_dir = os.path.join(config_dir, "deps")
|
||||||
|
|
||||||
@@ -79,7 +78,8 @@ def ensure_config_path(config_dir: str) -> None:
|
|||||||
|
|
||||||
def get_arguments() -> argparse.Namespace:
|
def get_arguments() -> argparse.Namespace:
|
||||||
"""Get parsed passed in arguments."""
|
"""Get parsed passed in arguments."""
|
||||||
from . import config as config_util # noqa: PLC0415
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
from . import config as config_util
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Home Assistant: Observe, Control, Automate.",
|
description="Home Assistant: Observe, Control, Automate.",
|
||||||
@@ -146,7 +146,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:
|
||||||
@@ -175,17 +177,16 @@ def main() -> int:
|
|||||||
validate_os()
|
validate_os()
|
||||||
|
|
||||||
if args.script is not None:
|
if args.script is not None:
|
||||||
from . import scripts # noqa: PLC0415
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
from . import scripts
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
from . import config, runner # noqa: PLC0415
|
# pylint: disable-next=import-outside-toplevel
|
||||||
|
from . import config, runner
|
||||||
|
|
||||||
safe_mode = config.safe_mode_enabled(config_dir)
|
safe_mode = config.safe_mode_enabled(config_dir)
|
||||||
|
|
||||||
@@ -209,10 +210,8 @@ def main() -> int:
|
|||||||
exit_code = runner.run(runtime_conf)
|
exit_code = runner.run(runtime_conf)
|
||||||
faulthandler.disable()
|
faulthandler.disable()
|
||||||
|
|
||||||
# It's possible for the fault file to disappear, so suppress obvious errors
|
if os.path.getsize(fault_file_name) == 0:
|
||||||
with suppress(FileNotFoundError):
|
os.remove(fault_file_name)
|
||||||
if os.path.getsize(fault_file_name) == 0:
|
|
||||||
os.remove(fault_file_name)
|
|
||||||
|
|
||||||
check_threads()
|
check_threads()
|
||||||
|
|
||||||
|
@@ -12,6 +12,7 @@ from typing import Any, cast
|
|||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
|
|
||||||
|
from homeassistant import data_entry_flow
|
||||||
from homeassistant.core import (
|
from homeassistant.core import (
|
||||||
CALLBACK_TYPE,
|
CALLBACK_TYPE,
|
||||||
HassJob,
|
HassJob,
|
||||||
@@ -19,24 +20,22 @@ from homeassistant.core import (
|
|||||||
HomeAssistant,
|
HomeAssistant,
|
||||||
callback,
|
callback,
|
||||||
)
|
)
|
||||||
from homeassistant.data_entry_flow import FlowHandler, FlowManager, FlowResultType
|
|
||||||
from homeassistant.helpers.event import async_track_point_in_utc_time
|
from homeassistant.helpers.event import async_track_point_in_utc_time
|
||||||
from homeassistant.util import dt as dt_util
|
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, REFRESH_TOKEN_EXPIRATION
|
||||||
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 .models import 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,7 +53,7 @@ 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)
|
||||||
@@ -74,13 +73,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,12 +85,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()
|
manager.async_setup()
|
||||||
return manager
|
return manager
|
||||||
|
|
||||||
|
|
||||||
class AuthManagerFlowManager(
|
class AuthManagerFlowManager(
|
||||||
FlowManager[AuthFlowContext, AuthFlowResult, tuple[str, str]]
|
data_entry_flow.FlowManager[AuthFlowResult, tuple[str, str]]
|
||||||
):
|
):
|
||||||
"""Manage authentication flows."""
|
"""Manage authentication flows."""
|
||||||
|
|
||||||
@@ -113,9 +105,9 @@ class AuthManagerFlowManager(
|
|||||||
self,
|
self,
|
||||||
handler_key: tuple[str, str],
|
handler_key: tuple[str, 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]:
|
) -> LoginFlow:
|
||||||
"""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:
|
||||||
@@ -124,17 +116,13 @@ class AuthManagerFlowManager(
|
|||||||
|
|
||||||
async def async_finish_flow(
|
async def async_finish_flow(
|
||||||
self,
|
self,
|
||||||
flow: FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]],
|
flow: data_entry_flow.FlowHandler[AuthFlowResult, tuple[str, str]],
|
||||||
result: AuthFlowResult,
|
result: AuthFlowResult,
|
||||||
) -> AuthFlowResult:
|
) -> AuthFlowResult:
|
||||||
"""Return a user as result of login flow.
|
"""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
|
||||||
@@ -193,7 +181,8 @@ class AuthManager:
|
|||||||
self._async_remove_expired_refresh_tokens, job_type=HassJobType.Callback
|
self._async_remove_expired_refresh_tokens, job_type=HassJobType.Callback
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_setup(self) -> None:
|
@callback
|
||||||
|
def async_setup(self) -> None:
|
||||||
"""Set up the auth manager."""
|
"""Set up the auth manager."""
|
||||||
hass = self.hass
|
hass = self.hass
|
||||||
hass.async_add_shutdown_job(
|
hass.async_add_shutdown_job(
|
||||||
@@ -367,15 +356,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 +375,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)
|
||||||
@@ -535,13 +517,6 @@ class AuthManager:
|
|||||||
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
|
@callback
|
||||||
def _async_remove_expired_refresh_tokens(self, _: datetime | None = None) -> None:
|
def _async_remove_expired_refresh_tokens(self, _: datetime | None = None) -> None:
|
||||||
"""Remove expired refresh tokens."""
|
"""Remove expired refresh tokens."""
|
||||||
|
@@ -62,7 +62,6 @@ class AuthStore:
|
|||||||
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] = {}
|
|
||||||
|
|
||||||
async def async_get_groups(self) -> list[models.Group]:
|
async def async_get_groups(self) -> list[models.Group]:
|
||||||
"""Retrieve all users."""
|
"""Retrieve all users."""
|
||||||
@@ -105,18 +104,14 @@ class AuthStore:
|
|||||||
"perm_lookup": self._perm_lookup,
|
"perm_lookup": self._perm_lookup,
|
||||||
}
|
}
|
||||||
|
|
||||||
kwargs.update(
|
for attr_name, value in (
|
||||||
{
|
("is_owner", is_owner),
|
||||||
attr_name: value
|
("is_active", is_active),
|
||||||
for attr_name, value in (
|
("local_only", local_only),
|
||||||
("is_owner", is_owner),
|
("system_generated", system_generated),
|
||||||
("is_active", is_active),
|
):
|
||||||
("local_only", local_only),
|
if value is not None:
|
||||||
("system_generated", system_generated),
|
kwargs[attr_name] = value
|
||||||
)
|
|
||||||
if value is not None
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
new_user = models.User(**kwargs)
|
new_user = models.User(**kwargs)
|
||||||
|
|
||||||
@@ -140,10 +135,7 @@ 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)
|
self._users.pop(user.id)
|
||||||
for refresh_token_id in user.refresh_tokens:
|
|
||||||
del self._token_id_to_user_id[refresh_token_id]
|
|
||||||
user.refresh_tokens.clear()
|
|
||||||
self._async_schedule_save()
|
self._async_schedule_save()
|
||||||
|
|
||||||
async def async_update_user(
|
async def async_update_user(
|
||||||
@@ -226,9 +218,7 @@ 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
|
||||||
@@ -236,17 +226,19 @@ class AuthStore:
|
|||||||
@callback
|
@callback
|
||||||
def async_remove_refresh_token(self, refresh_token: models.RefreshToken) -> None:
|
def async_remove_refresh_token(self, refresh_token: models.RefreshToken) -> None:
|
||||||
"""Remove a refresh token."""
|
"""Remove a refresh token."""
|
||||||
refresh_token_id = refresh_token.id
|
for user in self._users.values():
|
||||||
if user_id := self._token_id_to_user_id.get(refresh_token_id):
|
if user.refresh_tokens.pop(refresh_token.id, None):
|
||||||
del self._users[user_id].refresh_tokens[refresh_token_id]
|
self._async_schedule_save()
|
||||||
del self._token_id_to_user_id[refresh_token_id]
|
break
|
||||||
self._async_schedule_save()
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_get_refresh_token(self, token_id: str) -> models.RefreshToken | None:
|
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):
|
for user in self._users.values():
|
||||||
return self._users[user_id].refresh_tokens.get(token_id)
|
refresh_token = user.refresh_tokens.get(token_id)
|
||||||
|
if refresh_token is not None:
|
||||||
|
return refresh_token
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
@@ -285,30 +277,7 @@ class AuthStore:
|
|||||||
)
|
)
|
||||||
self._async_schedule_save()
|
self._async_schedule_save()
|
||||||
|
|
||||||
@callback
|
async def async_load(self) -> None: # noqa: C901
|
||||||
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:
|
if self._loaded:
|
||||||
raise RuntimeError("Auth storage is already loaded")
|
raise RuntimeError("Auth storage is already loaded")
|
||||||
@@ -321,6 +290,8 @@ class AuthStore:
|
|||||||
perm_lookup = PermissionLookup(ent_reg, dev_reg)
|
perm_lookup = PermissionLookup(ent_reg, dev_reg)
|
||||||
self._perm_lookup = perm_lookup
|
self._perm_lookup = perm_lookup
|
||||||
|
|
||||||
|
now_ts = dt_util.utcnow().timestamp()
|
||||||
|
|
||||||
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
|
||||||
@@ -474,6 +445,14 @@ class AuthStore:
|
|||||||
else:
|
else:
|
||||||
last_used_at = None
|
last_used_at = None
|
||||||
|
|
||||||
|
if (
|
||||||
|
expire_at := rt_dict.get("expire_at")
|
||||||
|
) is None and token_type == models.TOKEN_TYPE_NORMAL:
|
||||||
|
if last_used_at:
|
||||||
|
expire_at = last_used_at.timestamp() + REFRESH_TOKEN_EXPIRATION
|
||||||
|
else:
|
||||||
|
expire_at = now_ts + REFRESH_TOKEN_EXPIRATION
|
||||||
|
|
||||||
token = models.RefreshToken(
|
token = models.RefreshToken(
|
||||||
id=rt_dict["id"],
|
id=rt_dict["id"],
|
||||||
user=users[rt_dict["user_id"]],
|
user=users[rt_dict["user_id"]],
|
||||||
@@ -490,7 +469,7 @@ 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"),
|
expire_at=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,17 +478,8 @@ 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
|
self._async_schedule_save(INITIAL_LOAD_SAVE_DELAY)
|
||||||
def _build_token_id_to_user_id(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
|
@callback
|
||||||
def _async_schedule_save(self, delay: float = DEFAULT_SAVE_DELAY) -> None:
|
def _async_schedule_save(self, delay: float = DEFAULT_SAVE_DELAY) -> None:
|
||||||
@@ -604,7 +574,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:
|
||||||
|
@@ -18,7 +18,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 +78,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."""
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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 +15,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 +29,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 +70,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 +94,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 +149,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(
|
||||||
|
@@ -52,28 +52,28 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def _generate_secret() -> str:
|
def _generate_secret() -> str:
|
||||||
"""Generate a secret."""
|
"""Generate a secret."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
return str(pyotp.random_base32())
|
return str(pyotp.random_base32())
|
||||||
|
|
||||||
|
|
||||||
def _generate_random() -> int:
|
def _generate_random() -> int:
|
||||||
"""Generate a 32 digit number."""
|
"""Generate a 32 digit number."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
return int(pyotp.random_base32(length=32, chars=list("1234567890")))
|
return int(pyotp.random_base32(length=32, chars=list("1234567890")))
|
||||||
|
|
||||||
|
|
||||||
def _generate_otp(secret: str, count: int) -> str:
|
def _generate_otp(secret: str, count: int) -> str:
|
||||||
"""Generate one time password."""
|
"""Generate one time password."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
return str(pyotp.HOTP(secret).at(count))
|
return str(pyotp.HOTP(secret).at(count))
|
||||||
|
|
||||||
|
|
||||||
def _verify_otp(secret: str, otp: str, count: int) -> bool:
|
def _verify_otp(secret: str, otp: str, count: int) -> bool:
|
||||||
"""Verify one time password."""
|
"""Verify one time password."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
return bool(pyotp.HOTP(secret).verify(otp, count))
|
return bool(pyotp.HOTP(secret).verify(otp, count))
|
||||||
|
|
||||||
@@ -88,7 +88,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")
|
||||||
@@ -162,7 +162,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 +268,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 +280,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
|
||||||
|
@@ -37,7 +37,7 @@ DUMMY_SECRET = "FPPTH34D4E3MI2HG"
|
|||||||
|
|
||||||
def _generate_qr_code(data: str) -> str:
|
def _generate_qr_code(data: str) -> str:
|
||||||
"""Generate a base64 PNG string represent QR Code image of data."""
|
"""Generate a base64 PNG string represent QR Code image of data."""
|
||||||
import pyqrcode # noqa: PLC0415
|
import pyqrcode # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
qr_code = pyqrcode.create(data)
|
qr_code = pyqrcode.create(data)
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ def _generate_qr_code(data: str) -> str:
|
|||||||
|
|
||||||
def _generate_secret_and_qr_code(username: str) -> tuple[str, str, str]:
|
def _generate_secret_and_qr_code(username: str) -> tuple[str, str, str]:
|
||||||
"""Generate a secret, url, and QR code."""
|
"""Generate a secret, url, and QR code."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
ota_secret = pyotp.random_base32()
|
ota_secret = pyotp.random_base32()
|
||||||
url = pyotp.totp.TOTP(ota_secret).provisioning_uri(
|
url = pyotp.totp.TOTP(ota_secret).provisioning_uri(
|
||||||
@@ -107,14 +107,14 @@ class TotpAuthModule(MultiFactorAuthModule):
|
|||||||
|
|
||||||
def _add_ota_secret(self, user_id: str, secret: str | None = None) -> str:
|
def _add_ota_secret(self, user_id: str, secret: str | None = None) -> str:
|
||||||
"""Create a ota_secret for user."""
|
"""Create a ota_secret for user."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
ota_secret: str = secret or pyotp.random_base32()
|
ota_secret: str = secret or pyotp.random_base32()
|
||||||
|
|
||||||
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
|
||||||
@@ -163,7 +163,7 @@ class TotpAuthModule(MultiFactorAuthModule):
|
|||||||
|
|
||||||
def _validate_2fa(self, user_id: str, code: str) -> bool:
|
def _validate_2fa(self, user_id: str, code: str) -> bool:
|
||||||
"""Validate two factor authentication code."""
|
"""Validate two factor authentication code."""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
if (ota_secret := self._users.get(user_id)) is None: # type: ignore[union-attr]
|
if (ota_secret := self._users.get(user_id)) is None: # type: ignore[union-attr]
|
||||||
# even we cannot find user, we still do verify
|
# even we cannot find user, we still do verify
|
||||||
@@ -174,19 +174,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
|
||||||
@@ -196,7 +197,7 @@ class TotpSetupFlow(SetupFlow[TotpAuthModule]):
|
|||||||
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.
|
||||||
Return self.async_create_entry(data={'result': result}) if finish.
|
Return self.async_create_entry(data={'result': result}) if finish.
|
||||||
"""
|
"""
|
||||||
import pyotp # noqa: PLC0415
|
import pyotp # pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
@@ -213,11 +214,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),
|
||||||
)
|
)
|
||||||
|
@@ -3,37 +3,32 @@
|
|||||||
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 TYPE_CHECKING, Any, NamedTuple
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import attr
|
import attr
|
||||||
from attr import Attribute
|
from attr import Attribute
|
||||||
from attr.setters import validate
|
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.data_entry_flow import 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
|
||||||
from .const import GROUP_ID_ADMIN
|
from .const import GROUP_ID_ADMIN
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from functools import cached_property
|
||||||
|
else:
|
||||||
|
from homeassistant.backports.functools import cached_property
|
||||||
|
|
||||||
|
|
||||||
TOKEN_TYPE_NORMAL = "normal"
|
TOKEN_TYPE_NORMAL = "normal"
|
||||||
TOKEN_TYPE_SYSTEM = "system"
|
TOKEN_TYPE_SYSTEM = "system"
|
||||||
TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = "long_lived_access_token"
|
TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = "long_lived_access_token"
|
||||||
|
|
||||||
|
AuthFlowResult = FlowResult[tuple[str, str]]
|
||||||
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)
|
||||||
@@ -96,7 +91,11 @@ class User:
|
|||||||
def invalidate_cache(self) -> None:
|
def invalidate_cache(self) -> None:
|
||||||
"""Invalidate permission and is_admin cache."""
|
"""Invalidate permission and is_admin cache."""
|
||||||
for attr_to_invalidate in ("permissions", "is_admin"):
|
for attr_to_invalidate in ("permissions", "is_admin"):
|
||||||
self.__dict__.pop(attr_to_invalidate, None)
|
# try is must more efficient than suppress
|
||||||
|
try: # noqa: SIM105
|
||||||
|
delattr(self, attr_to_invalidate)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@attr.s(slots=True)
|
@attr.s(slots=True)
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
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 +18,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 +64,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
|
||||||
|
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
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 +18,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 +40,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,
|
|
||||||
}
|
}
|
||||||
|
@@ -58,7 +58,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)
|
||||||
|
|
||||||
|
@@ -4,17 +4,17 @@ 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 +24,4 @@ type CategoryType = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Example: { entities: … }
|
# Example: { entities: … }
|
||||||
type PolicyType = Mapping[str, CategoryType]
|
PolicyType = Mapping[str, CategoryType]
|
||||||
|
@@ -10,8 +10,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(
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
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 +11,19 @@ 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.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 AuthFlowResult, 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,12 @@ async def load_auth_provider_module(
|
|||||||
return module
|
return module
|
||||||
|
|
||||||
|
|
||||||
class LoginFlow[_AuthProviderT: AuthProvider = AuthProvider](
|
class LoginFlow(data_entry_flow.FlowHandler[AuthFlowResult, tuple[str, str]]):
|
||||||
FlowHandler[AuthFlowContext, AuthFlowResult, tuple[str, str]],
|
|
||||||
):
|
|
||||||
"""Handler for the login flow."""
|
"""Handler for the login flow."""
|
||||||
|
|
||||||
_flow_result = AuthFlowResult
|
_flow_result = AuthFlowResult
|
||||||
|
|
||||||
def __init__(self, auth_provider: _AuthProviderT) -> None:
|
def __init__(self, auth_provider: AuthProvider) -> None:
|
||||||
"""Initialize the login flow."""
|
"""Initialize the login flow."""
|
||||||
self._auth_provider = auth_provider
|
self._auth_provider = auth_provider
|
||||||
self._auth_module_id: str | None = None
|
self._auth_module_id: str | None = None
|
||||||
|
@@ -6,14 +6,14 @@ 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.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
from ..models import AuthFlowContext, AuthFlowResult, Credentials, UserMeta
|
from ..models import AuthFlowResult, 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,7 +133,7 @@ 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(
|
||||||
@@ -147,9 +145,9 @@ class CommandLineLoginFlow(LoginFlow[CommandLineAuthProvider]):
|
|||||||
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"
|
||||||
|
|
||||||
|
@@ -14,10 +14,9 @@ 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.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 AuthFlowResult, Credentials, UserMeta
|
||||||
from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow
|
from . import AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, AuthProvider, LoginFlow
|
||||||
|
|
||||||
STORAGE_VERSION = 1
|
STORAGE_VERSION = 1
|
||||||
@@ -55,27 +54,6 @@ class InvalidUser(HomeAssistantError):
|
|||||||
Will not be raised when validating authentication.
|
Will not be raised when validating authentication.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*args: object,
|
|
||||||
translation_key: str | None = None,
|
|
||||||
translation_placeholders: dict[str, str] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize exception."""
|
|
||||||
super().__init__(
|
|
||||||
*args,
|
|
||||||
translation_domain="auth",
|
|
||||||
translation_key=translation_key,
|
|
||||||
translation_placeholders=translation_placeholders,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidUsername(InvalidUser):
|
|
||||||
"""Raised when invalid username is specified.
|
|
||||||
|
|
||||||
Will not be raised when validating authentication.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class Data:
|
class Data:
|
||||||
"""Hold the user data."""
|
"""Hold the user data."""
|
||||||
@@ -89,15 +67,13 @@ class Data:
|
|||||||
self._data: dict[str, list[dict[str, str]]] | None = None
|
self._data: dict[str, list[dict[str, str]]] | None = None
|
||||||
# Legacy mode will allow usernames to start/end with whitespace
|
# Legacy mode will allow usernames to start/end with whitespace
|
||||||
# and will compare usernames case-insensitive.
|
# and will compare usernames case-insensitive.
|
||||||
# Deprecated in June 2019 and will be removed in 2026.7
|
# Remove in 2020 or when we launch 1.0.
|
||||||
self.is_legacy = False
|
self.is_legacy = False
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def normalize_username(
|
def normalize_username(self, username: str) -> str:
|
||||||
self, username: str, *, force_normalize: bool = False
|
|
||||||
) -> str:
|
|
||||||
"""Normalize a username based on the mode."""
|
"""Normalize a username based on the mode."""
|
||||||
if self.is_legacy and not force_normalize:
|
if self.is_legacy:
|
||||||
return username
|
return username
|
||||||
|
|
||||||
return username.strip().casefold()
|
return username.strip().casefold()
|
||||||
@@ -107,49 +83,44 @@ class Data:
|
|||||||
if (data := await self._store.async_load()) is None:
|
if (data := await self._store.async_load()) is None:
|
||||||
data = cast(dict[str, list[dict[str, str]]], {"users": []})
|
data = cast(dict[str, list[dict[str, str]]], {"users": []})
|
||||||
|
|
||||||
self._async_check_for_not_normalized_usernames(data)
|
seen: set[str] = set()
|
||||||
self._data = data
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _async_check_for_not_normalized_usernames(
|
|
||||||
self, data: dict[str, list[dict[str, str]]]
|
|
||||||
) -> None:
|
|
||||||
not_normalized_usernames: set[str] = set()
|
|
||||||
|
|
||||||
for user in data["users"]:
|
for user in data["users"]:
|
||||||
username = user["username"]
|
username = user["username"]
|
||||||
|
|
||||||
if self.normalize_username(username, force_normalize=True) != username:
|
# check if we have duplicates
|
||||||
|
if (folded := username.casefold()) in seen:
|
||||||
|
self.is_legacy = True
|
||||||
|
|
||||||
logging.getLogger(__name__).warning(
|
logging.getLogger(__name__).warning(
|
||||||
(
|
(
|
||||||
"Home Assistant auth provider is running in legacy mode "
|
"Home Assistant auth provider is running in legacy mode "
|
||||||
"because we detected usernames that are normalized (lowercase and without spaces)."
|
"because we detected usernames that are case-insensitive"
|
||||||
" Please change the username: '%s'."
|
"equivalent. Please change the username: '%s'."
|
||||||
),
|
),
|
||||||
username,
|
username,
|
||||||
)
|
)
|
||||||
not_normalized_usernames.add(username)
|
|
||||||
|
|
||||||
if not_normalized_usernames:
|
break
|
||||||
self.is_legacy = True
|
|
||||||
ir.async_create_issue(
|
seen.add(folded)
|
||||||
self.hass,
|
|
||||||
"auth",
|
# check if we have unstripped usernames
|
||||||
"homeassistant_provider_not_normalized_usernames",
|
if username != username.strip():
|
||||||
breaks_in_ha_version="2026.7.0",
|
self.is_legacy = True
|
||||||
is_fixable=False,
|
|
||||||
severity=ir.IssueSeverity.WARNING,
|
logging.getLogger(__name__).warning(
|
||||||
translation_key="homeassistant_provider_not_normalized_usernames",
|
(
|
||||||
translation_placeholders={
|
"Home Assistant auth provider is running in legacy mode "
|
||||||
"usernames": f'- "{'"\n- "'.join(sorted(not_normalized_usernames))}"'
|
"because we detected usernames that start or end in a "
|
||||||
},
|
"space. Please change the username: '%s'."
|
||||||
learn_more_url="homeassistant://config/users",
|
),
|
||||||
)
|
username,
|
||||||
else:
|
)
|
||||||
self.is_legacy = False
|
|
||||||
ir.async_delete_issue(
|
break
|
||||||
self.hass, "auth", "homeassistant_provider_not_normalized_usernames"
|
|
||||||
)
|
self._data = data
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def users(self) -> list[dict[str, str]]:
|
def users(self) -> list[dict[str, str]]:
|
||||||
@@ -191,11 +162,13 @@ class Data:
|
|||||||
return hashed
|
return hashed
|
||||||
|
|
||||||
def add_auth(self, username: str, password: str) -> None:
|
def add_auth(self, username: str, password: str) -> None:
|
||||||
"""Add a new authenticated user/pass.
|
"""Add a new authenticated user/pass."""
|
||||||
|
username = self.normalize_username(username)
|
||||||
|
|
||||||
Raises InvalidUsername if the new username is invalid.
|
if any(
|
||||||
"""
|
self.normalize_username(user["username"]) == username for user in self.users
|
||||||
self._validate_new_username(username)
|
):
|
||||||
|
raise InvalidUser
|
||||||
|
|
||||||
self.users.append(
|
self.users.append(
|
||||||
{
|
{
|
||||||
@@ -216,7 +189,7 @@ class Data:
|
|||||||
break
|
break
|
||||||
|
|
||||||
if index is None:
|
if index is None:
|
||||||
raise InvalidUser(translation_key="user_not_found")
|
raise InvalidUser
|
||||||
|
|
||||||
self.users.pop(index)
|
self.users.pop(index)
|
||||||
|
|
||||||
@@ -232,50 +205,7 @@ class Data:
|
|||||||
user["password"] = self.hash_password(new_password, True).decode()
|
user["password"] = self.hash_password(new_password, True).decode()
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
raise InvalidUser(translation_key="user_not_found")
|
raise InvalidUser
|
||||||
|
|
||||||
@callback
|
|
||||||
def _validate_new_username(self, new_username: str) -> None:
|
|
||||||
"""Validate that username is normalized and unique.
|
|
||||||
|
|
||||||
Raises InvalidUsername if the new username is invalid.
|
|
||||||
"""
|
|
||||||
normalized_username = self.normalize_username(
|
|
||||||
new_username, force_normalize=True
|
|
||||||
)
|
|
||||||
if normalized_username != new_username:
|
|
||||||
raise InvalidUsername(
|
|
||||||
translation_key="username_not_normalized",
|
|
||||||
translation_placeholders={"new_username": new_username},
|
|
||||||
)
|
|
||||||
|
|
||||||
if any(
|
|
||||||
self.normalize_username(user["username"]) == normalized_username
|
|
||||||
for user in self.users
|
|
||||||
):
|
|
||||||
raise InvalidUsername(
|
|
||||||
translation_key="username_already_exists",
|
|
||||||
translation_placeholders={"username": new_username},
|
|
||||||
)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def change_username(self, username: str, new_username: str) -> None:
|
|
||||||
"""Update the username.
|
|
||||||
|
|
||||||
Raises InvalidUser if user cannot be found.
|
|
||||||
Raises InvalidUsername if the new username is invalid.
|
|
||||||
"""
|
|
||||||
username = self.normalize_username(username)
|
|
||||||
self._validate_new_username(new_username)
|
|
||||||
|
|
||||||
for user in self.users:
|
|
||||||
if self.normalize_username(user["username"]) == username:
|
|
||||||
user["username"] = new_username
|
|
||||||
assert self._data is not None
|
|
||||||
self._async_check_for_not_normalized_usernames(self._data)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise InvalidUser(translation_key="user_not_found")
|
|
||||||
|
|
||||||
async def async_save(self) -> None:
|
async def async_save(self) -> None:
|
||||||
"""Save data."""
|
"""Save data."""
|
||||||
@@ -305,7 +235,7 @@ class HassAuthProvider(AuthProvider):
|
|||||||
await data.async_load()
|
await data.async_load()
|
||||||
self.data = data
|
self.data = data
|
||||||
|
|
||||||
async def async_login_flow(self, context: AuthFlowContext | None) -> 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,7 +316,7 @@ 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(
|
||||||
@@ -411,7 +327,7 @@ class HassLoginFlow(LoginFlow[HassAuthProvider]):
|
|||||||
|
|
||||||
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:
|
||||||
|
@@ -4,13 +4,14 @@ 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.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
from ..models import AuthFlowContext, AuthFlowResult, Credentials, UserMeta
|
from ..models import AuthFlowResult, 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,7 +93,7 @@ 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(
|
||||||
@@ -105,7 +104,7 @@ class ExampleLoginFlow(LoginFlow[ExampleAuthProvider]):
|
|||||||
|
|
||||||
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:
|
||||||
|
123
homeassistant/auth/providers/legacy_api_password.py
Normal file
123
homeassistant/auth/providers/legacy_api_password.py
Normal 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.exceptions import HomeAssistantError
|
||||||
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||||
|
|
||||||
|
from ..models import AuthFlowResult, 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
|
||||||
|
) -> AuthFlowResult:
|
||||||
|
"""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,
|
||||||
|
)
|
@@ -21,21 +21,15 @@ import voluptuous as vol
|
|||||||
|
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
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 AuthFlowResult, 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__(
|
||||||
@@ -237,7 +229,9 @@ class TrustedNetworksLoginFlow(LoginFlow[TrustedNetworksAuthProvider]):
|
|||||||
) -> AuthFlowResult:
|
) -> AuthFlowResult:
|
||||||
"""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")
|
||||||
|
16
homeassistant/backports/enum.py
Normal file
16
homeassistant/backports/enum.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""Enum backports from standard lib.
|
||||||
|
|
||||||
|
This file contained the backport of the StrEnum of Python 3.11.
|
||||||
|
|
||||||
|
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
|
||||||
|
import it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"StrEnum",
|
||||||
|
]
|
81
homeassistant/backports/functools.py
Normal file
81
homeassistant/backports/functools.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Functools backports from standard lib."""
|
||||||
|
|
||||||
|
# This file contains parts of Python's module wrapper
|
||||||
|
# for the _functools C module
|
||||||
|
# to allow utilities written in Python to be added
|
||||||
|
# to the functools module.
|
||||||
|
# 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 collections.abc import Callable
|
||||||
|
from types import GenericAlias
|
||||||
|
from typing import Any, Generic, Self, TypeVar, overload
|
||||||
|
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
|
||||||
|
class cached_property(Generic[_T]):
|
||||||
|
"""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})."
|
||||||
|
)
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def __get__(self, instance: None, owner: type[Any] | None = None) -> Self:
|
||||||
|
...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def __get__(self, instance: Any, owner: type[Any] | None = None) -> _T:
|
||||||
|
...
|
||||||
|
|
||||||
|
def __get__(
|
||||||
|
self, instance: Any | None, owner: type[Any] | None = None
|
||||||
|
) -> _T | Self:
|
||||||
|
"""Get."""
|
||||||
|
if instance is None:
|
||||||
|
return self
|
||||||
|
if self.attrname is None:
|
||||||
|
raise TypeError(
|
||||||
|
"Cannot use cached_property instance without calling __set_name__ on it."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
cache = instance.__dict__
|
||||||
|
# not all objects have __dict__ (e.g. class defines slots)
|
||||||
|
except AttributeError:
|
||||||
|
msg = (
|
||||||
|
f"No '__dict__' attribute on {type(instance).__name__!r} "
|
||||||
|
f"instance to cache {self.attrname!r} property."
|
||||||
|
)
|
||||||
|
raise TypeError(msg) from None
|
||||||
|
val = self.func(instance)
|
||||||
|
try:
|
||||||
|
cache[self.attrname] = val
|
||||||
|
except TypeError:
|
||||||
|
msg = (
|
||||||
|
f"The '__dict__' attribute on {type(instance).__name__!r} instance "
|
||||||
|
f"does not support item assignment for caching {self.attrname!r} property."
|
||||||
|
)
|
||||||
|
raise TypeError(msg) from None
|
||||||
|
return val
|
||||||
|
|
||||||
|
__class_getitem__ = classmethod(GenericAlias) # type: ignore[var-annotated]
|
@@ -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
|
|
@@ -1,267 +1,21 @@
|
|||||||
"""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()
|
# Prevent sleeping in event loop. Non-strict since 2022.02
|
||||||
for blocking_call in _BLOCKING_CALLS:
|
time.sleep = protect_loop(time.sleep, strict=False)
|
||||||
if _IN_TESTS and blocking_call.skip_for_tests:
|
|
||||||
continue
|
|
||||||
|
|
||||||
protected_function = protect_loop(
|
# Currently disabled. pytz doing I/O when getting timezone.
|
||||||
blocking_call.original_func,
|
# Prevent files being opened inside the event loop
|
||||||
strict=blocking_call.strict,
|
# builtins.open = protect_loop(builtins.open)
|
||||||
strict_core=blocking_call.strict_core,
|
|
||||||
check_allowed=blocking_call.check_allowed,
|
|
||||||
loop_thread_id=loop_thread_id,
|
|
||||||
)
|
|
||||||
setattr(blocking_call.object, blocking_call.function, protected_function)
|
|
||||||
calls.add(blocking_call)
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,5 @@
|
|||||||
{
|
{
|
||||||
"domain": "amazon",
|
"domain": "amazon",
|
||||||
"name": "Amazon",
|
"name": "Amazon",
|
||||||
"integrations": [
|
"integrations": ["alexa", "amazon_polly", "aws", "fire_tv", "route53"]
|
||||||
"alexa",
|
|
||||||
"alexa_devices",
|
|
||||||
"amazon_polly",
|
|
||||||
"aws",
|
|
||||||
"aws_s3",
|
|
||||||
"fire_tv",
|
|
||||||
"route53"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "ambient_weather",
|
|
||||||
"name": "Ambient Weather",
|
|
||||||
"integrations": ["ambient_network", "ambient_station"]
|
|
||||||
}
|
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "aqara",
|
|
||||||
"name": "Aqara",
|
|
||||||
"iot_standards": ["matter", "zigbee"]
|
|
||||||
}
|
|
5
homeassistant/brands/asterisk.json
Normal file
5
homeassistant/brands/asterisk.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"domain": "asterisk",
|
||||||
|
"name": "Asterisk",
|
||||||
|
"integrations": ["asterisk_cdr", "asterisk_mbox"]
|
||||||
|
}
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "bosch",
|
|
||||||
"name": "Bosch",
|
|
||||||
"integrations": ["bosch_alarm", "bosch_shc", "home_connect"]
|
|
||||||
}
|
|
5
homeassistant/brands/epson.json
Normal file
5
homeassistant/brands/epson.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"domain": "epson",
|
||||||
|
"name": "Epson",
|
||||||
|
"integrations": ["epson", "epsonworkforce"]
|
||||||
|
}
|
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"domain": "eq3",
|
"domain": "eq3",
|
||||||
"name": "eQ-3",
|
"name": "eQ-3",
|
||||||
"integrations": ["maxcube", "eq3btsmart"]
|
"integrations": ["maxcube"]
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "eve",
|
|
||||||
"name": "Eve",
|
|
||||||
"iot_standards": ["matter"]
|
|
||||||
}
|
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "fujitsu",
|
|
||||||
"name": "Fujitsu",
|
|
||||||
"integrations": ["fujitsu_anywair", "fujitsu_fglair"]
|
|
||||||
}
|
|
@@ -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",
|
||||||
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "husqvarna",
|
|
||||||
"name": "Husqvarna",
|
|
||||||
"integrations": ["husqvarna_automower", "husqvarna_automower_ble"]
|
|
||||||
}
|
|
@@ -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"]
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"domain": "logitech",
|
"domain": "logitech",
|
||||||
"name": "Logitech",
|
"name": "Logitech",
|
||||||
"integrations": ["harmony", "squeezebox"]
|
"integrations": ["harmony", "ue_smart_radio", "squeezebox"]
|
||||||
}
|
}
|
||||||
|
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "motionblinds",
|
|
||||||
"name": "Motionblinds",
|
|
||||||
"integrations": ["motion_blinds", "motionblinds_ble"],
|
|
||||||
"iot_standards": ["matter"]
|
|
||||||
}
|
|
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "nuki",
|
|
||||||
"name": "Nuki",
|
|
||||||
"integrations": ["nuki"],
|
|
||||||
"iot_standards": ["matter"]
|
|
||||||
}
|
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "roth",
|
|
||||||
"name": "Roth",
|
|
||||||
"integrations": ["touchline", "touchline_sl"]
|
|
||||||
}
|
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "ruuvi",
|
|
||||||
"name": "Ruuvi",
|
|
||||||
"integrations": ["ruuvi_gateway", "ruuvitag_ble"]
|
|
||||||
}
|
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "sensorpush",
|
|
||||||
"name": "SensorPush",
|
|
||||||
"integrations": ["sensorpush", "sensorpush_cloud"]
|
|
||||||
}
|
|
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "shelly",
|
|
||||||
"name": "shelly",
|
|
||||||
"integrations": ["shelly"],
|
|
||||||
"iot_standards": ["zwave"]
|
|
||||||
}
|
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "sky",
|
|
||||||
"name": "Sky",
|
|
||||||
"integrations": ["sky_hub", "sky_remote"]
|
|
||||||
}
|
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "slide",
|
|
||||||
"name": "Slide",
|
|
||||||
"integrations": ["slide", "slide_local"]
|
|
||||||
}
|
|
@@ -1,11 +1,5 @@
|
|||||||
{
|
{
|
||||||
"domain": "sony",
|
"domain": "sony",
|
||||||
"name": "Sony",
|
"name": "Sony",
|
||||||
"integrations": [
|
"integrations": ["braviatv", "ps4", "sony_projector", "songpal"]
|
||||||
"braviatv",
|
|
||||||
"ps4",
|
|
||||||
"sony_projector",
|
|
||||||
"songpal",
|
|
||||||
"playstation_network"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"domain": "switchbot",
|
"domain": "switchbot",
|
||||||
"name": "SwitchBot",
|
"name": "SwitchBot",
|
||||||
"integrations": ["switchbot", "switchbot_cloud"],
|
"integrations": ["switchbot", "switchbot_cloud"]
|
||||||
"iot_standards": ["matter"]
|
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"domain": "tesla",
|
"domain": "tesla",
|
||||||
"name": "Tesla",
|
"name": "Tesla",
|
||||||
"integrations": ["powerwall", "tesla_wall_connector", "tesla_fleet"]
|
"integrations": ["powerwall", "tesla_wall_connector"]
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "tilt",
|
|
||||||
"name": "Tilt",
|
|
||||||
"integrations": ["tilt_ble", "tilt_pi"]
|
|
||||||
}
|
|
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"domain": "weatherflow",
|
|
||||||
"name": "WeatherFlow",
|
|
||||||
"integrations": ["weatherflow", "weatherflow_cloud"]
|
|
||||||
}
|
|
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
@@ -6,3 +6,52 @@ 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
|
||||||
|
from homeassistant.helpers.frame import report
|
||||||
|
from homeassistant.helpers.group import expand_entity_ids
|
||||||
|
|
||||||
|
_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.
|
||||||
|
"""
|
||||||
|
report(
|
||||||
|
(
|
||||||
|
"uses homeassistant.components.is_on."
|
||||||
|
" This is deprecated and will stop working in Home Assistant 2024.9, it"
|
||||||
|
" should be updated to use the function of the platform directly."
|
||||||
|
),
|
||||||
|
error_if_core=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if entity_id:
|
||||||
|
entity_ids = expand_entity_ids(hass, [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
|
||||||
|
@@ -4,34 +4,40 @@ 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,
|
||||||
)
|
)
|
||||||
from jaraco.abode.helpers.timeline import Groups as GROUPS
|
from jaraco.abode.helpers.timeline import Groups as GROUPS
|
||||||
from requests.exceptions import ConnectTimeout, HTTPError
|
from requests.exceptions import ConnectTimeout, HTTPError
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_DATE,
|
ATTR_DATE,
|
||||||
ATTR_DEVICE_ID,
|
ATTR_DEVICE_ID,
|
||||||
|
ATTR_ENTITY_ID,
|
||||||
ATTR_TIME,
|
ATTR_TIME,
|
||||||
CONF_PASSWORD,
|
CONF_PASSWORD,
|
||||||
CONF_USERNAME,
|
CONF_USERNAME,
|
||||||
EVENT_HOMEASSISTANT_STOP,
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
Platform,
|
Platform,
|
||||||
)
|
)
|
||||||
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant
|
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.typing import ConfigType
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
from homeassistant.helpers.dispatcher import dispatcher_send
|
||||||
|
|
||||||
from .const import CONF_POLLING, DOMAIN, LOGGER
|
from .const import ATTRIBUTION, CONF_POLLING, DOMAIN, LOGGER
|
||||||
from .services import async_setup_services
|
|
||||||
|
SERVICE_SETTINGS = "change_setting"
|
||||||
|
SERVICE_CAPTURE_IMAGE = "capture_image"
|
||||||
|
SERVICE_TRIGGER_AUTOMATION = "trigger_automation"
|
||||||
|
|
||||||
ATTR_DEVICE_NAME = "device_name"
|
ATTR_DEVICE_NAME = "device_name"
|
||||||
ATTR_DEVICE_TYPE = "device_type"
|
ATTR_DEVICE_TYPE = "device_type"
|
||||||
@@ -39,12 +45,22 @@ ATTR_EVENT_CODE = "event_code"
|
|||||||
ATTR_EVENT_NAME = "event_name"
|
ATTR_EVENT_NAME = "event_name"
|
||||||
ATTR_EVENT_TYPE = "event_type"
|
ATTR_EVENT_TYPE = "event_type"
|
||||||
ATTR_EVENT_UTC = "event_utc"
|
ATTR_EVENT_UTC = "event_utc"
|
||||||
|
ATTR_SETTING = "setting"
|
||||||
ATTR_USER_NAME = "user_name"
|
ATTR_USER_NAME = "user_name"
|
||||||
ATTR_APP_TYPE = "app_type"
|
ATTR_APP_TYPE = "app_type"
|
||||||
ATTR_EVENT_BY = "event_by"
|
ATTR_EVENT_BY = "event_by"
|
||||||
|
ATTR_VALUE = "value"
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
|
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
|
||||||
|
|
||||||
|
CHANGE_SETTING_SCHEMA = vol.Schema(
|
||||||
|
{vol.Required(ATTR_SETTING): cv.string, vol.Required(ATTR_VALUE): cv.string}
|
||||||
|
)
|
||||||
|
|
||||||
|
CAPTURE_IMAGE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -67,21 +83,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."""
|
|
||||||
async_setup_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(
|
||||||
@@ -104,6 +111,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
|
||||||
@@ -111,6 +119,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)
|
||||||
@@ -122,6 +134,60 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
|
def setup_hass_services(hass: HomeAssistant) -> None:
|
||||||
|
"""Home Assistant services."""
|
||||||
|
|
||||||
|
def change_setting(call: ServiceCall) -> None:
|
||||||
|
"""Change an Abode system setting."""
|
||||||
|
setting = call.data[ATTR_SETTING]
|
||||||
|
value = call.data[ATTR_VALUE]
|
||||||
|
|
||||||
|
try:
|
||||||
|
hass.data[DOMAIN].abode.set_setting(setting, value)
|
||||||
|
except AbodeException as ex:
|
||||||
|
LOGGER.warning(ex)
|
||||||
|
|
||||||
|
def capture_image(call: ServiceCall) -> None:
|
||||||
|
"""Capture a new image."""
|
||||||
|
entity_ids = call.data[ATTR_ENTITY_ID]
|
||||||
|
|
||||||
|
target_entities = [
|
||||||
|
entity_id
|
||||||
|
for entity_id in hass.data[DOMAIN].entity_ids
|
||||||
|
if entity_id in entity_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
for entity_id in target_entities:
|
||||||
|
signal = f"abode_camera_capture_{entity_id}"
|
||||||
|
dispatcher_send(hass, signal)
|
||||||
|
|
||||||
|
def trigger_automation(call: ServiceCall) -> None:
|
||||||
|
"""Trigger an Abode automation."""
|
||||||
|
entity_ids = call.data[ATTR_ENTITY_ID]
|
||||||
|
|
||||||
|
target_entities = [
|
||||||
|
entity_id
|
||||||
|
for entity_id in hass.data[DOMAIN].entity_ids
|
||||||
|
if entity_id in entity_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
for entity_id in target_entities:
|
||||||
|
signal = f"abode_trigger_automation_{entity_id}"
|
||||||
|
dispatcher_send(hass, signal)
|
||||||
|
|
||||||
|
hass.services.register(
|
||||||
|
DOMAIN, SERVICE_SETTINGS, change_setting, schema=CHANGE_SETTING_SCHEMA
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.services.register(
|
||||||
|
DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image, schema=CAPTURE_IMAGE_SCHEMA
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.services.register(
|
||||||
|
DOMAIN, SERVICE_TRIGGER_AUTOMATION, trigger_automation, schema=AUTOMATION_SCHEMA
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def setup_hass_events(hass: HomeAssistant) -> None:
|
async def setup_hass_events(hass: HomeAssistant) -> None:
|
||||||
"""Home Assistant start and stop callbacks."""
|
"""Home Assistant start and stop callbacks."""
|
||||||
|
|
||||||
@@ -181,3 +247,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()
|
||||||
|
@@ -7,21 +7,22 @@ from jaraco.abode.devices.alarm import Alarm
|
|||||||
from homeassistant.components.alarm_control_panel import (
|
from homeassistant.components.alarm_control_panel import (
|
||||||
AlarmControlPanelEntity,
|
AlarmControlPanelEntity,
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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]
|
||||||
@@ -42,14 +43,14 @@ class AbodeAlarm(AbodeDevice, AlarmControlPanelEntity):
|
|||||||
_device: Alarm
|
_device: Alarm
|
||||||
|
|
||||||
@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:
|
||||||
|
@@ -4,7 +4,14 @@ 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
|
||||||
|
from jaraco.abode.helpers.constants import (
|
||||||
|
TYPE_CONNECTIVITY,
|
||||||
|
TYPE_MOISTURE,
|
||||||
|
TYPE_MOTION,
|
||||||
|
TYPE_OCCUPANCY,
|
||||||
|
TYPE_OPENING,
|
||||||
|
)
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import (
|
||||||
BinarySensorDeviceClass,
|
BinarySensorDeviceClass,
|
||||||
@@ -12,28 +19,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",
|
TYPE_CONNECTIVITY,
|
||||||
"moisture",
|
TYPE_MOISTURE,
|
||||||
"motion",
|
TYPE_MOTION,
|
||||||
"occupancy",
|
TYPE_OCCUPANCY,
|
||||||
"door",
|
TYPE_OPENING,
|
||||||
]
|
]
|
||||||
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
|
@@ -8,6 +8,7 @@ from typing import Any, cast
|
|||||||
from jaraco.abode.devices.base import Device
|
from jaraco.abode.devices.base import Device
|
||||||
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 timeline
|
||||||
|
from jaraco.abode.helpers.constants import TYPE_CAMERA
|
||||||
import requests
|
import requests
|
||||||
from requests.models import Response
|
from requests.models import Response
|
||||||
|
|
||||||
@@ -15,27 +16,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=TYPE_CAMERA)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -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
|
||||||
@@ -112,6 +120,9 @@ class AbodeFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""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)
|
||||||
|
@@ -3,28 +3,26 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from jaraco.abode.devices.cover import Cover
|
from jaraco.abode.devices.cover import Cover
|
||||||
|
from jaraco.abode.helpers.constants import TYPE_COVER
|
||||||
|
|
||||||
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=TYPE_COVER)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -1,115 +0,0 @@
|
|||||||
"""Support for Abode Security System entities."""
|
|
||||||
|
|
||||||
from jaraco.abode.automation import Automation as AbodeAuto
|
|
||||||
from jaraco.abode.devices.base import Device as AbodeDev
|
|
||||||
|
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
|
||||||
from homeassistant.helpers.entity import Entity
|
|
||||||
|
|
||||||
from . import AbodeSystem
|
|
||||||
from .const import ATTRIBUTION, DOMAIN
|
|
||||||
|
|
||||||
|
|
||||||
class AbodeEntity(Entity):
|
|
||||||
"""Representation of an Abode entity."""
|
|
||||||
|
|
||||||
_attr_attribution = ATTRIBUTION
|
|
||||||
_attr_has_entity_name = True
|
|
||||||
|
|
||||||
def __init__(self, data: AbodeSystem) -> None:
|
|
||||||
"""Initialize Abode entity."""
|
|
||||||
self._data = data
|
|
||||||
self._attr_should_poll = data.polling
|
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
|
||||||
"""Subscribe to Abode connection status updates."""
|
|
||||||
await self.hass.async_add_executor_job(
|
|
||||||
self._data.abode.events.add_connection_status_callback,
|
|
||||||
self.unique_id,
|
|
||||||
self._update_connection_status,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.hass.data[DOMAIN].entity_ids.add(self.entity_id)
|
|
||||||
|
|
||||||
async def async_will_remove_from_hass(self) -> None:
|
|
||||||
"""Unsubscribe from Abode connection status updates."""
|
|
||||||
await self.hass.async_add_executor_job(
|
|
||||||
self._data.abode.events.remove_connection_status_callback, self.unique_id
|
|
||||||
)
|
|
||||||
|
|
||||||
def _update_connection_status(self) -> None:
|
|
||||||
"""Update the entity available property."""
|
|
||||||
self._attr_available = self._data.abode.events.connected
|
|
||||||
self.schedule_update_ha_state()
|
|
||||||
|
|
||||||
|
|
||||||
class AbodeDevice(AbodeEntity):
|
|
||||||
"""Representation of an Abode device."""
|
|
||||||
|
|
||||||
def __init__(self, data: AbodeSystem, device: AbodeDev) -> None:
|
|
||||||
"""Initialize Abode device."""
|
|
||||||
super().__init__(data)
|
|
||||||
self._device = device
|
|
||||||
self._attr_unique_id = device.uuid
|
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
|
||||||
"""Subscribe to device events."""
|
|
||||||
await super().async_added_to_hass()
|
|
||||||
await self.hass.async_add_executor_job(
|
|
||||||
self._data.abode.events.add_device_callback,
|
|
||||||
self._device.id,
|
|
||||||
self._update_callback,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_will_remove_from_hass(self) -> None:
|
|
||||||
"""Unsubscribe from device events."""
|
|
||||||
await super().async_will_remove_from_hass()
|
|
||||||
await self.hass.async_add_executor_job(
|
|
||||||
self._data.abode.events.remove_all_device_callbacks, self._device.id
|
|
||||||
)
|
|
||||||
|
|
||||||
def update(self) -> None:
|
|
||||||
"""Update device state."""
|
|
||||||
self._device.refresh()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def extra_state_attributes(self) -> dict[str, str]:
|
|
||||||
"""Return the state attributes."""
|
|
||||||
return {
|
|
||||||
"device_id": self._device.id,
|
|
||||||
"battery_low": self._device.battery_low,
|
|
||||||
"no_response": self._device.no_response,
|
|
||||||
"device_type": self._device.type,
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def device_info(self) -> DeviceInfo:
|
|
||||||
"""Return device registry information for this entity."""
|
|
||||||
return DeviceInfo(
|
|
||||||
identifiers={(DOMAIN, self._device.id)},
|
|
||||||
manufacturer="Abode",
|
|
||||||
model=self._device.type,
|
|
||||||
name=self._device.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _update_callback(self, device: AbodeDev) -> None:
|
|
||||||
"""Update the device state."""
|
|
||||||
self.schedule_update_ha_state()
|
|
||||||
|
|
||||||
|
|
||||||
class AbodeAutomation(AbodeEntity):
|
|
||||||
"""Representation of an Abode automation."""
|
|
||||||
|
|
||||||
def __init__(self, data: AbodeSystem, automation: AbodeAuto) -> None:
|
|
||||||
"""Initialize for Abode automation."""
|
|
||||||
super().__init__(data)
|
|
||||||
self._automation = automation
|
|
||||||
self._attr_name = automation.name
|
|
||||||
self._attr_unique_id = automation.id
|
|
||||||
self._attr_extra_state_attributes = {
|
|
||||||
"type": "CUE automation",
|
|
||||||
}
|
|
||||||
|
|
||||||
def update(self) -> None:
|
|
||||||
"""Update automation state."""
|
|
||||||
self._automation.refresh()
|
|
@@ -5,16 +5,5 @@
|
|||||||
"default": "mdi:robot"
|
"default": "mdi:robot"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"services": {
|
|
||||||
"capture_image": {
|
|
||||||
"service": "mdi:camera"
|
|
||||||
},
|
|
||||||
"change_setting": {
|
|
||||||
"service": "mdi:cog"
|
|
||||||
},
|
|
||||||
"trigger_automation": {
|
|
||||||
"service": "mdi:play"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -6,36 +6,36 @@ from math import ceil
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from jaraco.abode.devices.light import Light
|
from jaraco.abode.devices.light import Light
|
||||||
|
from jaraco.abode.helpers.constants import TYPE_LIGHT
|
||||||
|
|
||||||
from homeassistant.components.light import (
|
from homeassistant.components.light import (
|
||||||
ATTR_BRIGHTNESS,
|
ATTR_BRIGHTNESS,
|
||||||
ATTR_COLOR_TEMP_KELVIN,
|
ATTR_COLOR_TEMP,
|
||||||
ATTR_HS_COLOR,
|
ATTR_HS_COLOR,
|
||||||
DEFAULT_MAX_KELVIN,
|
|
||||||
DEFAULT_MIN_KELVIN,
|
|
||||||
ColorMode,
|
ColorMode,
|
||||||
LightEntity,
|
LightEntity,
|
||||||
)
|
)
|
||||||
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.color import (
|
||||||
|
color_temperature_kelvin_to_mired,
|
||||||
|
color_temperature_mired_to_kelvin,
|
||||||
|
)
|
||||||
|
|
||||||
from . import AbodeSystem
|
from . import AbodeDevice, AbodeSystem
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .entity import AbodeDevice
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
entry: ConfigEntry,
|
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Abode light devices."""
|
"""Set up Abode light devices."""
|
||||||
data: AbodeSystem = hass.data[DOMAIN]
|
data: AbodeSystem = hass.data[DOMAIN]
|
||||||
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
AbodeLight(data, device)
|
AbodeLight(data, device)
|
||||||
for device in data.abode.get_devices(generic_type="light")
|
for device in data.abode.get_devices(generic_type=TYPE_LIGHT)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -44,13 +44,13 @@ class AbodeLight(AbodeDevice, LightEntity):
|
|||||||
|
|
||||||
_device: Light
|
_device: Light
|
||||||
_attr_name = None
|
_attr_name = None
|
||||||
_attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN
|
|
||||||
_attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN
|
|
||||||
|
|
||||||
def turn_on(self, **kwargs: Any) -> None:
|
def turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn on the light."""
|
"""Turn on the light."""
|
||||||
if ATTR_COLOR_TEMP_KELVIN in kwargs and self._device.is_color_capable:
|
if ATTR_COLOR_TEMP in kwargs and self._device.is_color_capable:
|
||||||
self._device.set_color_temp(kwargs[ATTR_COLOR_TEMP_KELVIN])
|
self._device.set_color_temp(
|
||||||
|
int(color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]))
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if ATTR_HS_COLOR in kwargs and self._device.is_color_capable:
|
if ATTR_HS_COLOR in kwargs and self._device.is_color_capable:
|
||||||
@@ -85,10 +85,10 @@ class AbodeLight(AbodeDevice, LightEntity):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def color_temp_kelvin(self) -> int | None:
|
def color_temp(self) -> int | None:
|
||||||
"""Return the color temp of the light."""
|
"""Return the color temp of the light."""
|
||||||
if self._device.has_color:
|
if self._device.has_color:
|
||||||
return int(self._device.color_temp)
|
return color_temperature_kelvin_to_mired(self._device.color_temp)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@@ -3,28 +3,26 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from jaraco.abode.devices.lock import Lock
|
from jaraco.abode.devices.lock import Lock
|
||||||
|
from jaraco.abode.helpers.constants import TYPE_LOCK
|
||||||
|
|
||||||
from homeassistant.components.lock import LockEntity
|
from homeassistant.components.lock import LockEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import 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 lock devices."""
|
"""Set up Abode lock devices."""
|
||||||
data: AbodeSystem = hass.data[DOMAIN]
|
data: AbodeSystem = hass.data[DOMAIN]
|
||||||
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
AbodeLock(data, device)
|
AbodeLock(data, device)
|
||||||
for device in data.abode.get_devices(generic_type="lock")
|
for device in data.abode.get_devices(generic_type=TYPE_LOCK)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -9,6 +9,5 @@
|
|||||||
},
|
},
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["jaraco.abode", "lomond"],
|
"loggers": ["jaraco.abode", "lomond"],
|
||||||
"requirements": ["jaraco.abode==6.2.1"],
|
"requirements": ["jaraco.abode==3.3.0", "jaraco.functools==3.9.0"]
|
||||||
"single_config_entry": true
|
|
||||||
}
|
}
|
||||||
|
@@ -7,6 +7,15 @@ from dataclasses import dataclass
|
|||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from jaraco.abode.devices.sensor import Sensor
|
from jaraco.abode.devices.sensor import Sensor
|
||||||
|
from jaraco.abode.helpers.constants import (
|
||||||
|
HUMI_STATUS_KEY,
|
||||||
|
LUX_STATUS_KEY,
|
||||||
|
STATUSES_KEY,
|
||||||
|
TEMP_STATUS_KEY,
|
||||||
|
TYPE_SENSOR,
|
||||||
|
UNIT_CELSIUS,
|
||||||
|
UNIT_FAHRENHEIT,
|
||||||
|
)
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
@@ -16,15 +25,14 @@ from homeassistant.components.sensor import (
|
|||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature
|
from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature
|
||||||
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
|
|
||||||
|
|
||||||
ABODE_TEMPERATURE_UNIT_HA_UNIT = {
|
ABODE_TEMPERATURE_UNIT_HA_UNIT = {
|
||||||
"°F": UnitOfTemperature.FAHRENHEIT,
|
UNIT_FAHRENHEIT: UnitOfTemperature.FAHRENHEIT,
|
||||||
"°C": UnitOfTemperature.CELSIUS,
|
UNIT_CELSIUS: UnitOfTemperature.CELSIUS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -38,7 +46,7 @@ class AbodeSensorDescription(SensorEntityDescription):
|
|||||||
|
|
||||||
SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
|
SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
|
||||||
AbodeSensorDescription(
|
AbodeSensorDescription(
|
||||||
key="temperature",
|
key=TEMP_STATUS_KEY,
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
native_unit_of_measurement_fn=lambda device: ABODE_TEMPERATURE_UNIT_HA_UNIT[
|
native_unit_of_measurement_fn=lambda device: ABODE_TEMPERATURE_UNIT_HA_UNIT[
|
||||||
device.temp_unit
|
device.temp_unit
|
||||||
@@ -46,13 +54,13 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
|
|||||||
value_fn=lambda device: cast(float, device.temp),
|
value_fn=lambda device: cast(float, device.temp),
|
||||||
),
|
),
|
||||||
AbodeSensorDescription(
|
AbodeSensorDescription(
|
||||||
key="humidity",
|
key=HUMI_STATUS_KEY,
|
||||||
device_class=SensorDeviceClass.HUMIDITY,
|
device_class=SensorDeviceClass.HUMIDITY,
|
||||||
native_unit_of_measurement_fn=lambda _: PERCENTAGE,
|
native_unit_of_measurement_fn=lambda _: PERCENTAGE,
|
||||||
value_fn=lambda device: cast(float, device.humidity),
|
value_fn=lambda device: cast(float, device.humidity),
|
||||||
),
|
),
|
||||||
AbodeSensorDescription(
|
AbodeSensorDescription(
|
||||||
key="lux",
|
key=LUX_STATUS_KEY,
|
||||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||||
native_unit_of_measurement_fn=lambda _: LIGHT_LUX,
|
native_unit_of_measurement_fn=lambda _: LIGHT_LUX,
|
||||||
value_fn=lambda device: cast(float, device.lux),
|
value_fn=lambda device: cast(float, device.lux),
|
||||||
@@ -61,9 +69,7 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
|
|||||||
|
|
||||||
|
|
||||||
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 sensor devices."""
|
"""Set up Abode sensor devices."""
|
||||||
data: AbodeSystem = hass.data[DOMAIN]
|
data: AbodeSystem = hass.data[DOMAIN]
|
||||||
@@ -71,8 +77,8 @@ async def async_setup_entry(
|
|||||||
async_add_entities(
|
async_add_entities(
|
||||||
AbodeSensor(data, device, description)
|
AbodeSensor(data, device, description)
|
||||||
for description in SENSOR_TYPES
|
for description in SENSOR_TYPES
|
||||||
for device in data.abode.get_devices(generic_type="sensor")
|
for device in data.abode.get_devices(generic_type=TYPE_SENSOR)
|
||||||
if description.key in device.get_value("statuses")
|
if description.key in device.get_value(STATUSES_KEY)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -1,90 +0,0 @@
|
|||||||
"""Support for the Abode Security System."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from jaraco.abode.exceptions import Exception as AbodeException
|
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.const import ATTR_ENTITY_ID
|
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
|
||||||
from homeassistant.helpers import config_validation as cv
|
|
||||||
from homeassistant.helpers.dispatcher import dispatcher_send
|
|
||||||
|
|
||||||
from .const import DOMAIN, LOGGER
|
|
||||||
|
|
||||||
SERVICE_SETTINGS = "change_setting"
|
|
||||||
SERVICE_CAPTURE_IMAGE = "capture_image"
|
|
||||||
SERVICE_TRIGGER_AUTOMATION = "trigger_automation"
|
|
||||||
|
|
||||||
ATTR_SETTING = "setting"
|
|
||||||
ATTR_VALUE = "value"
|
|
||||||
|
|
||||||
|
|
||||||
CHANGE_SETTING_SCHEMA = vol.Schema(
|
|
||||||
{vol.Required(ATTR_SETTING): cv.string, vol.Required(ATTR_VALUE): cv.string}
|
|
||||||
)
|
|
||||||
|
|
||||||
CAPTURE_IMAGE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
|
|
||||||
|
|
||||||
AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
|
|
||||||
|
|
||||||
|
|
||||||
def _change_setting(call: ServiceCall) -> None:
|
|
||||||
"""Change an Abode system setting."""
|
|
||||||
setting = call.data[ATTR_SETTING]
|
|
||||||
value = call.data[ATTR_VALUE]
|
|
||||||
|
|
||||||
try:
|
|
||||||
call.hass.data[DOMAIN].abode.set_setting(setting, value)
|
|
||||||
except AbodeException as ex:
|
|
||||||
LOGGER.warning(ex)
|
|
||||||
|
|
||||||
|
|
||||||
def _capture_image(call: ServiceCall) -> None:
|
|
||||||
"""Capture a new image."""
|
|
||||||
entity_ids = call.data[ATTR_ENTITY_ID]
|
|
||||||
|
|
||||||
target_entities = [
|
|
||||||
entity_id
|
|
||||||
for entity_id in call.hass.data[DOMAIN].entity_ids
|
|
||||||
if entity_id in entity_ids
|
|
||||||
]
|
|
||||||
|
|
||||||
for entity_id in target_entities:
|
|
||||||
signal = f"abode_camera_capture_{entity_id}"
|
|
||||||
dispatcher_send(call.hass, signal)
|
|
||||||
|
|
||||||
|
|
||||||
def _trigger_automation(call: ServiceCall) -> None:
|
|
||||||
"""Trigger an Abode automation."""
|
|
||||||
entity_ids = call.data[ATTR_ENTITY_ID]
|
|
||||||
|
|
||||||
target_entities = [
|
|
||||||
entity_id
|
|
||||||
for entity_id in call.hass.data[DOMAIN].entity_ids
|
|
||||||
if entity_id in entity_ids
|
|
||||||
]
|
|
||||||
|
|
||||||
for entity_id in target_entities:
|
|
||||||
signal = f"abode_trigger_automation_{entity_id}"
|
|
||||||
dispatcher_send(call.hass, signal)
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_setup_services(hass: HomeAssistant) -> None:
|
|
||||||
"""Home Assistant services."""
|
|
||||||
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN, SERVICE_SETTINGS, _change_setting, schema=CHANGE_SETTING_SCHEMA
|
|
||||||
)
|
|
||||||
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN, SERVICE_CAPTURE_IMAGE, _capture_image, schema=CAPTURE_IMAGE_SCHEMA
|
|
||||||
)
|
|
||||||
|
|
||||||
hass.services.async_register(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_TRIGGER_AUTOMATION,
|
|
||||||
_trigger_automation,
|
|
||||||
schema=AUTOMATION_SCHEMA,
|
|
||||||
)
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user